refactor: basically finish core refactor

This commit is contained in:
2026-09-02 13:36:41 +08:00
parent bcac59ceb1
commit 63ba6d44f7
11 changed files with 244 additions and 117 deletions
+37 -14
View File
@@ -1,11 +1,39 @@
import logging
from pathlib import Path
from dataclasses import dataclass
from .assemblicon.cli import ArgParserSetup, create_argparser, parse_standard_opts
from typing import Optional
from .assemblicon.cli import (
SvgRenderKind,
StdOptsSetup,
create_argparser,
parse_standard_opts,
)
from .assemblicon.highlevel.environment import Prologue, Environment
from .assemblicon.highlevel.context import Context
from .std_icons import build_standard_icons
from .nostd_icons import build_non_standard_icons
from .assemblicon.logger import APP_LOGGER as LOGGER
from . import std_icons, nostd_icons
@dataclass(frozen=True)
class AuroraOpts:
selection: Optional[list[str]]
"""The list holding all name of jobs for building."""
svg_render: SvgRenderKind
"""The render kind for SVG file."""
def parse_opts() -> AuroraOpts:
setup = StdOptsSetup(require_input=False, require_output=False)
parser = create_argparser(setup)
parser.prog = "Aurora Icon Set Builder"
parser.description = "The Programmatic Icon Assembly Workflow for Aurora Icon Set."
ns = parser.parse_args()
opts = parse_standard_opts(setup, ns)
return AuroraOpts(
selection=opts.selection, svg_render=opts.svg_render.to_assemblicon_type()
)
@dataclass(frozen=True)
@@ -16,17 +44,12 @@ class AuroraPrologue(Prologue):
def main() -> None:
parser_cfg = ArgParserSetup(require_input=False, require_output=False)
parser = create_argparser(parser_cfg)
ns = parser.parse_args()
opts = parse_standard_opts(parser_cfg, ns)
opts = parse_opts()
repository_root = Path(__file__).resolve().parent.parent.parent.parent
iconset_input = repository_root / "src"
iconset_output = repository_root / "artifact"
prologue = AuroraPrologue(
iconset_input, iconset_output, opts.svg_render.to_assemblicon_type()
)
prologue = AuroraPrologue(iconset_input, iconset_output, opts.svg_render)
env = Environment(prologue)
ctx = Context(env)
@@ -34,9 +57,9 @@ def main() -> None:
register(ctx)
ctx.run(opts.selection)
except BaseException as e:
logging.fatal("Runtime error: %s", e)
LOGGER.fatal("Runtime error: %s", e)
def register(ctx: Context) -> None:
build_standard_icons(ctx)
build_non_standard_icons(ctx)
std_icons.register(ctx)
nostd_icons.register(ctx)
@@ -33,14 +33,14 @@ class StdOpts:
@dataclass(frozen=True)
class ArgParserSetup:
class StdOptsSetup:
require_input: bool
"""Whether add input path option in argument parser."""
require_output: bool
"""Whether add outputput path option in argument parser."""
def create_argparser(cfg: ArgParserSetup) -> ArgumentParser:
def create_argparser(setup: StdOptsSetup) -> ArgumentParser:
"""
Create Assemblicon standard argument parser.
@@ -54,7 +54,7 @@ def create_argparser(cfg: ArgParserSetup) -> ArgumentParser:
prog="Assemblicon",
description="The Programmatic Icon Assembly Workflow.",
)
if cfg.require_input:
if setup.require_input:
parser.add_argument(
"-i",
"--input",
@@ -65,7 +65,7 @@ def create_argparser(cfg: ArgParserSetup) -> ArgumentParser:
help="The path to input directory.",
metavar="DIR",
)
if cfg.require_output:
if setup.require_output:
parser.add_argument(
"-o",
"--output",
@@ -99,7 +99,7 @@ def create_argparser(cfg: ArgParserSetup) -> ArgumentParser:
return parser
def parse_standard_opts(cfg: ArgParserSetup, ns: Namespace) -> StdOpts:
def parse_standard_opts(cfg: StdOptsSetup, ns: Namespace) -> StdOpts:
return StdOpts(
input_directory=ns.input if cfg.require_input else None,
output_directory=ns.output if cfg.require_output else None,
@@ -1,60 +1,121 @@
import math
from dataclasses import dataclass
@dataclass
class MinMaxRect:
x1: int
y1: int
x2: int
y2: int
class Point:
"""A point (or vector) on 2D plane with integer coordinates."""
def to_pos_size_rect(self) -> "PosSizeRect":
return PosSizeRect(
x=self.x1, y=self.y1, width=self.x2 - self.x1, height=self.y2 - self.y1
)
def to_pillow_tuple(self) -> tuple[int, int, int, int]:
return (self.x1, self.y1, self.x2, self.y2)
@dataclass
class PosSizeRect:
x: int
y: int
width: int
height: int
def to_min_max_rect(self) -> MinMaxRect:
return MinMaxRect(
x1=self.x, y1=self.y, x2=self.x + self.width, y2=self.y + self.height
)
@staticmethod
def from_tuple(t: tuple[int, int]) -> "Point":
return Point(t[0], t[1])
def to_pillow_tuple(self) -> tuple[int, int]:
def __add__(self, other: "Point") -> "Point":
if not isinstance(other, Point):
return NotImplemented
return Point(self.x + other.x, self.y + other.y)
def __sub__(self, other: "Point") -> "Point":
if not isinstance(other, Point):
return NotImplemented
return Point(self.x - other.x, self.y - other.y)
def __neg__(self) -> "Point":
return Point(-self.x, -self.y)
def __mul__(self, other: int | float) -> "Point":
if not isinstance(other, (int, float)):
return NotImplemented
return Point(math.floor(self.x * other), math.floor(self.y * other))
def __rmul__(self, other: int | float) -> "Point":
return self.__mul__(other)
def __floordiv__(self, other: int | float) -> "Point":
if not isinstance(other, (int, float)):
return NotImplemented
return Point(math.floor(self.x / other), math.floor(self.y / other))
def to_tuple(self) -> tuple[int, int]:
"""
Create the tuple representing this point by ``(x, y)``
which can be used by Pillow.
"""
return (self.x, self.y)
@dataclass
class MarginRect:
top: int
right: int
bottom: int
left: int
def to_min_max_rect(self, width: int, height: int) -> MinMaxRect:
return MinMaxRect(
x1=self.left,
y1=self.top,
x2=width - self.right,
y2=height - self.bottom,
)
@dataclass
class PaddingRect:
top: int
right: int
bottom: int
left: int
class Rectangle:
"""
A rectangle framed by two corner points.
def to_min_max_rect(self, x: int, y: int) -> MinMaxRect:
return MinMaxRect(
x1=x - self.left, y1=y - self.top, x2=x + self.right, y2=y + self.bottom
)
pos1 is always assumed to be the top-left corner and pos2 the
bottom-right one. This relation is a pure convention and is never
validated: constructing a Rectangle with swapped or inverted corners
produces a meaningless-but-silent result.
"""
pos1: Point
pos2: Point
@staticmethod
def from_tuple(t: tuple[int, int, int, int]) -> "Rectangle":
return Rectangle(Point(t[0], t[1]), Point(t[2], t[3]))
@staticmethod
def from_min_max_rect(pos1: Point, pos2: Point) -> "Rectangle":
return Rectangle(pos1, pos2)
@staticmethod
def from_pos_size_rect(pos: Point, width: int, height: int) -> "Rectangle":
return Rectangle(pos, pos + Point(width, height))
@staticmethod
def from_margin_rect(
width: int, height: int, top: int, right: int, bottom: int, left: int
) -> "Rectangle":
"""
TODO
:param width: The width of parent.
:param height: The height of parent.
:param top: The distance from this rectangle to the top border of parent.
:param right: The distance from this rectangle to the right border of parent.
:param bottom: The distance from this rectangle to the bottom border of parent.
:param left: The distance from this rectangle to the top border of parent.
"""
return Rectangle(Point(left, top), Point(width - right, height - bottom))
@staticmethod
def from_padding_rect(
x: int, y: int, top: int, right: int, bottom: int, left: int
) -> "Rectangle":
"""
TODO
:param x: The X factor of reference point.
:param y: The Y factor of reference point.
:param top: The distance from reference point to this rectangle's top border.
:param right: The distance from reference point to this rectangle's right border.
:param bottom: The distance from reference point to this rectangle's bottom border.
:param left: The distance from reference point to this rectangle's left border.
"""
return Rectangle(Point(x - left, y - top), Point(x + right, y + bottom))
@property
def width(self) -> int:
return self.pos2.x - self.pos1.x
@property
def height(self) -> int:
return self.pos2.y - self.pos1.y
def to_tuple(self) -> tuple[int, int, int, int]:
"""
Create the tuple representing this rectangle by ``(x1, y2 x2, y2)``
which can be used by Pillow.
"""
return (self.pos1.x, self.pos1.y, self.pos2.x, self.pos2.y)
@@ -1,29 +1,32 @@
from collections import deque
from dataclasses import dataclass
from typing import Callable, Iterable
from .environment import Environment
from typing import Callable, Iterable, TypeVar, Generic
from .environment import Environment, Prologue
from ..logger import HIGHLEVEL_LOGGER as LOGGER
P = TypeVar("P", bound=Prologue)
@dataclass(frozen=True)
class Job:
class Job(Generic[P]):
"""The class represents a job."""
name: str
"""The name of this job."""
dependencies: set[str]
"""The dependencies of this job. Each item is a job name. Empty for no dependencies."""
executor: Callable[[Environment], None]
executor: Callable[[Environment[P]], None]
"""The executor of this job."""
class Context:
__jobs: dict[str, Job]
class Context(Generic[P]):
__jobs: dict[str, Job[P]]
"""The dict storing all jobs. Key is job name and value is job payload."""
__env: Environment
__env: Environment[P]
"""The shared environment passed to every job executor."""
def __init__(self, env: Environment) -> None:
def __init__(self, env: Environment[P]) -> None:
self.__jobs = {}
self.__env = env
@@ -92,7 +95,7 @@ class Context:
self,
name: str,
deps: Iterable[str],
exec: Callable[[Environment], None],
exec: Callable[[Environment[P]], None],
) -> None:
"""
Register a new job with given name, dependencies and executor.
@@ -6,9 +6,8 @@ from types import TracebackType
from dataclasses import dataclass
from pathlib import Path
from typing import TypeVar, Generic
from .utils import Artifact, ArtifactFile, VART_HW
from .utils import Artifact, ArtifactFile, VART_HW, check_vart
from ..logger import HIGHLEVEL_LOGGER as LOGGER
from ..lowlevel.artrdr import SvgRenderKind
from ..lowlevel.artrdr import SvgRenderKind, SvgConfig, BlenderConfig
from ..lowlevel import artio, artrdr
@@ -120,6 +119,14 @@ class Environment(Generic[P]):
# endregion
# region: Prologue Visitor
@property
def prologue(self) -> P:
return self.__prologue
# endregion
# region: New Artifact
def new_artifact(self) -> Artifact:
@@ -309,6 +316,8 @@ class Environment(Generic[P]):
'Saving FreeDesktop artifact with kind "%s" and name "%s".', kind, name
)
check_vart(art)
for hw, thumbnail in zip(
_FD_THUMBNAIL_HWS, artrdr.render_thumbnail(art, _FD_THUMBNAIL_HWS)
):
@@ -343,6 +352,7 @@ class Environment(Generic[P]):
LOGGER.info(
'Saving Windows-only artifact with kind "%s" and name "%s".', kind, name
)
check_vart(art)
artrdr.render_ico(art, self.__win_artifact(kind, name))
def dup_win_artifact(
@@ -372,6 +382,7 @@ class Environment(Generic[P]):
LOGGER.info(
'Saving macOS-only artifact with kind "%s" and name "%s".', kind, name
)
check_vart(art)
artrdr.render_icns(art, self.__mac_artifact(kind, name))
def dup_mac_artifact(
@@ -4,7 +4,7 @@ VART_HW: int = 1024
"""The height or width of Vanilla Artifact."""
def check_vanilla_artifact(art: Artifact) -> None:
def check_vart(art: Artifact) -> None:
if art.size != (VART_HW, VART_HW):
raise ValueError(
f"The size of general highlevel used artifact must be {VART_HW}x{VART_HW}, got {art.width}x{art.height} instead"
@@ -8,6 +8,9 @@ class LoggerFactory:
format="[%(levelname)s][%(name)s] %(message)s", level=logging.INFO
)
def get_app_logger(self) -> logging.Logger:
return logging.getLogger("App")
def get_lowlevel_logger(self) -> logging.Logger:
return logging.getLogger("Lowlevel")
@@ -18,3 +21,4 @@ class LoggerFactory:
LOGGER_FACTORY = LoggerFactory()
LOWLEVEL_LOGGER = LOGGER_FACTORY.get_lowlevel_logger()
HIGHLEVEL_LOGGER = LOGGER_FACTORY.get_highlevel_logger()
APP_LOGGER = LOGGER_FACTORY.get_app_logger()
@@ -1,6 +1,7 @@
import enum
import math
from .utils import Artifact
from ..geometry import Point
class HorizontalAnchor(enum.IntEnum):
@@ -15,14 +16,32 @@ class VerticalAnchor(enum.IntEnum):
Bottom = enum.auto()
def _anchor_offset(oversize: int, anchor: HorizontalAnchor | VerticalAnchor) -> int:
match anchor:
case HorizontalAnchor.Left | VerticalAnchor.Top:
return 0
case HorizontalAnchor.Center | VerticalAnchor.Center:
return oversize // 2
case HorizontalAnchor.Right | VerticalAnchor.Bottom:
return oversize
def anchor_to_offset(
oversize: Point,
horizontal_anchor: HorizontalAnchor,
vertical_anchor: VerticalAnchor,
) -> Point:
half_oversize = oversize // 2
x: int
match horizontal_anchor:
case HorizontalAnchor.Left:
x = 0
case HorizontalAnchor.Center:
x = half_oversize.x
case HorizontalAnchor.Right:
x = oversize.x
y: int
match vertical_anchor:
case VerticalAnchor.Top:
y = 0
case VerticalAnchor.Center:
y = half_oversize.y
case VerticalAnchor.Bottom:
y = oversize.y
return Point(x, y)
def aspect_ratio_scale_and_crop(
@@ -45,16 +64,20 @@ def aspect_ratio_scale_and_crop(
scaled_height = math.ceil(art.height * scale)
scaled = art.resize((scaled_width, scaled_height), scale_resample)
crop_x = _anchor_offset(scaled_width - target_width, crop_horizontal_anchor)
crop_y = _anchor_offset(scaled_height - target_height, crop_vertical_anchor)
crop = anchor_to_offset(
Point(scaled_width - target_width, scaled_height - target_height),
crop_horizontal_anchor,
crop_vertical_anchor,
)
(crop_x, crop_y) = crop.to_tuple()
return scaled.crop((crop_x, crop_y, crop_x + target_width, crop_y + target_height))
def anchor_to_pos(
def art_anchor_to_offset(
art: Artifact,
horizontal_anchor: HorizontalAnchor,
vertical_anchor: VerticalAnchor,
) -> tuple[int, int]:
) -> Point:
"""
Resolve the anchor point position inside given artifact.
@@ -65,17 +88,16 @@ def anchor_to_pos(
anchors this is the corresponding edge; for Right/Bottom anchors
this is just outside the artifact (the exact corner coordinate).
"""
return (
_anchor_offset(art.width, horizontal_anchor),
_anchor_offset(art.height, vertical_anchor),
return anchor_to_offset(
Point.from_tuple(art.size), horizontal_anchor, vertical_anchor
)
def align_paste(
art: Artifact,
clipboard: Artifact,
art_pos: tuple[int, int],
clipboard_pos: tuple[int, int],
art_pos: Point,
clipboard_pos: Point,
) -> None:
"""
Paste clipboard onto given artifact with two anchor points aligned.
@@ -93,21 +115,16 @@ def align_paste(
:param clipboard_pos: The (x, y) point on the clipboard to be
aligned, usually resolved by anchor_to_pos.
"""
art.paste(
clipboard,
(art_pos[0] - clipboard_pos[0], art_pos[1] - clipboard_pos[1]),
)
art.paste(clipboard, (art_pos - clipboard_pos).to_tuple())
def align_alpha_composite(
art: Artifact,
clipboard: Artifact,
art_pos: tuple[int, int],
clipboard_pos: tuple[int, int],
art_pos: Point,
clipboard_pos: Point,
) -> None:
"""
Alpha-supported ``align_paste``.
"""
art.alpha_composite(
clipboard, (art_pos[0] - clipboard_pos[0], art_pos[1] - clipboard_pos[1])
)
art.alpha_composite(clipboard, (art_pos - clipboard_pos).to_tuple())
@@ -1,4 +1,3 @@
import logging
import os
import enum
import subprocess
@@ -6,7 +5,8 @@ from typing import Iterable, Iterator
from pathlib import Path
from dataclasses import dataclass
import PIL.Image
from .utils import Artifact, check_vanilla_artifact
from .utils import Artifact
from ..logger import LOWLEVEL_LOGGER as LOGGER
class SvgRenderKind(enum.IntEnum):
@@ -39,7 +39,7 @@ def render_svg(src: Path, dst: Path, kind: SvgRenderKind, cfg: SvgConfig) -> Non
:param kind: The selected SVG render kind.
:param cfg: The configuration when rendering.
"""
logging.debug(
LOGGER.debug(
"Rendering SVG artifact with render %s: %s -> %s", kind.name, src, dst
)
@@ -141,7 +141,7 @@ def render_blender(src: Path, dst: Path, temp_script: Path, cfg: BlenderConfig)
The caller must make sure that this file is immutable during calling this function.
:param cfg: The configuration when rendering.
"""
logging.debug("Rendering Blender artifact: %s -> %s", src, dst)
LOGGER.debug("Rendering Blender artifact: %s -> %s", src, dst)
if not src.is_file():
raise ValueError(
@@ -178,9 +178,19 @@ def render_thumbnail(art: Artifact, hws: Iterable[int]) -> Iterator[Artifact]:
: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)
LOGGER.debug("Rendering thumbnail...")
# check whether given artifact is square rectangle.
if art.width != art.height:
raise ValueError(f'Thumbnail render only accept square rectangle.')
# scale artifact up to the maximum resolution hws if it is too small.
max_hws = max(hws)
if art.width < max_hws:
art = art.resize((max_hws, max_hws), PIL.Image.Resampling.LANCZOS)
# render thumbnail
for hw in hws:
logging.debug("Rendering thumbnail %dx%d.", hw, hw)
LOGGER.debug("Rendering thumbnail %dx%d.", hw, hw)
thumbnail = art.copy()
thumbnail.thumbnail((hw, hw), PIL.Image.Resampling.LANCZOS)
yield thumbnail
@@ -193,9 +203,8 @@ def render_ico(art: Artifact, dst: Path) -> None:
:param art: The artifact to be rendered.
:param dst: The path to rendered result.
"""
logging.debug("Rendering .ICO artifact: %s", dst)
LOGGER.debug("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
@@ -215,9 +224,8 @@ def render_icns(art: Artifact, dst: Path) -> None:
:param art: The artifact to be rendered.
:param dst: The path to rendered result.
"""
logging.debug("Rendering .ICNS artifact: %s", dst)
LOGGER.debug("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)))
@@ -1,10 +1,10 @@
from ..assemblicon.context import AdvancedContext
from ..assemblicon.highlevel.context import Context
from .action_icons import build_action_icons
from .mimetype_icons import build_mimetype_icons
from .application_icons import build_application_icons
def build_non_standard_icons(ctx: AdvancedContext) -> None:
def register(ctx: Context) -> None:
build_mimetype_icons(ctx)
build_application_icons(ctx)
build_action_icons(ctx)
@@ -1,8 +1,8 @@
from ..assemblicon.context import AdvancedContext
from ..assemblicon.highlevel.context import Context
from .action_icons import build_action_icons
from .status_icons import build_status_icons
def build_standard_icons(ctx: AdvancedContext) -> None:
def register(ctx: Context) -> None:
build_action_icons(ctx)
build_status_icons(ctx)