Skip to content

Commit 17cfb0c

Browse files
author
Shengjie Xu
committed
[WIP] UIAssetGen: Initial working code
1 parent d53b8be commit 17cfb0c

File tree

5 files changed

+253
-25
lines changed

5 files changed

+253
-25
lines changed

src/preppipe/irdataop.py

+1
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ def _process_class(cls : type[_OperationVT], vty : type | None) -> type[_Operati
258258
# correctly.
259259
globals = {}
260260

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

src/preppipe/pipeline_cmd.py

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from .util import imagepack
2424
from .util import imagepackrecolortester
2525
from .assets import imports as asset_imports
26+
from .uiassetgen import toolentry as uiassetgen_toolentry
2627

2728
if __name__ == "__main__":
2829
pipeline_main()

src/preppipe/uiassetgen/base.py

+99-7
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,71 @@ class UIAssetElementDrawingData:
2626
description : Translatable | str | None = None # 该图的描述,用于调试;应该描述画的算法(比如渐变填充)而不是该图是什么(比如按钮背景)
2727
anchor : tuple[int, int] = (0, 0) # 锚点在当前结果中的位置(像素距离)
2828

29+
def sanity_check(self):
30+
# 有异常就报错
31+
pass
32+
2933
@IROperationDataclassWithValue(UIAssetElementType)
3034
class UIAssetElementNodeOp(Operation, Value):
3135
# 该类用于表述任意 UI 素材的元素,包括点、线、面等
3236
# 所有的坐标、大小都以像素为单位
37+
# 除非特殊情况,否则所有元素的锚点都应该是左上角顶点(不管留白或是延伸的特效(如星星延伸出的光效等))
3338

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

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

4348
def draw(self, drawctx : UIAssetDrawingContext) -> UIAssetElementDrawingData:
44-
raise PPNotImplementedError()
49+
return UIAssetElementDrawingData()
50+
51+
def add_child(self, ref : UIAssetElementNodeOp, pos : tuple[int, int], zorder : int):
52+
self.child_refs.add_operand(ref)
53+
self.child_positions.add_operand(IntTuple2DLiteral.get(pos, context=self.context))
54+
self.child_zorders.add_operand(IntLiteral.get(zorder, context=self.context))
55+
56+
def get_child_bbox(self) -> tuple[int, int, int, int] | None:
57+
cur_bbox = None
58+
numchildren = self.child_refs.get_num_operands()
59+
for i in range(numchildren):
60+
child = self.child_refs.get_operand(i)
61+
if not isinstance(child, UIAssetElementNodeOp):
62+
raise PPInternalError(f"Unexpected child type: {type(child)}")
63+
child_bbox = child.get_bbox()
64+
child_child_bbox = child.get_child_bbox()
65+
if child_bbox is not None:
66+
left, top, right, bottom = child_bbox
67+
if child_child_bbox is not None:
68+
c_left, c_top, c_right, c_bottom = child_child_bbox
69+
left = min(left, c_left)
70+
top = min(top, c_top)
71+
right = max(right, c_right)
72+
bottom = max(bottom, c_bottom)
73+
elif child_child_bbox is not None:
74+
left, top, right, bottom = child_child_bbox
75+
else:
76+
continue
77+
offset_x, offset_y = self.child_positions.get_operand(i).value
78+
left += offset_x
79+
right += offset_x
80+
top += offset_y
81+
bottom += offset_y
82+
if cur_bbox is None:
83+
cur_bbox = (left, top, right, bottom)
84+
else:
85+
cur_bbox = (min(cur_bbox[0], left), min(cur_bbox[1], top), max(cur_bbox[2], right), max(cur_bbox[3], bottom))
86+
return cur_bbox
87+
88+
@IROperationDataclassWithValue(UIAssetElementType)
89+
class UIAssetElementGroupOp(UIAssetElementNodeOp):
90+
# 自身不含任何需要画的内容,仅用于组织其他元素(比如方便复用)
91+
@staticmethod
92+
def create(context : Context):
93+
return UIAssetElementGroupOp(init_mode=IRObjectInitMode.CONSTRUCT, context=context)
4594

4695
@IROperationDataclass
4796
class UIAssetDrawingResultElementOp(Operation):
@@ -70,6 +119,7 @@ def get_drawing_data(self, node : UIAssetElementNodeOp) -> tuple[UIAssetElementD
70119
if result := self.drawing_cache.get(node):
71120
return result
72121
result = node.draw(self)
122+
result.sanity_check()
73123
imagedata = None
74124
if result.image is not None:
75125
imagedata = TemporaryImageData.create(node.context, result.image)
@@ -122,10 +172,52 @@ class UIAssetEntrySymbol(Symbol):
122172
# 如果需要额外的元数据(比如该需求对应一个滚动条,我们想知道滚动条的边框大小),请使用 Attributes
123173
kind : OpOperand[EnumLiteral[UIAssetKind]]
124174
body : Block # UIAssetDrawingResultElementOp 的列表
175+
canvas_size : OpOperand[IntTuple2DLiteral] # 画布大小
176+
origin_pos : OpOperand[IntTuple2DLiteral] # 原点在画布中的位置
177+
178+
def take_image_layers(self, layers : list[UIAssetDrawingResultElementOp]):
179+
xmin = 0
180+
ymin = 0
181+
xmax = 0
182+
ymax = 0
183+
for op in layers:
184+
if not isinstance(op, UIAssetDrawingResultElementOp):
185+
raise PPInternalError(f"Unexpected type: {type(op)}")
186+
self.body.push_back(op)
187+
x, y = op.image_pos.get().value
188+
imagedata = op.image_patch.get()
189+
if not isinstance(imagedata, TemporaryImageData):
190+
raise PPInternalError(f"Unexpected type: {type(imagedata)}")
191+
w, h = imagedata.value.size
192+
xmin = min(xmin, x)
193+
ymin = min(ymin, y)
194+
xmax = max(xmax, x + w)
195+
ymax = max(ymax, y + h)
196+
total_width = xmax - xmin
197+
total_height = ymax - ymin
198+
self.canvas_size.set_operand(0, IntTuple2DLiteral.get((total_width, total_height), context=self.context))
199+
self.origin_pos.set_operand(0, IntTuple2DLiteral.get((-xmin, -ymin), context=self.context))
200+
201+
def save_png(self, path : str):
202+
size_tuple = self.canvas_size.get().value
203+
origin_tuple = self.origin_pos.get().value
204+
image = PIL.Image.new('RGBA', size_tuple, (0,0,0,0))
205+
for op in self.body.body:
206+
if not isinstance(op, UIAssetDrawingResultElementOp):
207+
raise PPInternalError(f"Unexpected type: {type(op)}")
208+
imagedata = op.image_patch.get()
209+
if not isinstance(imagedata, TemporaryImageData):
210+
raise PPInternalError(f"Unexpected type: {type(imagedata)}")
211+
x, y = op.image_pos.get().value
212+
curlayer = PIL.Image.new('RGBA', size_tuple, (0,0,0,0))
213+
curlayer.paste(imagedata.value, (x + origin_tuple[0], y + origin_tuple[1]))
214+
image = PIL.Image.alpha_composite(image, curlayer)
215+
image.save(path)
125216

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

130222
class UIAssetStyleData:
131223
# 用于描述基础样式(比如颜色组合)

src/preppipe/uiassetgen/elements.py

+106-18
Original file line numberDiff line numberDiff line change
@@ -13,56 +13,114 @@
1313

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

25+
@staticmethod
26+
def create(context : Context, text : StringLiteral | str, fontsize : IntLiteral | int, fontcolor : ColorLiteral | Color, fontpath : StringLiteral | str | None = None, fontindex : IntLiteral | int | None = None):
27+
text_value = StringLiteral.get(text, context=context) if isinstance(text, str) else text
28+
fontsize_value = IntLiteral.get(fontsize, context=context) if isinstance(fontsize, int) else fontsize
29+
fontcolor_value = ColorLiteral.get(fontcolor, context=context) if isinstance(fontcolor, Color) else fontcolor
30+
fontpath_value = StringLiteral.get(fontpath, context=context) if isinstance(fontpath, str) else fontpath
31+
fontindex_value = IntLiteral.get(fontindex, context=context) if isinstance(fontindex, int) else fontindex
32+
return UIAssetTextElementOp(init_mode=IRObjectInitMode.CONSTRUCT, context=context, fontpath=fontpath_value, fontindex=fontindex_value, fontsize=fontsize_value, fontcolor=fontcolor_value, text=text_value)
33+
2434
def get_font(self) -> PIL.ImageFont.FreeTypeFont | PIL.ImageFont.ImageFont:
2535
size = self.fontsize.get().value
2636
index = 0
27-
if index_l := self.fontindex.get():
37+
if index_l := self.fontindex.try_get_value():
2838
index = index_l.value
29-
if path := self.fontpath.get().value:
30-
return PIL.ImageFont.truetype(path, index=index, size=size)
39+
if path := self.fontpath.try_get_value():
40+
return PIL.ImageFont.truetype(path.value, index=index, size=size)
3141
return AssetManager.get_font(fontsize=size)
3242

33-
def get_bbox(self) -> tuple[int, int, int, int]:
43+
def get_bbox_impl(self, font : PIL.ImageFont.FreeTypeFont | PIL.ImageFont.ImageFont) -> tuple[int, int, int, int]:
3444
font = self.get_font()
3545
s = self.text.get().value
36-
left, top, right, bottom = font.getbbox(s)
37-
return (math.floor(left), math.floor(top), math.ceil(right), math.ceil(bottom))
46+
if isinstance(font, PIL.ImageFont.FreeTypeFont):
47+
left, top, right, bottom = font.getbbox(s, anchor='la')
48+
return (math.floor(left), math.floor(top), math.ceil(right), math.ceil(bottom))
49+
elif isinstance(font, PIL.ImageFont.ImageFont):
50+
return font.getbbox(s)
51+
else:
52+
raise PPInternalError(f'Unexpected type of font: {type(font)}')
53+
54+
def get_bbox(self) -> tuple[int, int, int, int]:
55+
return self.get_bbox_impl(self.get_font())
3856

3957
def draw(self, drawctx : UIAssetDrawingContext) -> UIAssetElementDrawingData:
4058
font = self.get_font()
4159
s = self.text.get().value
42-
left, top, right, bottom = font.getbbox(s)
43-
left = math.floor(left)
44-
top = math.floor(top)
45-
right = math.ceil(right)
46-
bottom = math.ceil(bottom)
60+
left, top, right, bottom = self.get_bbox_impl(font)
4761
width = right - left
4862
height = bottom - top
4963
color = self.fontcolor.get().value
5064
image = PIL.Image.new('RGBA', (width, height), (0, 0, 0, 0))
5165
draw = PIL.ImageDraw.Draw(image)
52-
draw.text((-left, -top), s, font=font, fill=color.to_tuple())
66+
draw.text((-left, -top), s, font=font, fill=color.to_tuple(), anchor='la')
67+
print(f"Original bbox: {left}, {top}, {right}, {bottom}; actual bbox: {str(image.getbbox())}")
5368
description = "<default_font>"
54-
if path := self.fontpath.get().value:
55-
description = path
56-
if index_l := self.fontindex.get():
69+
if path := self.fontpath.try_get_value():
70+
description = path.value
71+
if index_l := self.fontindex.try_get_value():
5772
index = index_l.value
5873
if index != 0:
5974
description += f"#{index}"
6075
description += ", size=" + str(self.fontsize.get().value)
61-
return UIAssetElementDrawingData(image=image, description=description, anchor=(left, top))
76+
return UIAssetElementDrawingData(image=image, description=description, anchor=(-left, -top))
77+
78+
@dataclasses.dataclass
79+
class UIAssetLineLoopDrawingData(UIAssetElementDrawingData):
80+
# 除了基类的属性外,我们在这还存储描述边框的信息
81+
mask_interior : PIL.Image.Image | None = None # L (灰度)模式的图片,非零的部分表示内部(用于区域填充等),可作为 alpha 通道
82+
83+
def sanity_check(self):
84+
sized_image_list = []
85+
if self.image is not None:
86+
sized_image_list.append(self.image)
87+
if self.mask_interior is not None:
88+
if not isinstance(self.mask_interior, PIL.Image.Image):
89+
raise PPInternalError(f'Unexpected type of mask_interior: {type(self.mask_interior)}')
90+
if self.mask_interior.mode != 'L':
91+
raise PPInternalError(f'Unexpected mode of mask_interior: {self.mask_interior.mode}')
92+
if len(sized_image_list) > 1:
93+
size = sized_image_list[0].size
94+
for image in sized_image_list[1:]:
95+
if image.size != size:
96+
raise PPInternalError(f'Inconsistent image size: {image.size} and {size}')
97+
6298

6399
@IROperationDataclassWithValue(UIAssetElementType)
64100
class UIAssetLineLoopOp(UIAssetElementNodeOp):
65-
pass
101+
# 用于描述一条或多条线段组成的闭合区域
102+
# 由于大部分情况下我们有更简单的表示(比如就一个矩形),该类只作为基类,不应该直接使用
103+
def draw(self, drawctx : UIAssetDrawingContext) -> UIAssetLineLoopDrawingData:
104+
return UIAssetLineLoopDrawingData()
105+
106+
@IROperationDataclassWithValue(UIAssetElementType)
107+
class UIAssetRectangleOp(UIAssetLineLoopOp):
108+
width : OpOperand[IntLiteral]
109+
height : OpOperand[IntLiteral]
110+
111+
def get_bbox(self) -> tuple[int, int, int, int]:
112+
return (0, 0, self.width.get().value, self.height.get().value)
113+
114+
def draw(self, drawctx : UIAssetDrawingContext) -> UIAssetLineLoopDrawingData:
115+
result = UIAssetLineLoopDrawingData()
116+
result.mask_interior = PIL.Image.new('L', (self.width.get().value, self.height.get().value), 255)
117+
return result
118+
119+
@staticmethod
120+
def create(context : Context, width : IntLiteral | int, height : IntLiteral | int):
121+
width_value = IntLiteral.get(width, context=context) if isinstance(width, int) else width
122+
height_value = IntLiteral.get(height, context=context) if isinstance(height, int) else height
123+
return UIAssetRectangleOp(init_mode=IRObjectInitMode.CONSTRUCT, context=context, width=width_value, height=height_value)
66124

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

133+
boundary : OpOperand[UIAssetLineLoopOp]
75134
mode : OpOperand[EnumLiteral[UIAssetAreaFillMode]]
76135
color1 : OpOperand[ColorLiteral]
77136
color2 : OpOperand[ColorLiteral] # 目前不用
137+
138+
@staticmethod
139+
def create(context : Context, boundary : UIAssetLineLoopOp, mode : UIAssetAreaFillMode, color1 : ColorLiteral | Color, color2 : ColorLiteral | Color | None = None):
140+
mode_value = EnumLiteral.get(value=mode, context=context)
141+
color1_value = ColorLiteral.get(color1, context=context) if isinstance(color1, Color) else color1
142+
color2_value = ColorLiteral.get(color2, context=context) if isinstance(color2, Color) else color2
143+
return UIAssetAreaFillElementOp(init_mode=IRObjectInitMode.CONSTRUCT, context=context, boundary=boundary, mode=mode_value, color1=color1_value, color2=color2_value)
144+
145+
def get_bbox(self) -> tuple[int, int, int, int] | None:
146+
return self.boundary.get().get_bbox()
147+
148+
def draw(self, drawctx : UIAssetDrawingContext) -> UIAssetElementDrawingData:
149+
boundary = self.boundary.get()
150+
boundary_data, _ = drawctx.get_drawing_data(boundary)
151+
if not isinstance(boundary_data, UIAssetLineLoopDrawingData):
152+
raise PPInternalError(f'Unexpected type of boundary_data: {type(boundary_data)}')
153+
if boundary_data.mask_interior is None:
154+
raise PPInternalError('Boundary mask_interior is None')
155+
match self.mode.get().value:
156+
case UIAssetAreaFillMode.COLOR_FILL:
157+
color = self.color1.get().value
158+
result = UIAssetLineLoopDrawingData()
159+
result.image = PIL.Image.new('RGBA', boundary_data.mask_interior.size, (0, 0, 0, 0))
160+
draw = PIL.ImageDraw.Draw(result.image)
161+
draw.bitmap((0, 0), boundary_data.mask_interior, fill=color.to_tuple())
162+
result.description = f'ColorFill({color.get_string()})'
163+
return result
164+
case _:
165+
raise PPNotImplementedError()

src/preppipe/uiassetgen/toolentry.py

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# SPDX-FileCopyrightText: 2025 PrepPipe's Contributors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from ..tooldecl import *
5+
from .base import *
6+
from .elements import *
7+
8+
@ToolClassDecl("uiassetgen-tester")
9+
class UIAssetGenTester:
10+
@staticmethod
11+
def tool_main(args : list[str] | None):
12+
context = Context()
13+
text_color = Color.get((0, 0, 0)) # 黑色
14+
box1_color = Color.get((255, 255, 255))
15+
box2_color = Color.get((128, 128, 128))
16+
fontsize = 100
17+
text_color_value = ColorLiteral.get(text_color, context=context)
18+
box1_color_value = ColorLiteral.get(box1_color, context=context)
19+
box2_color_value = ColorLiteral.get(box2_color, context=context)
20+
text_value = StringLiteral.get("Test", context=context)
21+
text = UIAssetTextElementOp.create(context, fontcolor=text_color_value, fontsize=fontsize, text=text_value)
22+
text_bbox = text.get_bbox()
23+
print(text_bbox)
24+
text_width = text_bbox[2] - text_bbox[0]
25+
text_height = text_bbox[3] - text_bbox[1]
26+
box1 = UIAssetRectangleOp.create(context, width=text_width + 10, height=text_height + 10)
27+
box1_fill = UIAssetAreaFillElementOp.create(context, boundary=box1, mode=UIAssetAreaFillMode.COLOR_FILL, color1=box1_color_value)
28+
box2 = UIAssetRectangleOp.create(context, width=text_width + 20, height=text_height + 20)
29+
box2_fill = UIAssetAreaFillElementOp.create(context, boundary=box2, mode=UIAssetAreaFillMode.COLOR_FILL, color1=box2_color_value)
30+
box2.add_child(box2_fill, (0, 0), zorder=1)
31+
box2.add_child(box1, (5, 5), zorder=2)
32+
box1.add_child(box1_fill, (0, 0), zorder=1)
33+
box1.add_child(text, (5 - text_bbox[0], 5 - text_bbox[1]), zorder=2)
34+
group = UIAssetElementGroupOp.create(context)
35+
group.add_child(box2, (0, 0), zorder=1)
36+
group.add_child(box2, (text_width + 20, 0), zorder=2)
37+
group.add_child(box2, (0, text_height + 20), zorder=3)
38+
group.add_child(box2, (text_width + 20, text_height + 20), zorder=4)
39+
drawctx = UIAssetDrawingContext()
40+
stack = []
41+
image_layers = drawctx.draw_stack(group, 0, 0, stack)
42+
result_symb = UIAssetEntrySymbol.create(context, kind=UIAssetKind.GENERIC, name="test")
43+
result_symb.take_image_layers(image_layers)
44+
result_symb.save_png("testout.png")
45+
result_symb.dump()
46+

0 commit comments

Comments
 (0)