Skip to content

Commit

Permalink
[WIP] UIAssetGen: Initial working code
Browse files Browse the repository at this point in the history
  • Loading branch information
Shengjie Xu committed Feb 17, 2025
1 parent d53b8be commit 17cfb0c
Show file tree
Hide file tree
Showing 5 changed files with 253 additions and 25 deletions.
1 change: 1 addition & 0 deletions src/preppipe/irdataop.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ def _process_class(cls : type[_OperationVT], vty : type | None) -> type[_Operati
# correctly.
globals = {}

globals[cls.__name__] = cls
cls_annotations = inspect.get_annotations(cls, globals=globals, eval_str=True)
cls_fields : collections.OrderedDict[str, OpField] = collections.OrderedDict()

Expand Down
1 change: 1 addition & 0 deletions src/preppipe/pipeline_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from .util import imagepack
from .util import imagepackrecolortester
from .assets import imports as asset_imports
from .uiassetgen import toolentry as uiassetgen_toolentry

if __name__ == "__main__":
pipeline_main()
106 changes: 99 additions & 7 deletions src/preppipe/uiassetgen/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,71 @@ class UIAssetElementDrawingData:
description : Translatable | str | None = None # 该图的描述,用于调试;应该描述画的算法(比如渐变填充)而不是该图是什么(比如按钮背景)
anchor : tuple[int, int] = (0, 0) # 锚点在当前结果中的位置(像素距离)

def sanity_check(self):
# 有异常就报错
pass

@IROperationDataclassWithValue(UIAssetElementType)
class UIAssetElementNodeOp(Operation, Value):
# 该类用于表述任意 UI 素材的元素,包括点、线、面等
# 所有的坐标、大小都以像素为单位
# 除非特殊情况,否则所有元素的锚点都应该是左上角顶点(不管留白或是延伸的特效(如星星延伸出的光效等))

# 子节点的信息,越往后则越晚绘制
child_positions : OpOperand[IntTuple2DLiteral] # 子元素的锚点在本图层内的位置(相对像素位置)
child_refs : OpOperand[Value] # 子元素的引用
child_refs : OpOperand[UIAssetElementNodeOp] # 子元素的引用
child_zorders : OpOperand[IntLiteral] # 子元素的 Z 轴顺序(相对于本元素,越大越靠前、越晚画)
children : Block # 存放子元素,不过它们可能会被引用不止一次

def get_bbox(self) -> tuple[int, int, int, int]:
raise PPNotImplementedError()
def get_bbox(self) -> tuple[int, int, int, int] | None:
# 只返回该元素的 bbox,不包括子元素
return None

def draw(self, drawctx : UIAssetDrawingContext) -> UIAssetElementDrawingData:
raise PPNotImplementedError()
return UIAssetElementDrawingData()

def add_child(self, ref : UIAssetElementNodeOp, pos : tuple[int, int], zorder : int):
self.child_refs.add_operand(ref)
self.child_positions.add_operand(IntTuple2DLiteral.get(pos, context=self.context))
self.child_zorders.add_operand(IntLiteral.get(zorder, context=self.context))

def get_child_bbox(self) -> tuple[int, int, int, int] | None:
cur_bbox = None
numchildren = self.child_refs.get_num_operands()
for i in range(numchildren):
child = self.child_refs.get_operand(i)
if not isinstance(child, UIAssetElementNodeOp):
raise PPInternalError(f"Unexpected child type: {type(child)}")
child_bbox = child.get_bbox()
child_child_bbox = child.get_child_bbox()
if child_bbox is not None:
left, top, right, bottom = child_bbox
if child_child_bbox is not None:
c_left, c_top, c_right, c_bottom = child_child_bbox
left = min(left, c_left)
top = min(top, c_top)
right = max(right, c_right)
bottom = max(bottom, c_bottom)
elif child_child_bbox is not None:
left, top, right, bottom = child_child_bbox
else:
continue
offset_x, offset_y = self.child_positions.get_operand(i).value
left += offset_x
right += offset_x
top += offset_y
bottom += offset_y
if cur_bbox is None:
cur_bbox = (left, top, right, bottom)
else:
cur_bbox = (min(cur_bbox[0], left), min(cur_bbox[1], top), max(cur_bbox[2], right), max(cur_bbox[3], bottom))
return cur_bbox

@IROperationDataclassWithValue(UIAssetElementType)
class UIAssetElementGroupOp(UIAssetElementNodeOp):
# 自身不含任何需要画的内容,仅用于组织其他元素(比如方便复用)
@staticmethod
def create(context : Context):
return UIAssetElementGroupOp(init_mode=IRObjectInitMode.CONSTRUCT, context=context)

@IROperationDataclass
class UIAssetDrawingResultElementOp(Operation):
Expand Down Expand Up @@ -70,6 +119,7 @@ def get_drawing_data(self, node : UIAssetElementNodeOp) -> tuple[UIAssetElementD
if result := self.drawing_cache.get(node):
return result
result = node.draw(self)
result.sanity_check()
imagedata = None
if result.image is not None:
imagedata = TemporaryImageData.create(node.context, result.image)
Expand Down Expand Up @@ -122,10 +172,52 @@ class UIAssetEntrySymbol(Symbol):
# 如果需要额外的元数据(比如该需求对应一个滚动条,我们想知道滚动条的边框大小),请使用 Attributes
kind : OpOperand[EnumLiteral[UIAssetKind]]
body : Block # UIAssetDrawingResultElementOp 的列表
canvas_size : OpOperand[IntTuple2DLiteral] # 画布大小
origin_pos : OpOperand[IntTuple2DLiteral] # 原点在画布中的位置

def take_image_layers(self, layers : list[UIAssetDrawingResultElementOp]):
xmin = 0
ymin = 0
xmax = 0
ymax = 0
for op in layers:
if not isinstance(op, UIAssetDrawingResultElementOp):
raise PPInternalError(f"Unexpected type: {type(op)}")
self.body.push_back(op)
x, y = op.image_pos.get().value
imagedata = op.image_patch.get()
if not isinstance(imagedata, TemporaryImageData):
raise PPInternalError(f"Unexpected type: {type(imagedata)}")
w, h = imagedata.value.size
xmin = min(xmin, x)
ymin = min(ymin, y)
xmax = max(xmax, x + w)
ymax = max(ymax, y + h)
total_width = xmax - xmin
total_height = ymax - ymin
self.canvas_size.set_operand(0, IntTuple2DLiteral.get((total_width, total_height), context=self.context))
self.origin_pos.set_operand(0, IntTuple2DLiteral.get((-xmin, -ymin), context=self.context))

def save_png(self, path : str):
size_tuple = self.canvas_size.get().value
origin_tuple = self.origin_pos.get().value
image = PIL.Image.new('RGBA', size_tuple, (0,0,0,0))
for op in self.body.body:
if not isinstance(op, UIAssetDrawingResultElementOp):
raise PPInternalError(f"Unexpected type: {type(op)}")
imagedata = op.image_patch.get()
if not isinstance(imagedata, TemporaryImageData):
raise PPInternalError(f"Unexpected type: {type(imagedata)}")
x, y = op.image_pos.get().value
curlayer = PIL.Image.new('RGBA', size_tuple, (0,0,0,0))
curlayer.paste(imagedata.value, (x + origin_tuple[0], y + origin_tuple[1]))
image = PIL.Image.alpha_composite(image, curlayer)
image.save(path)

@staticmethod
def save_png(path : str):
pass
def create(context : Context, kind : UIAssetKind, name : str, loc : Location | None = None) -> UIAssetEntrySymbol:
kind_value = EnumLiteral.get(value=kind, context=context)
return UIAssetEntrySymbol(init_mode=IRObjectInitMode.CONSTRUCT, context=context, name=name, loc=loc, kind=kind_value)

class UIAssetStyleData:
# 用于描述基础样式(比如颜色组合)
Expand Down
124 changes: 106 additions & 18 deletions src/preppipe/uiassetgen/elements.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,56 +13,114 @@

@IROperationDataclassWithValue(UIAssetElementType)
class UIAssetTextElementOp(UIAssetElementNodeOp):
# 文字元素
# 文字元素,锚点在左上角(可能文字顶部离锚点还有段距离)
# 具体锚点位置是 https://pillow.readthedocs.io/en/stable/handbook/text-anchors.html#text-anchors 中的 la
# 如果要对同一张图做不同语言的版本,应该有不同的 UIAssetTextElementOp
fontpath : OpOperand[StringLiteral] # 如果为空就用默认字体
fontindex : OpOperand[IntLiteral]
fontsize : OpOperand[IntLiteral]
fontcolor : OpOperand[ColorLiteral]
text : OpOperand[StringLiteral]

@staticmethod
def create(context : Context, text : StringLiteral | str, fontsize : IntLiteral | int, fontcolor : ColorLiteral | Color, fontpath : StringLiteral | str | None = None, fontindex : IntLiteral | int | None = None):
text_value = StringLiteral.get(text, context=context) if isinstance(text, str) else text
fontsize_value = IntLiteral.get(fontsize, context=context) if isinstance(fontsize, int) else fontsize
fontcolor_value = ColorLiteral.get(fontcolor, context=context) if isinstance(fontcolor, Color) else fontcolor
fontpath_value = StringLiteral.get(fontpath, context=context) if isinstance(fontpath, str) else fontpath
fontindex_value = IntLiteral.get(fontindex, context=context) if isinstance(fontindex, int) else fontindex
return UIAssetTextElementOp(init_mode=IRObjectInitMode.CONSTRUCT, context=context, fontpath=fontpath_value, fontindex=fontindex_value, fontsize=fontsize_value, fontcolor=fontcolor_value, text=text_value)

def get_font(self) -> PIL.ImageFont.FreeTypeFont | PIL.ImageFont.ImageFont:
size = self.fontsize.get().value
index = 0
if index_l := self.fontindex.get():
if index_l := self.fontindex.try_get_value():
index = index_l.value
if path := self.fontpath.get().value:
return PIL.ImageFont.truetype(path, index=index, size=size)
if path := self.fontpath.try_get_value():
return PIL.ImageFont.truetype(path.value, index=index, size=size)
return AssetManager.get_font(fontsize=size)

def get_bbox(self) -> tuple[int, int, int, int]:
def get_bbox_impl(self, font : PIL.ImageFont.FreeTypeFont | PIL.ImageFont.ImageFont) -> tuple[int, int, int, int]:
font = self.get_font()
s = self.text.get().value
left, top, right, bottom = font.getbbox(s)
return (math.floor(left), math.floor(top), math.ceil(right), math.ceil(bottom))
if isinstance(font, PIL.ImageFont.FreeTypeFont):
left, top, right, bottom = font.getbbox(s, anchor='la')
return (math.floor(left), math.floor(top), math.ceil(right), math.ceil(bottom))
elif isinstance(font, PIL.ImageFont.ImageFont):
return font.getbbox(s)
else:
raise PPInternalError(f'Unexpected type of font: {type(font)}')

def get_bbox(self) -> tuple[int, int, int, int]:
return self.get_bbox_impl(self.get_font())

def draw(self, drawctx : UIAssetDrawingContext) -> UIAssetElementDrawingData:
font = self.get_font()
s = self.text.get().value
left, top, right, bottom = font.getbbox(s)
left = math.floor(left)
top = math.floor(top)
right = math.ceil(right)
bottom = math.ceil(bottom)
left, top, right, bottom = self.get_bbox_impl(font)
width = right - left
height = bottom - top
color = self.fontcolor.get().value
image = PIL.Image.new('RGBA', (width, height), (0, 0, 0, 0))
draw = PIL.ImageDraw.Draw(image)
draw.text((-left, -top), s, font=font, fill=color.to_tuple())
draw.text((-left, -top), s, font=font, fill=color.to_tuple(), anchor='la')
print(f"Original bbox: {left}, {top}, {right}, {bottom}; actual bbox: {str(image.getbbox())}")
description = "<default_font>"
if path := self.fontpath.get().value:
description = path
if index_l := self.fontindex.get():
if path := self.fontpath.try_get_value():
description = path.value
if index_l := self.fontindex.try_get_value():
index = index_l.value
if index != 0:
description += f"#{index}"
description += ", size=" + str(self.fontsize.get().value)
return UIAssetElementDrawingData(image=image, description=description, anchor=(left, top))
return UIAssetElementDrawingData(image=image, description=description, anchor=(-left, -top))

@dataclasses.dataclass
class UIAssetLineLoopDrawingData(UIAssetElementDrawingData):
# 除了基类的属性外,我们在这还存储描述边框的信息
mask_interior : PIL.Image.Image | None = None # L (灰度)模式的图片,非零的部分表示内部(用于区域填充等),可作为 alpha 通道

def sanity_check(self):
sized_image_list = []
if self.image is not None:
sized_image_list.append(self.image)
if self.mask_interior is not None:
if not isinstance(self.mask_interior, PIL.Image.Image):
raise PPInternalError(f'Unexpected type of mask_interior: {type(self.mask_interior)}')
if self.mask_interior.mode != 'L':
raise PPInternalError(f'Unexpected mode of mask_interior: {self.mask_interior.mode}')
if len(sized_image_list) > 1:
size = sized_image_list[0].size
for image in sized_image_list[1:]:
if image.size != size:
raise PPInternalError(f'Inconsistent image size: {image.size} and {size}')


@IROperationDataclassWithValue(UIAssetElementType)
class UIAssetLineLoopOp(UIAssetElementNodeOp):
pass
# 用于描述一条或多条线段组成的闭合区域
# 由于大部分情况下我们有更简单的表示(比如就一个矩形),该类只作为基类,不应该直接使用
def draw(self, drawctx : UIAssetDrawingContext) -> UIAssetLineLoopDrawingData:
return UIAssetLineLoopDrawingData()

@IROperationDataclassWithValue(UIAssetElementType)
class UIAssetRectangleOp(UIAssetLineLoopOp):
width : OpOperand[IntLiteral]
height : OpOperand[IntLiteral]

def get_bbox(self) -> tuple[int, int, int, int]:
return (0, 0, self.width.get().value, self.height.get().value)

def draw(self, drawctx : UIAssetDrawingContext) -> UIAssetLineLoopDrawingData:
result = UIAssetLineLoopDrawingData()
result.mask_interior = PIL.Image.new('L', (self.width.get().value, self.height.get().value), 255)
return result

@staticmethod
def create(context : Context, width : IntLiteral | int, height : IntLiteral | int):
width_value = IntLiteral.get(width, context=context) if isinstance(width, int) else width
height_value = IntLiteral.get(height, context=context) if isinstance(height, int) else height
return UIAssetRectangleOp(init_mode=IRObjectInitMode.CONSTRUCT, context=context, width=width_value, height=height_value)

class UIAssetAreaFillMode(enum.Enum):
COLOR_FILL = enum.auto() # 单色填充
Expand All @@ -72,6 +130,36 @@ class UIAssetAreaFillElementOp(UIAssetElementNodeOp):
# 区域填充
# TODO 添加渐变填充等功能

boundary : OpOperand[UIAssetLineLoopOp]
mode : OpOperand[EnumLiteral[UIAssetAreaFillMode]]
color1 : OpOperand[ColorLiteral]
color2 : OpOperand[ColorLiteral] # 目前不用

@staticmethod
def create(context : Context, boundary : UIAssetLineLoopOp, mode : UIAssetAreaFillMode, color1 : ColorLiteral | Color, color2 : ColorLiteral | Color | None = None):
mode_value = EnumLiteral.get(value=mode, context=context)
color1_value = ColorLiteral.get(color1, context=context) if isinstance(color1, Color) else color1
color2_value = ColorLiteral.get(color2, context=context) if isinstance(color2, Color) else color2
return UIAssetAreaFillElementOp(init_mode=IRObjectInitMode.CONSTRUCT, context=context, boundary=boundary, mode=mode_value, color1=color1_value, color2=color2_value)

def get_bbox(self) -> tuple[int, int, int, int] | None:
return self.boundary.get().get_bbox()

def draw(self, drawctx : UIAssetDrawingContext) -> UIAssetElementDrawingData:
boundary = self.boundary.get()
boundary_data, _ = drawctx.get_drawing_data(boundary)
if not isinstance(boundary_data, UIAssetLineLoopDrawingData):
raise PPInternalError(f'Unexpected type of boundary_data: {type(boundary_data)}')
if boundary_data.mask_interior is None:
raise PPInternalError('Boundary mask_interior is None')
match self.mode.get().value:
case UIAssetAreaFillMode.COLOR_FILL:
color = self.color1.get().value
result = UIAssetLineLoopDrawingData()
result.image = PIL.Image.new('RGBA', boundary_data.mask_interior.size, (0, 0, 0, 0))
draw = PIL.ImageDraw.Draw(result.image)
draw.bitmap((0, 0), boundary_data.mask_interior, fill=color.to_tuple())
result.description = f'ColorFill({color.get_string()})'
return result
case _:
raise PPNotImplementedError()
46 changes: 46 additions & 0 deletions src/preppipe/uiassetgen/toolentry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# SPDX-FileCopyrightText: 2025 PrepPipe's Contributors
# SPDX-License-Identifier: Apache-2.0

from ..tooldecl import *
from .base import *
from .elements import *

@ToolClassDecl("uiassetgen-tester")
class UIAssetGenTester:
@staticmethod
def tool_main(args : list[str] | None):
context = Context()
text_color = Color.get((0, 0, 0)) # 黑色
box1_color = Color.get((255, 255, 255))
box2_color = Color.get((128, 128, 128))
fontsize = 100
text_color_value = ColorLiteral.get(text_color, context=context)
box1_color_value = ColorLiteral.get(box1_color, context=context)
box2_color_value = ColorLiteral.get(box2_color, context=context)
text_value = StringLiteral.get("Test", context=context)
text = UIAssetTextElementOp.create(context, fontcolor=text_color_value, fontsize=fontsize, text=text_value)
text_bbox = text.get_bbox()
print(text_bbox)
text_width = text_bbox[2] - text_bbox[0]
text_height = text_bbox[3] - text_bbox[1]
box1 = UIAssetRectangleOp.create(context, width=text_width + 10, height=text_height + 10)
box1_fill = UIAssetAreaFillElementOp.create(context, boundary=box1, mode=UIAssetAreaFillMode.COLOR_FILL, color1=box1_color_value)
box2 = UIAssetRectangleOp.create(context, width=text_width + 20, height=text_height + 20)
box2_fill = UIAssetAreaFillElementOp.create(context, boundary=box2, mode=UIAssetAreaFillMode.COLOR_FILL, color1=box2_color_value)
box2.add_child(box2_fill, (0, 0), zorder=1)
box2.add_child(box1, (5, 5), zorder=2)
box1.add_child(box1_fill, (0, 0), zorder=1)
box1.add_child(text, (5 - text_bbox[0], 5 - text_bbox[1]), zorder=2)
group = UIAssetElementGroupOp.create(context)
group.add_child(box2, (0, 0), zorder=1)
group.add_child(box2, (text_width + 20, 0), zorder=2)
group.add_child(box2, (0, text_height + 20), zorder=3)
group.add_child(box2, (text_width + 20, text_height + 20), zorder=4)
drawctx = UIAssetDrawingContext()
stack = []
image_layers = drawctx.draw_stack(group, 0, 0, stack)
result_symb = UIAssetEntrySymbol.create(context, kind=UIAssetKind.GENERIC, name="test")
result_symb.take_image_layers(image_layers)
result_symb.save_png("testout.png")
result_symb.dump()

0 comments on commit 17cfb0c

Please sign in to comment.