Skip to content

Commit

Permalink
Add support to emit documentation for asset listings
Browse files Browse the repository at this point in the history
  • Loading branch information
Shengjie Xu committed Dec 30, 2024
1 parent 7f4fde3 commit 2163e70
Show file tree
Hide file tree
Showing 3 changed files with 273 additions and 2 deletions.
55 changes: 55 additions & 0 deletions src/preppipe/assets/assetclassdecl.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# SPDX-FileCopyrightText: 2024 PrepPipe's Contributors
# SPDX-License-Identifier: Apache-2.0

import dataclasses
import shutil
import typing
from ..language import *
Expand Down Expand Up @@ -131,3 +132,57 @@ def clear_directory_recursive(path : str) -> None:
os.unlink(os.path.join(root, f))
for d in dirs:
shutil.rmtree(os.path.join(root, d))

@dataclasses.dataclass
class AssetDocsDumpInfo:
base_path : str # 以下所有相对路径的起始路径
base_path_ref : str # 当生成对保存的内容的引用(比如到共通部分)时,使用的起始路径(即导出的文件中应该使用 base_path_ref 来替代 base_path)
common_export_path : str # 共通部分、语言无关内容(比如图片本体)的导出路径
language_specific_docs : dict[str, str] # 语言相关内容的导出路径

class AssetDocsDumperBase:
# 用于输出面向用户的文档 (Markdown, mkdocs)
dumpinfo : AssetDocsDumpInfo
_classdict : typing.ClassVar[dict[int, type]] = {}
_TARGET_CLASS : typing.ClassVar[typing.Type[NamedAssetClassBase]]

def __init__(self, dumpinfo : AssetDocsDumpInfo):
self.dumpinfo = dumpinfo

@classmethod
def get_targeting_asset_class(cls) -> typing.Type[NamedAssetClassBase]:
return cls._TARGET_CLASS

def try_claim_asset(self, name : str, asset : NamedAssetClassBase) -> int:
# 返回一个表示顺序的整数,如果不处理则返回 -1
# 该整数应该表示某种优先级,越小越优先,我们也会给每个值输出一个标题
# 如果需要处理,那么这个函数应该完成所有需要的准备工作,比如导出共通的部分
return -1

@classmethod
def get_heading_for_type(cls) -> str:
# 应该是某个 Translatable 的 .get() 结果
raise PPNotImplementedError()

@classmethod
def get_heading_for_sort_order(cls, sort_order : int) -> str:
# 应该是某个 Translatable 的 .get() 结果
raise PPNotImplementedError()

def dump(self, dest : typing.TextIO, name : str, asset : NamedAssetClassBase, sort_order : int) -> None:
# 将一个素材的文档输出到目标文件
# 这个函数可能会执行不止一次,有多少种语言输出就会执行多少次
pass

def AssetDocsDumperDecl(target : typing.Type[NamedAssetClassBase], sort_order : int | None = None): # pylint: disable=invalid-name
def decorator(cls : typing.Type[AssetDocsDumperBase]):
# pylint: disable=protected-access
if not issubclass(cls, AssetDocsDumperBase):
raise ValueError("AssetDocsDumperDecl can only be used on subclasses of AssetDocsDumperBase")
cls._TARGET_CLASS = target
sort_order_value = sort_order if sort_order is not None else len(AssetDocsDumperBase._classdict)
if sort_order_value in AssetDocsDumperBase._classdict:
raise ValueError(f"Duplicate sort order {sort_order_value}")
AssetDocsDumperBase._classdict[sort_order_value] = cls
return cls
return decorator
76 changes: 76 additions & 0 deletions src/preppipe/assets/assetmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from ..exceptions import *
from ..tooldecl import ToolClassDecl
from .assetclassdecl import _registered_asset_classes
from .assetclassdecl import *
from ..util.message import MessageHandler
from ..util.nameconvert import *
from .. import __version__
Expand Down Expand Up @@ -444,6 +445,76 @@ def build_assets_extra(self, srcpath : str):
install_base = AssetManager.get_extra_asset_install_path(srcpath)
self.build_assets_impl(srcpath, install_base, manifest_src)

_tr_assetlistings_title = TR_assetmanager.tr("assetlistings_title",
en="Embedded Assets",
zh_cn="内嵌素材",
zh_hk="內嵌素材",
)
def export_assets_docs(self, yamlpath : str):
if len(AssetDocsDumperBase._classdict) == 0:
return
with open(yamlpath, "r", encoding="utf-8") as f:
yamlobj = yaml.safe_load(f)
# 解析为 AssetDocsDumpInfo
base_path = yamlobj["base_path"]
base_path_ref = yamlobj["base_path_ref"]
common_export_path = yamlobj["common_export_path"]
language_specific_docs = yamlobj["language_specific_docs"]
dumpinfo = AssetDocsDumpInfo(
base_path=base_path,
base_path_ref=base_path_ref,
common_export_path=common_export_path,
language_specific_docs=language_specific_docs
)

asset_export_dict = collections.OrderedDict()
dumpers = {}
for sort_order, cls in sorted(AssetDocsDumperBase._classdict.items()): # pylint: disable=protected-access
index = len(dumpers)
dumper = cls(dumpinfo)
dumpers[index] = dumper
asset_export_dict[index] = collections.OrderedDict()

common_export_path_complete = os.path.join(base_path, common_export_path)
if not os.path.isdir(common_export_path_complete):
os.makedirs(common_export_path_complete, exist_ok=True)
for name, info in sorted(self._assets.items()):
if info.handle is None:
self._load_asset(info)
for index, dumper in dumpers.items():
if isinstance(info.handle, dumper.get_targeting_asset_class()):
order = dumper.try_claim_asset(name, info.handle)
if order < 0:
continue
dumper_asset_dict = asset_export_dict[index]
if order not in dumper_asset_dict:
dumper_asset_dict[order] = []
dumper_asset_dict[order].append(name)
break
for lang, path in language_specific_docs.items():
lang_export_path = os.path.join(base_path, path)
lang_export_dir = os.path.dirname(lang_export_path)
if not os.path.isdir(lang_export_dir):
os.makedirs(lang_export_dir, exist_ok=True)
Translatable.language_update_preferred_langs([lang])
with open(lang_export_path, "w", encoding="utf-8") as f:
f.write(f"# {AssetManager._tr_assetlistings_title.get()}\n\n")
for index, dumper in dumpers.items():
dumper_asset_dict = asset_export_dict[index]
if len(dumper_asset_dict) == 0:
continue
type_heading = dumper.get_heading_for_type()
f.write(f"## {type_heading}\n\n")
for sort_order, names in dumper_asset_dict.items():
sort_order_heading = dumper.get_heading_for_sort_order(sort_order)
f.write(f"### {sort_order_heading}\n\n")
for name in names:
asset = self.get_asset(name)
if asset is None:
raise PPInternalError(f"Asset {name} not found")
dumper.dump(f, name, asset, sort_order)
f.write("\n")

@staticmethod
def init():
AssetManager.get_instance()
Expand All @@ -463,6 +534,7 @@ def tool_main(args : list[str] | None = None):
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
parser.add_argument("--build-embedded", metavar="<dir>", help="Build the embedded asset pack from the specified directory")
parser.add_argument("--build-extra", metavar="<dir>", nargs="*", help="Build the extra asset pack from the specified directory")
parser.add_argument("--export-docs", metavar="<yml>", help="Export asset documentation accoding to the specified YAML file")
parser.add_argument("--dump-json", action="store_true", help="Dump all asset info as a JSON string")
if args is None:
args = sys.argv[1:]
Expand All @@ -478,6 +550,10 @@ def tool_main(args : list[str] | None = None):
AssetManager.get_instance().build_assets_extra(p)
else:
AssetManager.get_instance().try_load_manifest()

if parsed_args.export_docs is not None:
AssetManager.get_instance().export_assets_docs(parsed_args.export_docs)

if parsed_args.dump_json:
inst = AssetManager.get_instance()
inst.load_all_assets()
Expand Down
144 changes: 142 additions & 2 deletions src/preppipe/util/imagepack.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
from ..commontypes import Color
from ..language import *
from ..tooldecl import ToolClassDecl
from ..assets.assetclassdecl import AssetClassDecl, NamedAssetClassBase
from ..assets.assetclassdecl import *
from ..assets.assetmanager import AssetManager
from ..assets.fileasset import FileAssetPack
from .message import MessageHandler
Expand Down Expand Up @@ -216,7 +216,7 @@ def __init__(self, layers : typing.Iterable[int], basename : str = '') -> None:
# "author": str, 作者
# "license": str, 发布许可(没有的话就是仅限内部使用)
# - "cc0": CC0 1.0 Universal
# "overview_scale" : decimal.Decimal, 用于生成预览图的缩放比例
# "overview_scale" : decimal.Decimal, 用于生成预览图或是文档时的缩放比例
# "diff_croprect": tuple[int,int,int,int], 用于生成差分图的裁剪矩形
# "modified": bool, 用于标注是否已修改选区
# "original_size": tuple[int,int], 用于标注原始大小,只在第一次改变大小时设置
Expand Down Expand Up @@ -2909,6 +2909,146 @@ def dump_asset_info_json(self) -> dict[str, typing.Any]:
result["composites_references"] = reference_dict
return result

@AssetDocsDumperDecl(ImagePack)
class ImagePackDocsDumper(AssetDocsDumperBase):
TR_docs = TranslationDomain("imagepack-docs")
_tr_type_heading = TR_docs.tr("type_heading",
en="Image Packs",
zh_cn="图片包",
zh_hk="圖片包",
)
@classmethod
def get_heading_for_type(cls) -> str:
return cls._tr_type_heading.get()

_tr_heading_background_template = TR_docs.tr("heading_background_template",
en="Background Template",
zh_cn="背景模板",
zh_hk="背景模板",
)
_tr_heading_background = TR_docs.tr("heading_background",
en="Background",
zh_cn="背景",
zh_hk="背景",
)

heading_list = [
_tr_heading_background_template,
_tr_heading_background,
]

@classmethod
def get_heading_for_sort_order(cls, sort_order : int) -> str:
return cls.heading_list[sort_order].get()

def try_claim_asset(self, name : str, asset : NamedAssetClassBase) -> int:
if not isinstance(asset, ImagePack):
return -1
descriptor : ImagePackDescriptor = ImagePack.get_descriptor_by_id(name)
if not isinstance(descriptor, ImagePackDescriptor):
raise PPInternalError()
match descriptor.packtype:
case ImagePackDescriptor.ImagePackType.BACKGROUND:
return self.export_background_common(name, asset, descriptor)
case ImagePackDescriptor.ImagePackType.CHARACTER:
# TODO
return -1
case _:
return -1

def resize_for_docs(self, asset : ImagePack) -> ImagePack:
if "overview_scale" in asset.opaque_metadata:
scale = asset.opaque_metadata["overview_scale"]
return asset.fork_and_shrink(scale)
return asset

@dataclasses.dataclass
class BackgroundExportInfo:
composites_filepaths : list[str]
masks_filepaths : list[str]

_backgrounds : dict[str, BackgroundExportInfo]

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._backgrounds = {}

def export_background_common(self, name : str, asset : ImagePack, descriptor : ImagePackDescriptor) -> int:
asset = self.resize_for_docs(asset)
composites_filepaths = []
masks_filepaths = []
export_basepath = os.path.join(self.dumpinfo.base_path, self.dumpinfo.common_export_path, name)
export_refpath = os.path.join(self.dumpinfo.base_path_ref, self.dumpinfo.common_export_path, name)
os.makedirs(export_basepath, exist_ok=True)
for index in range(len(asset.composites)):
code = descriptor.composites_code[index]
filename = code + ".png"
actual_path = os.path.join(export_basepath, filename)
refpath = os.path.join(export_refpath, filename)
asset.get_composed_image(index).save_png(actual_path)
composites_filepaths.append(refpath)
for index in range(len(asset.masks)):
filename = f"mask_{index}.png"
actual_path = os.path.join(export_basepath, filename)
refpath = os.path.join(export_refpath, filename)
fork_args : list[Color | None] = [None] * len(asset.masks)
fork_args[index] = Color.get((0,0,0))
forked = asset.fork_applying_mask(args=fork_args) # type: ignore
forked.get_composed_image(0).save_png(actual_path)
masks_filepaths.append(refpath)
info = self.BackgroundExportInfo(composites_filepaths, masks_filepaths)
self._backgrounds[name] = info
if len(masks_filepaths) > 0:
return 0 # 背景模板
return 1 # 背景

_tr_mask = TR_docs.tr("mask",
en="Mask: {name}",
zh_cn="选区:{name}",
zh_hk="選區:{name}",
)

def dump_background(self, dest : typing.TextIO, name : str, asset : ImagePack, descriptor : ImagePackDescriptor):
info = self._backgrounds[name]
dest.write(f"#### {str(descriptor.topref)}\n\n")
def write_image(label : str, refpath : str):
dest.write(f"=== \"{label}\"\n\n")
dest.write(f" ![{label}]({refpath})\n\n")
for index, refpath in enumerate(info.composites_filepaths):
code = descriptor.composites_code[index]
label = code
if tr := descriptor.try_get_composite_name(code):
label = tr.get()
write_image(label, refpath)
for index, refpath in enumerate(info.masks_filepaths):
masktype = descriptor.masktypes[index]
label = self._tr_mask.format(name=masktype.trname.get())
write_image(label, refpath)
original_width = asset.width
original_height = asset.height
if "original_size" in asset.opaque_metadata:
original_width, original_height = asset.opaque_metadata["original_size"]
author_note = ''
if author := asset.opaque_metadata.get("author", None):
author_note = ImagePack.TR_imagepack_overview_author.format(name=author)
size_note = ImagePack.TR_imagepack_overview_size_note.format(width=str(original_width), height=str(original_height))
complexity_note = ImagePack.TR_imagepack_overview_complexity_note.format(numlayers=str(len(asset.layers)), numcomposites=str(len(asset.composites)))
additional_notes = [author_note, size_note, complexity_note]
dest.write("\n")
for note in additional_notes:
if len(note) > 0:
dest.write("* " + note + "\n")
dest.write("\n")
def dump(self, dest : typing.TextIO, name : str, asset : NamedAssetClassBase, sort_order : int) -> None:
descriptor : ImagePackDescriptor = ImagePack.get_descriptor_by_id(name)
if not isinstance(descriptor, ImagePackDescriptor):
raise PPInternalError()
if not isinstance(asset, ImagePack):
raise PPInternalError()
match sort_order:
case 0 | 1:
self.dump_background(dest, name, asset, descriptor)

if __name__ == "__main__":
# 由于这个模块会被其他模块引用,所以如果这是 __main__,其他地方再引用该模块,模块内的代码会被执行两次,导致出错
raise RuntimeError("This module is not supposed to be executed directly. please use preppipe.pipeline_cmd with PREPPIPE_TOOL=imagepack")
Expand Down

0 comments on commit 2163e70

Please sign in to comment.