template_service.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. """
  2. 模板服务
  3. 管理表格线条模板的创建、应用、预览和删除
  4. """
  5. import json
  6. import os
  7. from pathlib import Path
  8. from typing import List, Dict, Optional, Tuple
  9. from datetime import datetime
  10. class TemplateService:
  11. """表格线条模板服务"""
  12. # 模板存储目录
  13. _file_path = Path(__file__).resolve()
  14. TEMPLATE_DIR = _file_path.parent.parent / "templates"
  15. TEMPLATE_SUFFIX = ".template.json"
  16. def __init__(self):
  17. """初始化模板服务,确保模板目录存在"""
  18. self.TEMPLATE_DIR.mkdir(parents=True, exist_ok=True)
  19. def _get_template_path(self, name: str) -> Path:
  20. """获取模板文件路径"""
  21. # 清理名称,移除不安全字符
  22. safe_name = "".join(c for c in name if c.isalnum() or c in "._- ")
  23. return self.TEMPLATE_DIR / f"{safe_name}{self.TEMPLATE_SUFFIX}"
  24. def create_template(
  25. self,
  26. name: str,
  27. structure: Dict,
  28. image_size: Dict,
  29. description: str = ""
  30. ) -> Dict:
  31. """
  32. 从当前结构创建模板
  33. Args:
  34. name: 模板名称(用户自定义)
  35. structure: 当前表格结构 {horizontal_lines, vertical_lines, table_bbox, ...}
  36. image_size: 图片尺寸 {width, height}
  37. description: 模板描述
  38. Returns:
  39. 创建的模板信息
  40. """
  41. if not name or not name.strip():
  42. raise ValueError("模板名称不能为空")
  43. name = name.strip()
  44. template_path = self._get_template_path(name)
  45. if template_path.exists():
  46. raise ValueError(f"模板 '{name}' 已存在")
  47. # 提取模板信息
  48. horizontal_lines = structure.get("horizontal_lines", [])
  49. vertical_lines = structure.get("vertical_lines", [])
  50. table_bbox = structure.get("table_bbox")
  51. if not horizontal_lines and not vertical_lines:
  52. raise ValueError("结构中没有线条信息")
  53. # 计算相对坐标(相对于 table_bbox,这样应用时可以直接映射到目标 bbox)
  54. img_width = image_size.get("width", 1)
  55. img_height = image_size.get("height", 1)
  56. # 如果有 table_bbox,计算相对于 bbox 的坐标;否则相对于整个图片
  57. if table_bbox and len(table_bbox) == 4:
  58. bbox_x1, bbox_y1, bbox_x2, bbox_y2 = table_bbox
  59. bbox_width = bbox_x2 - bbox_x1
  60. bbox_height = bbox_y2 - bbox_y1
  61. # 相对于 table_bbox 的坐标 (0-1)
  62. rel_horizontal_lines = [(y - bbox_y1) / bbox_height for y in horizontal_lines] if bbox_height > 0 else []
  63. rel_vertical_lines = [(x - bbox_x1) / bbox_width for x in vertical_lines] if bbox_width > 0 else []
  64. # table_bbox 本身相对于图片尺寸
  65. rel_table_bbox = [
  66. bbox_x1 / img_width,
  67. bbox_y1 / img_height,
  68. bbox_x2 / img_width,
  69. bbox_y2 / img_height,
  70. ]
  71. else:
  72. # 没有 table_bbox 时,相对于整个图片
  73. rel_horizontal_lines = [y / img_height for y in horizontal_lines]
  74. rel_vertical_lines = [x / img_width for x in vertical_lines]
  75. rel_table_bbox = None
  76. template = {
  77. "name": name,
  78. "description": description,
  79. "created_at": datetime.now().isoformat(),
  80. "source_image_size": image_size,
  81. # 存储绝对坐标
  82. "horizontal_lines": horizontal_lines,
  83. "vertical_lines": vertical_lines,
  84. "table_bbox": table_bbox,
  85. # 相对坐标 (0-1范围),相对于 table_bbox,用于不同尺寸图片的适配
  86. "relative": {
  87. "horizontal_lines": rel_horizontal_lines,
  88. "vertical_lines": rel_vertical_lines,
  89. "table_bbox": rel_table_bbox
  90. },
  91. # 统计信息
  92. "stats": {
  93. "row_count": len(horizontal_lines) - 1 if len(horizontal_lines) > 1 else 0,
  94. "col_count": len(vertical_lines) - 1 if len(vertical_lines) > 1 else 0,
  95. }
  96. }
  97. # 保存模板
  98. with open(template_path, 'w', encoding='utf-8') as f:
  99. json.dump(template, f, ensure_ascii=False, indent=2)
  100. return {
  101. "name": name,
  102. "path": str(template_path),
  103. "stats": template["stats"],
  104. "created_at": template["created_at"]
  105. }
  106. def list_templates(self) -> List[Dict]:
  107. """
  108. 列出所有模板
  109. Returns:
  110. 模板列表 [{name, description, created_at, stats}, ...]
  111. """
  112. templates = []
  113. for file_path in self.TEMPLATE_DIR.glob(f"*{self.TEMPLATE_SUFFIX}"):
  114. try:
  115. with open(file_path, 'r', encoding='utf-8') as f:
  116. template = json.load(f)
  117. templates.append({
  118. "name": template.get("name", file_path.stem),
  119. "description": template.get("description", ""),
  120. "created_at": template.get("created_at", ""),
  121. "stats": template.get("stats", {}),
  122. "source_image_size": template.get("source_image_size", {})
  123. })
  124. except Exception as e:
  125. print(f"读取模板 {file_path} 失败: {e}")
  126. continue
  127. # 按创建时间排序(最新在前)
  128. templates.sort(key=lambda x: x.get("created_at", ""), reverse=True)
  129. return templates
  130. def get_template(self, name: str) -> Optional[Dict]:
  131. """
  132. 获取模板详情
  133. Args:
  134. name: 模板名称
  135. Returns:
  136. 模板详情或 None
  137. """
  138. template_path = self._get_template_path(name)
  139. if not template_path.exists():
  140. return None
  141. with open(template_path, 'r', encoding='utf-8') as f:
  142. return json.load(f)
  143. def delete_template(self, name: str) -> bool:
  144. """
  145. 删除模板
  146. Args:
  147. name: 模板名称
  148. Returns:
  149. 是否删除成功
  150. """
  151. template_path = self._get_template_path(name)
  152. if template_path.exists():
  153. template_path.unlink()
  154. return True
  155. return False
  156. def preview_apply(
  157. self,
  158. template_name: str,
  159. target_image_size: Dict,
  160. target_table_bbox: Optional[List] = None,
  161. mode: str = "relative"
  162. ) -> Dict:
  163. """
  164. 预览模板应用效果(不保存)
  165. Args:
  166. template_name: 模板名称
  167. target_image_size: 目标图片尺寸 {width, height}
  168. target_table_bbox: 目标页面的表格边界框 [x1, y1, x2, y2]
  169. 如果提供,则将模板线条映射到此区域内
  170. 如果不提供,则使用模板的 table_bbox(缩放后)
  171. mode: 应用模式
  172. - "relative": 按相对坐标缩放(适用于不同尺寸图片)
  173. - "absolute": 使用绝对坐标(适用于相同尺寸图片)
  174. Returns:
  175. 预览结构 {horizontal_lines, vertical_lines, table_bbox, ...}
  176. """
  177. template = self.get_template(template_name)
  178. if not template:
  179. raise ValueError(f"模板 '{template_name}' 不存在")
  180. target_width = target_image_size.get("width", 1)
  181. target_height = target_image_size.get("height", 1)
  182. # 获取模板的相对坐标信息(相对于 table_bbox 的 0-1 坐标)
  183. relative = template.get("relative", {})
  184. rel_h_lines = relative.get("horizontal_lines", [])
  185. rel_v_lines = relative.get("vertical_lines", [])
  186. rel_bbox = relative.get("table_bbox")
  187. if mode == "absolute":
  188. # 使用绝对坐标(直接使用模板原始值)
  189. horizontal_lines = template.get("horizontal_lines", []).copy()
  190. vertical_lines = template.get("vertical_lines", []).copy()
  191. table_bbox = template.get("table_bbox")
  192. else:
  193. # relative 模式
  194. if target_table_bbox and len(target_table_bbox) == 4:
  195. # 使用目标页面的 table_bbox
  196. bbox_x1, bbox_y1, bbox_x2, bbox_y2 = target_table_bbox
  197. table_bbox = list(target_table_bbox)
  198. elif rel_bbox and len(rel_bbox) == 4:
  199. # 使用模板的 table_bbox(缩放到目标图片尺寸)
  200. bbox_x1 = int(rel_bbox[0] * target_width)
  201. bbox_y1 = int(rel_bbox[1] * target_height)
  202. bbox_x2 = int(rel_bbox[2] * target_width)
  203. bbox_y2 = int(rel_bbox[3] * target_height)
  204. table_bbox = [bbox_x1, bbox_y1, bbox_x2, bbox_y2]
  205. else:
  206. # 没有 bbox 信息,使用整个图片
  207. bbox_x1, bbox_y1 = 0, 0
  208. bbox_x2, bbox_y2 = target_width, target_height
  209. table_bbox = [bbox_x1, bbox_y1, bbox_x2, bbox_y2]
  210. bbox_width = bbox_x2 - bbox_x1
  211. bbox_height = bbox_y2 - bbox_y1
  212. # 将模板的相对位置(0-1)映射到目标 bbox 内
  213. horizontal_lines = [
  214. int(bbox_y1 + y * bbox_height)
  215. for y in rel_h_lines
  216. ]
  217. vertical_lines = [
  218. int(bbox_x1 + x * bbox_width)
  219. for x in rel_v_lines
  220. ]
  221. # 强制对齐:确保第一条和最后一条线与 table_bbox 边界一致
  222. if table_bbox and horizontal_lines:
  223. if len(horizontal_lines) > 0:
  224. horizontal_lines[0] = table_bbox[1] # 第一条横线 = top
  225. if len(horizontal_lines) > 1:
  226. horizontal_lines[-1] = table_bbox[3] # 最后一条横线 = bottom
  227. if table_bbox and vertical_lines:
  228. if len(vertical_lines) > 0:
  229. vertical_lines[0] = table_bbox[0] # 第一条竖线 = left
  230. if len(vertical_lines) > 1:
  231. vertical_lines[-1] = table_bbox[2] # 最后一条竖线 = right
  232. # 计算行列数
  233. total_rows = len(horizontal_lines) - 1 if len(horizontal_lines) > 1 else 0
  234. total_cols = len(vertical_lines) - 1 if len(vertical_lines) > 1 else 0
  235. return {
  236. "horizontal_lines": horizontal_lines,
  237. "vertical_lines": vertical_lines,
  238. "table_bbox": table_bbox,
  239. "total_rows": total_rows,
  240. "total_cols": total_cols,
  241. "modified_h_lines": [],
  242. "modified_v_lines": [],
  243. "applied_template": template_name,
  244. "apply_mode": mode
  245. }
  246. def apply_template(
  247. self,
  248. template_name: str,
  249. target_image_size: Dict,
  250. target_table_bbox: Optional[List] = None,
  251. mode: str = "relative"
  252. ) -> Dict:
  253. """
  254. 应用模板(返回结构,由调用者决定是否保存)
  255. 与 preview_apply 相同,但标记为已确认应用
  256. """
  257. structure = self.preview_apply(template_name, target_image_size, target_table_bbox, mode)
  258. structure["confirmed"] = True
  259. return structure
  260. # 单例
  261. _template_service: Optional[TemplateService] = None
  262. def get_template_service() -> TemplateService:
  263. """获取模板服务单例"""
  264. global _template_service
  265. if _template_service is None:
  266. _template_service = TemplateService()
  267. return _template_service