import logging import os import re from copy import deepcopy from cchardet import detect from lxml import etree from xmldiff import main from xmldiff.actions import UpdateTextIn, InsertComment, UpdateTextAfter, DeleteNode from .translate import ChatTranslator SPACE = "{http://www.w3.org/XML/1998/namespace}space" logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" ) def judge_encoding(file): with open(file, "rb") as _: content = _.read() result = detect(content) return result["encoding"] def parse_lang_str(doc_path): lang_str = os.path.basename(doc_path) match lang_str: case s if "zh-cn" in s: return "Chinese (Simplified)" case s if "zh-tw" in s: return "Chinese (Traditional)" case s if "en-us" in s: return "English" case s if "ja-jp" in s: return "Japanese" case s if "ko-kr" in s: return "Korean" case _: raise ValueError("wrong language input") class XamlParser: """ XamlParser类,用于解析xaml文件 """ def __init__( self, file=None, parse_type=0, xaml_string=None, language=None, encoding="utf-8" ): """ 初始化XamlParser类 :param file: 从文件解析时,传入文件路径 :param parse_type: 0表示从文件解析,1表示从字符串解析 :param xaml_string: 从字符串解析时,传入xaml字符串 :param language: 从字符串解析时,传入语言 :param encoding: 从字符串解析时,传入编码 """ self.__test = False self.__counter = 0 if parse_type == 0: ( self._file_path, self.__encoding, self.__language, xaml_string, ) = self.__from_file(file) elif parse_type == 1: assert ( language is not None ), "language must be specified when parse_type is 1" self._file_path, self.__encoding, self.__language = file, encoding, language else: raise ValueError("parse_type must be 0 or 1") xaml_string = xaml_string.replace(" ", "\\n") self.__root = etree.fromstring(xaml_string) # 获取命名空间 self.__nsmap = self.__root.nsmap self.__default_namespace = self.__nsmap.get(None) self.__x_ns = self.__nsmap.get("x") self.__x_uid_ns, self.__x_key_ns = ( f"{{{self.__x_ns}}}Uid", f"{{{self.__x_ns}}}Key", ) self.__merged_node = self.__root.find( "./ResourceDictionary.MergedDictionaries", namespaces=self.__nsmap ) self.__cp_root = self.copy_node(self.__root, cp_text=True) if self.__merged_node is not None: self.__cp_merged_node = self.copy_node(self.__merged_node, cp_text=True) self.__cp_root.append(self.__cp_merged_node) else: self.__cp_merged_node = None self.__gen_cp_tree_by_traverse( element=self.__root, current_cp_node=self.__cp_root ) def __getattr__(self, prop): properties_map = { "encoding": self.__encoding, "cp_tree": self.__cp_root, "tree": self.__root, "x_uid_ns": self.__x_uid_ns, "x_key_ns": self.__x_key_ns, "language": self.__language, } return properties_map.get(prop, None) @staticmethod def __from_file(file): assert os.path.exists(file), f"{file}file not exists" encoding = judge_encoding(file) language = parse_lang_str(file) with open(file, "r", encoding=encoding) as f: content = f.read() return file, encoding, language, content def __gen_cp_tree_by_traverse(self, element, current_cp_node): for child in element: if child.tag == etree.Comment: # 注释节点 parent = child.getparent() if parent is None: continue children = list(parent) index = children.index(child) if index == 0: if ( self.__merged_node is not None and parent.tag == f"{{{self.__default_namespace}}}ResourceDictionary" ): continue # 注释节点为第一个节点 cp_comment = self.copy_node(child, cp_text=False) current_cp_node.append(cp_comment) continue cp_comment = self.copy_node(child, cp_text=False) prev_node = children[index - 1] prev_cp_node = current_cp_node[index - 1] assert ( prev_node.tag == prev_cp_node.tag and prev_node.attrib == prev_cp_node.attrib ), "前节点类型不一致" current_cp_node.append(cp_comment) continue cp_node = self.copy_node(child) if ( child.tag == f"{{{self.__default_namespace}}}ResourceDictionary.MergedDictionaries" ): # 次根 MergedDictionaries: 遍历后退出循环 current_cp_node = self.__cp_merged_node self.__gen_cp_tree_by_traverse(child, current_cp_node=current_cp_node) break elif child.tag == f"{{{self.__default_namespace}}}ResourceDictionary": # resource dictionary uid组 cp_node.text = child.text current_cp_node.append(cp_node) # 添加说明注释 # chat = ChatTranslator(language=self.language, base_language="english") uid = child.get(self.__x_uid_ns) new_comment = etree.Comment(f"${uid}:") # new_comment.text += self.language + ' ' # new_comment.text += chat.translate(uid) new_comment.tail = child.text if child[0].tag != etree.Comment: child.insert(0, new_comment) else: child[0].text = new_comment.text cp_comment = self.copy_node(new_comment, cp_text=True) cp_node.insert(0, cp_comment) self.__gen_cp_tree_by_traverse(child, current_cp_node=cp_node) elif child.get(self.__x_key_ns) is not None: # 正常节点 current_cp_node.append(cp_node) else: current_cp_node.append(cp_node) self.__gen_cp_tree_by_traverse(child, current_cp_node=cp_node) return current_cp_node @staticmethod def copy_node(node, cp_text=False): if node.tag == etree.Comment: cp_node = etree.Comment(node.text) if cp_text else etree.Comment("") cp_node.tail = node.tail else: cp_node = etree.Element(node.tag, attrib=node.attrib, nsmap=node.nsmap) cp_node.text = node.text if cp_text else "" cp_node.sourceline = node.sourceline cp_node.tail = node.tail return cp_node def write_xaml(self, tree=None, file_path=None): if tree is None: tree = self.tree if file_path is None: file_path = self._file_path if file_path is None: return False xaml_data = etree.tostring(tree, pretty_print=True, encoding=self.__encoding) # xaml_data = html.unescape(xaml_data.decode(self.__encoding)) xaml_data = xaml_data.decode(self.__encoding) with open(file_path, "w", encoding=self.__encoding) as _: _.write(xaml_data) return True def compare_structure(self, compare_parser): """ 比较两个xaml结构,比较时忽略类似下的首个注释节点 :param compare_parser: :return: 如果结构一致返回True,否则返回False """ this_tree = self.cp_tree compare_tree = compare_parser.cp_tree assert ( self.x_key_ns == compare_parser.x_key_ns ), f"{self.language} 和 {compare_parser.language}x:Key命名空间不一致" assert ( self.x_uid_ns == compare_parser.x_uid_ns ), f"{self.language} 和 {compare_parser.language}x:Uid命名空间不一致" pt = re.compile(r"/\*/\*/\*\[\d*]/comment\(\)\[1]") uniqueattrs = [self.x_key_ns, self.x_uid_ns] ignored_attrs = [SPACE] res = main.diff_trees( this_tree, compare_tree, diff_options={ "F": 0.1, "ratio_mode": "accurate", "uniqueattrs": uniqueattrs, "ignored_attrs": ignored_attrs, }, ) if any((isinstance(i, UpdateTextIn) and pt.search(i.node)) for i in res): return False return True def xpath(self, xpath_str, only_one=True, accept_empty=False): ns = self.nsmap del_arr = (None, "") for remove_elem in del_arr: if remove_elem in ns: del ns[remove_elem] search_result = self.tree.xpath(xpath_str, namespaces=ns) if not len(search_result): assert accept_empty, f"xpath: {xpath_str} 搜索结果为空" yield None if only_one: assert len(search_result) == 1, f"xpath: {search_result} 搜索结果数量不为1" yield search_result[0] else: yield from search_result def getpath(self, node): tree = etree.ElementTree(self.__root) return tree.getpath(node) def counter(self, start=False, test=False, messages=""): if test: self.__test = True if self.__test: return if start: self.__counter = 0 logging.info(messages) else: self.__counter += 1 logging.info(f"{messages}: {self.__counter}") def translate_force(self, target_path, skip_translate=False): """ 强制输出本文件到目标文件的翻译,完成后本文件也会更新 :param target_path: 输出的目标文件路径 :param skip_translate: 是否跳过chatgpt翻译 :return: """ target_language = parse_lang_str(target_path) output_tree = deepcopy(self.cp_tree) chat = ( None if skip_translate else ChatTranslator(language=target_language, base_language=self.language) ) self.counter( start=True, test=skip_translate, messages=f"start force translate {self.language} -> {target_language}", ) t = self.__merged_node if self.__merged_node is not None else self.__root for i in t.findall(".//s:String[@x:Key]", namespaces=self.__nsmap): key = i.get(self.__x_key_ns) node = output_tree.find( f'.//s:String[@x:Key="{key}"]', namespaces=self.__nsmap ) node.text = i.text if skip_translate else chat.translate(i.text) node.text = i.text if node.text is None else node.text self.counter(messages=f"translate {i.text[0:20]}") self.write_xaml(output_tree, target_path) self.write_xaml() def translate_compare(self, compare_parser, skip_translate=False): """ 输出本文件到目标文件的翻译,若目标文件存在与本文件相同的节点,则跳过该节点,完成后本文件也会更新 :param compare_parser: 目标文件的XamlParser :param skip_translate: 是否跳过chatgpt翻译 :return: 返回翻译后的Xaml lxml树 """ target_cp_tree = self.cp_tree base_cp_tree = compare_parser.cp_tree assert ( self.nsmap == compare_parser.nsmap ), f"{self.language} 和 {compare_parser.language}命名空间不一致" assert ( self.x_key_ns == compare_parser.x_key_ns ), f"{self.language}和{compare_parser.language}x:Key命名空间不一致" assert ( self.x_uid_ns == compare_parser.x_uid_ns ), f"{self.language}和{compare_parser.language}x:Uid命名空间不一致" uniqueattrs = [self.x_key_ns, self.x_uid_ns] ignored_attrs = [SPACE] chat = ( None if skip_translate else ChatTranslator( language=self.language, base_language=compare_parser.language ) ) self.counter( start=True, test=skip_translate, messages=f"start compare translate {compare_parser.language} -> {self.language}", ) res = main.diff_trees( target_cp_tree, base_cp_tree, diff_options={ "F": 0.1, "ratio_mode": "accurate", "uniqueattrs": uniqueattrs, "ignored_attrs": ignored_attrs, }, ) new_action = [] logging.info( f"all movements contains {len([i for i in res if isinstance(i, UpdateTextIn)])} steps" ) for i in res: if type(i).__name__ == "MoveNode" and "comment()" in i.node: try: str_node = next(compare_parser.xpath(i.target))[i.position] parts = i.node.split("/") # 提取父节点的路径 parent_path = "/".join(parts[:-1]) node_position = "{}/comment()[{}]".format( i.target, len(str_node.xpath("preceding-sibling::comment()")) + 1, ) if parent_path == compare_parser.getpath(str_node.getparent()): # 如果父节点路径相同,说明 new_action.append(i) new_action.append(UpdateTextIn(node_position, str_node.text)) continue new_action.append( InsertComment(i.target, i.position, str_node.text) ) new_action.append(UpdateTextAfter(node_position, str_node.tail)) new_action.append(DeleteNode(i.node)) except Exception as _: logging.warning(f"{type(_)}: {_}") new_action.append(i) elif isinstance(i, UpdateTextIn): if "comment()" in i.node: continue str_node = next(compare_parser.xpath(i.node)) if str_node.text.strip(): text = ( str_node.text if skip_translate else chat.translate(str_node.text) ) text = str_node.text if text is None else text else: text = str_node.text self.counter(messages=f"translate {str_node.text[0:20]}") new_action.append(UpdateTextIn(i.node, text)) elif isinstance(i, InsertComment): str_node = next(compare_parser.xpath(i.target))[i.position] if str_node.tag == etree.Comment: node_position = "{}/comment()[{}]".format( i.target, len(str_node.xpath("preceding-sibling::comment()")) + 1, ) new_action.append( InsertComment(i.target, i.position, str_node.text) ) new_action.append(UpdateTextAfter(node_position, str_node.tail)) else: new_action.append(i) else: new_action.append(i) final_tree = main.patch_tree(new_action, self.tree) compare_parser.write_xaml() self.write_xaml(final_tree, self._file_path) return final_tree def update_translate( self, compare_old_parser, compare_new_parser, skip_translate=False ): """ 将比较新旧两个文件的更新,并将更新同步到本文件,若本文件与旧文件结构不同,则会与旧文件调用translate_compare :param compare_old_parser: 旧文件的XamlParser :param compare_new_parser: 新文件的XamlParser :param skip_translate: 是否跳过chatgpt翻译 :return: 返回翻译后的Xaml lxml树 """ compare_old_tree = compare_old_parser.merged_root_tree if not self.compare_structure(compare_old_parser): target_tree = self.translate_compare( compare_old_parser, skip_translate=skip_translate ) else: target_tree = self.cp_tree compare_new_tree = compare_new_parser.merged_root_tree namespace_ununit_string = "old %s和 new %s{}命名空间不一致" % ( compare_old_parser.language, compare_new_parser.language, ) for prop, err in {"nsmap": "", "x_key_ns": "x:Key", "x_uid_ns": "x:Uid"}: assert getattr(compare_old_parser, prop) == getattr( compare_new_parser, prop ), namespace_ununit_string.format(err) chat = ( None if skip_translate else ChatTranslator( language=self.language, base_language=compare_new_parser.language ) ) self.counter( start=True, test=skip_translate, messages=f"start compare translate {compare_new_parser.language} -> {self.language}", ) uniqueattrs = [self.x_key_ns, self.x_uid_ns] ignored_attrs = [SPACE] res = main.diff_trees( compare_old_tree, compare_new_tree, diff_options={ "F": 0.1, "ratio_mode": "accurate", "uniqueattrs": uniqueattrs, "ignored_attrs": ignored_attrs, }, ) new_actions = [] logging.info( f"all movements contains {len([i for i in res if isinstance(i, UpdateTextIn)])} steps" ) for i in res: if isinstance(i, UpdateTextIn): if i.node.endswith("comment()[1]") and i.text[0] == "$": continue elif "s:String" in i.node: text = i.text if skip_translate else chat.translate(i.text) text = i.text if text is None else text self.counter(messages=f"translate {i.text[0:20]}") new_actions.append(UpdateTextIn(i.node, text)) continue new_actions.append(i) final_tree = main.patch_tree(new_actions, target_tree) compare_new_parser.write_xaml() self.write_xaml(final_tree) return final_tree @property def nsmap(self): return deepcopy(self.__nsmap) @property def merged_root_tree(self): root = self.copy_node(self.__root, cp_text=True) if self.__merged_node is not None: root.append(deepcopy(self.__merged_node)) else: root = deepcopy(self.__root) return root @property def tostring(self): return etree.tostring( self.tree, encoding=self.__encoding, pretty_print=True ).decode(self.__encoding) if __name__ == "__main__": zh_parser = XamlParser(parse_type=0, file="../../example/zh-cn.xaml") zh_new_parser = XamlParser(parse_type=0, file="../../example/zh-cn_new.xaml") print()