"""Small, typed template-edit programs; source strokes remain compiler data. Edits address occurrence paths (r0/r2), not shared component names. Copy-on-write keeps other uses of the same component unchanged. Record IDs do not renumber after deletion. Original KAGE stretch/transform fields survive unchanged. """ from __future__ import annotations import copy import math import re from .catalog import hash_json, refs SCHEMA = 'guwenzi.template-program.v1' COMPILER = 'template-program-v1' PRIMITIVES = { 'loop': '1:12:13:20:20:20:180$1:2:2:20:20:180:20$1:22:23:180:20:180:180$1:2:2:20:180:180:180', 'dot': '2:7:8:55:35:100:75:140:140', 'bar': '1:0:0:20:100:180:100', 'slash': '1:0:0:160:30:40:170', } def number(value): if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): raise ValueError('布局值须为有限数值。') return value def box(value): if not isinstance(value, list) or len(value) != 4: raise ValueError('box 必须为 [left,top,right,bottom],使用部件的 200 单位局部字格。') numbers = [number(v) for v in value] if any(v != int(v) for v in numbers): raise ValueError('当前 KAGE 后端使用整数字格;不静默截断小数坐标。') if numbers[0] >= numbers[2] or numbers[1] >= numbers[3]: raise ValueError('布局框宽高必须为正。') return [int(v) for v in numbers] def same_canvas_alias(fields): return (len(fields) >= 8 and fields[:7] == ['99','0','0','0','0','200','200'] and all(float(x or 0) == 0 for x in fields[8:])) class Instance: def __init__(self, name, table): self.name, self.table = name, table self.records = [{'id': f'r{i}', 'fields': fragment.split(':'), 'child': None} for i, fragment in enumerate(table[name].split('$'))] self.used_ids = {row['id'] for row in self.records} def record(self, identifier): for row in self.records: if row['id'] == identifier: return row raise ValueError('节点不存在或已删除:' + identifier) def child(self, row): if row['fields'][0] != '99': raise ValueError('该节点是直接笔画记录,没有可展开的子部件。') if row['child'] is None: row['child'] = Instance(row['fields'][7], self.table) return row['child'] def target(self, path): if not isinstance(path, str) or not re.fullmatch(r'(?:r[0-9]+|a_[a-z0-9_-]+)(?:/(?:r[0-9]+|a_[a-z0-9_-]+))*', path): raise ValueError('node 使用工具返回的节点路径,如 r0 或 r0/r2。') node, ids = self, path.split('/') for identifier in ids[:-1]: node = node.child(node.record(identifier)) return node, node.record(ids[-1]) def container(self, path): if path == 'root': return self node, row = self.target(path) return node.child(row) def emit(self, table): fragments = [] for row in self.records: fields = list(row['fields']) if row['child'] is not None: fields[7] = row['child'].emit(table) fragments.append(':'.join(fields)) if not fragments: raise ValueError('修改后的部件为空;不能输出空白模板。') data = '$'.join(fragments) if data == self.table[self.name]: return self.name name = 'openglyph-template-' + hash_json(data) if name in table and table[name] != data: raise ValueError('模板程序名冲突。') table[name] = data return name class TemplateProgram: def __init__(self, catalog, base): self.catalog = catalog self.base = catalog.resolve(base) self.table = dict(self.base['table']) self.selections = [{k: self.base[k] for k in ('name','ref','closure_sha256')}] name = self.base['name'] self.aliases = [] while True: fragments = self.table[name].split('$') if len(fragments) != 1 or not same_canvas_alias(fragments[0].split(':')): break self.aliases.append(name) name = fragments[0].split(':')[7] self.root = Instance(name, self.table) self.edits = [] def select(self, query): if isinstance(query, str) and query.startswith('primitive:'): primitive = query[10:] if '@' in primitive: primitive, version = primitive.rsplit('@', 1) if version != '1': raise ValueError('未知图形模板版本;不会静默替换。') if primitive not in PRIMITIVES: raise ValueError('未知图形模板:' + primitive) name = 'openglyph-primitive-' + primitive + '-v1' data = PRIMITIVES[primitive] if name in self.table and self.table[name] != data: raise ValueError('图形模板与来源程序同名冲突。') self.table[name] = data return name, 'primitive:' + primitive + '@1' selected = self.catalog.resolve(query) for name, data in selected['table'].items(): if name in self.table and self.table[name] != data: raise ValueError('同名依赖存在版本冲突:' + name) self.table.update(selected['table']) self.selections.append({k: selected[k] for k in ('name','ref','closure_sha256')}) return selected['name'], selected['ref'] def apply(self, action): if not isinstance(action, dict): raise ValueError('每个修改必须为操作对象。') action = copy.deepcopy(action) op = action.get('op') allowed = {'replace': {'op','node','glyph'}, 'drop': {'op','node'}, 'place': {'op','node','box'}, 'move': {'op','node','delta'}, 'insert': {'op','parent','id','glyph','box'}, 'group': {'op','parent','id','nodes'}} if not isinstance(op,str) or op not in allowed or set(action) != allowed[op]: raise ValueError('不支持的修改操作或字段:' + str(op)) if op in ('insert','group'): container = self.root.container(action['parent']) label = action['id'] if not isinstance(label, str) or not re.fullmatch(r'[a-z][a-z0-9_-]*', label): raise ValueError('新增节点 id 使用 ASCII 小写字母、数字、下划线或连字符。') identifier = 'a_' + label if identifier in container.used_ids: raise ValueError('新增节点 id 重复:' + label) if op == 'insert': bounds = box(action['box']) name, action['glyph'] = self.select(action['glyph']) container.records.append({'id': identifier, 'fields': ['99','0','0',*map(str,bounds),name], 'child': None}) else: nodes=action['nodes'] if not isinstance(nodes,list) or not nodes or any(not isinstance(n,str) for n in nodes) or len(set(nodes))!=len(nodes): raise ValueError('group.nodes 须为当前层不重复的节点编号。') selected=[container.record(n) for n in nodes] indices=[container.records.index(r) for r in selected] if indices != list(range(indices[0],indices[0]+len(indices))): raise ValueError('只允许按原顺序组合相邻记录,避免改变笔画覆盖顺序。') if any(r['fields'][0] in ('','0') for r in selected): raise ValueError('不能将分隔符或作用域变换记录归并为普通部件。') emitted=dict(self.table) fragments=[] for row in selected: fields=list(row['fields']) if row['child'] is not None:fields[7]=row['child'].emit(emitted) fragments.append(':'.join(fields)) data='$'.join(fragments) name='openglyph-group-'+hash_json(data) emitted[name]=data self.table.update(emitted) child=Instance(name,self.table) child.records=selected child.used_ids={r['id'] for r in selected} container.records[indices[0]:indices[-1]+1]=[{'id':identifier, 'fields':['99','0','0','0','0','200','200',name], 'child':child}] container.used_ids.add(identifier) else: container, row = self.root.target(action['node']) if op == 'drop': if row['fields'][0] == '': raise ValueError('来源分隔符不是可删除笔画。') if not any(r is not row and r['fields'][0] not in ('','0') for r in container.records): raise ValueError('不能删除部件的全部记录。') container.records.remove(row) else: if row['fields'][0] != '99': raise ValueError('此操作需要部件节点;直接笔画可 drop,或将它作为独立模块后编辑。') if op == 'replace': name, action['glyph'] = self.select(action['glyph']) row['fields'][7], row['child'] = name, None elif op == 'place': row['fields'][3:7] = map(str, box(action['box'])) elif op == 'move': if not isinstance(action['delta'], list) or len(action['delta']) != 2: raise ValueError('delta 须为 [dx,dy]。') dx, dy = map(number, action['delta']) bounds = [float(v) for v in row['fields'][3:7]] row['fields'][3:7] = map(str, box([bounds[0]+dx,bounds[1]+dy,bounds[2]+dx,bounds[3]+dy])) self.edits.append(action) def describe(self, node='root'): container = self.root.container(node) rows = [] for record in container.records: fields = record['fields'] path = record['id'] if node == 'root' else node + '/' + record['id'] if fields[0] == '99': primitive = next((p for p in PRIMITIVES if fields[7] == 'openglyph-primitive-' + p + '-v1'),None) local=fields[7].startswith('openglyph-group-') selected = self.catalog.resolve(fields[7]) if primitive is None and not local else None rows.append({'node': path, 'kind': 'component', 'glyph_name': fields[7], 'glyph_ref': selected['ref'] if selected else ('primitive:' + primitive + '@1' if primitive else None), 'local_module':local, 'locally_edited': record['child'] is not None and record['child'].emit(dict(self.table)) != fields[7], 'box': [float(v) for v in fields[3:7]], 'placement_flags_preserved': fields[1:3] + fields[8:], 'simple_placement': fields[1:3] == ['0','0'] and all(float(x or 0)==0 for x in fields[8:]), 'actions': ['replace','drop','place','move','inspect_children']}) else: rows.append({'node': path, 'kind': 'source_record', 'record_type': fields[0], 'actions': ['drop'] if fields[0] else [], 'source_record_preserved': True}) return {'schema': 'guwenzi.template-inspection.v1', 'base': self.base['ref'], 'root_aliases': self.aliases, 'inspected_node': node, 'glyph_name': container.name, 'nodes': rows, 'coordinate_space': 'parent_component_200_unit_grid', 'next_step': 'Select by actual previews. Nested occurrences are independent; node IDs do not renumber after drop.', 'program': {'schema': SCHEMA, 'base': self.base['ref'], 'edits': list(self.edits)}} def compile(self): table = dict(self.table) root = self.root.emit(table) if root == self.root.name and not getattr(self, 'selected_root', False): root = self.base['name'] closure = {} pending = [root] while pending: name = pending.pop() if name in closure: continue if name not in table: raise ValueError('编译缺少部件:' + name) closure[name] = table[name] pending.extend(refs(table[name])) program = {'schema': SCHEMA, 'base': self.base['ref'], 'edits': copy.deepcopy(self.edits)} selected = list({r['ref']: r for r in self.selections}.values()) recipe = {'schema': 'guwenzi.kage-construction.v1', 'root': root, 'table': closure, 'source_kind': 'printed', 'compiler': COMPILER, 'template_program': program, 'selected_components': selected, 'reconstruction_label': '模板原形复用' if not self.edits else '模板局部修改'} info = {'mode': 'template_reuse' if not self.edits else 'template_edit', 'compiler': COMPILER, 'base_glyph_ref': self.base['ref'], 'program': program, 'program_sha256': hash_json(program), 'recipe_sha256': hash_json(recipe), 'selected_components': selected, 'source_equivalence_verified': False, 'unicode_identity_verified': False, 'raw_strokes_authored_by_model': False, 'occurrence_edits_are_local': True, 'unchanged_source_records_preserved': True} return recipe, info def compile_program(catalog, value): if not isinstance(value, dict) or set(value) != {'schema','base','edits'} or value['schema'] != SCHEMA or not isinstance(value['edits'], list): raise ValueError('模板程序需要 schema/base/edits;不接受原始 KAGE 笔画串。') program = TemplateProgram(catalog, value['base']) for index, edit in enumerate(value['edits']): try: program.apply(edit) except (ValueError, TypeError, KeyError) as exc: raise ValueError(f'edits[{index}]: {exc}') from exc return program.compile()