parse-bench 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. parse_bench/__init__.py +3 -0
  2. parse_bench/analysis/__init__.py +6 -0
  3. parse_bench/analysis/aggregation_report.py +582 -0
  4. parse_bench/analysis/cli.py +472 -0
  5. parse_bench/analysis/comparison.py +382 -0
  6. parse_bench/analysis/comparison_core.py +357 -0
  7. parse_bench/analysis/comparison_report.py +2066 -0
  8. parse_bench/analysis/detailed_report.py +2254 -0
  9. parse_bench/analysis/leaderboard_report.py +852 -0
  10. parse_bench/analysis/metric_definitions.py +771 -0
  11. parse_bench/cli.py +267 -0
  12. parse_bench/data/__init__.py +1 -0
  13. parse_bench/data/cli.py +118 -0
  14. parse_bench/data/download.py +127 -0
  15. parse_bench/evaluation/__init__.py +11 -0
  16. parse_bench/evaluation/cli.py +435 -0
  17. parse_bench/evaluation/evaluators/__init__.py +17 -0
  18. parse_bench/evaluation/evaluators/base.py +34 -0
  19. parse_bench/evaluation/evaluators/extract.py +429 -0
  20. parse_bench/evaluation/evaluators/layoutdet.py +1682 -0
  21. parse_bench/evaluation/evaluators/parse.py +1353 -0
  22. parse_bench/evaluation/evaluators/qa.py +199 -0
  23. parse_bench/evaluation/layout_adapters/__init__.py +21 -0
  24. parse_bench/evaluation/layout_adapters/adapters.py +3180 -0
  25. parse_bench/evaluation/layout_adapters/base.py +105 -0
  26. parse_bench/evaluation/layout_adapters/registry.py +109 -0
  27. parse_bench/evaluation/layout_label_mappers/__init__.py +22 -0
  28. parse_bench/evaluation/layout_label_mappers/base.py +66 -0
  29. parse_bench/evaluation/layout_label_mappers/mappers.py +332 -0
  30. parse_bench/evaluation/layout_label_mappers/projection.py +74 -0
  31. parse_bench/evaluation/layout_label_mappers/registry.py +119 -0
  32. parse_bench/evaluation/metric_aggregation.py +56 -0
  33. parse_bench/evaluation/metrics/__init__.py +5 -0
  34. parse_bench/evaluation/metrics/attribution/__init__.py +35 -0
  35. parse_bench/evaluation/metrics/attribution/constants.py +12 -0
  36. parse_bench/evaluation/metrics/attribution/core.py +1108 -0
  37. parse_bench/evaluation/metrics/attribution/evaluate.py +446 -0
  38. parse_bench/evaluation/metrics/attribution/geometry.py +161 -0
  39. parse_bench/evaluation/metrics/attribution/text_utils.py +233 -0
  40. parse_bench/evaluation/metrics/base.py +33 -0
  41. parse_bench/evaluation/metrics/downstream/__init__.py +0 -0
  42. parse_bench/evaluation/metrics/extract/__init__.py +29 -0
  43. parse_bench/evaluation/metrics/extract/json_subset_match.py +473 -0
  44. parse_bench/evaluation/metrics/extract/json_subset_match_metric.py +81 -0
  45. parse_bench/evaluation/metrics/extract/list_unwrap.py +340 -0
  46. parse_bench/evaluation/metrics/extract/rule_based_metric.py +90 -0
  47. parse_bench/evaluation/metrics/extract/test_rules.py +409 -0
  48. parse_bench/evaluation/metrics/extract/test_types.py +11 -0
  49. parse_bench/evaluation/metrics/field_grounding/__init__.py +21 -0
  50. parse_bench/evaluation/metrics/field_grounding/core.py +437 -0
  51. parse_bench/evaluation/metrics/field_grounding/extract_adapter.py +1224 -0
  52. parse_bench/evaluation/metrics/field_grounding/parse_adapter.py +697 -0
  53. parse_bench/evaluation/metrics/field_grounding/rule_filters.py +19 -0
  54. parse_bench/evaluation/metrics/field_grounding/value_compare.py +190 -0
  55. parse_bench/evaluation/metrics/layoutdet/__init__.py +17 -0
  56. parse_bench/evaluation/metrics/layoutdet/classification_utils.py +300 -0
  57. parse_bench/evaluation/metrics/layoutdet/iou.py +76 -0
  58. parse_bench/evaluation/metrics/parse/__init__.py +5 -0
  59. parse_bench/evaluation/metrics/parse/_vendor_grits_reference.py +531 -0
  60. parse_bench/evaluation/metrics/parse/cross_page_table_consistency.py +165 -0
  61. parse_bench/evaluation/metrics/parse/emphasis_spans.py +242 -0
  62. parse_bench/evaluation/metrics/parse/fast_tree_edit.py +282 -0
  63. parse_bench/evaluation/metrics/parse/grits_metric.py +1125 -0
  64. parse_bench/evaluation/metrics/parse/grits_reference_metric.py +142 -0
  65. parse_bench/evaluation/metrics/parse/header_accuracy_metric.py +1662 -0
  66. parse_bench/evaluation/metrics/parse/llm_normalization/__init__.py +51 -0
  67. parse_bench/evaluation/metrics/parse/llm_normalization/base.py +125 -0
  68. parse_bench/evaluation/metrics/parse/llm_normalization/config.py +44 -0
  69. parse_bench/evaluation/metrics/parse/llm_normalization/postprocess.py +322 -0
  70. parse_bench/evaluation/metrics/parse/llm_normalization/strategy_judge.py +541 -0
  71. parse_bench/evaluation/metrics/parse/mermaid_graph.py +682 -0
  72. parse_bench/evaluation/metrics/parse/rule_based_judge_metric.py +56 -0
  73. parse_bench/evaluation/metrics/parse/rule_based_metric.py +434 -0
  74. parse_bench/evaluation/metrics/parse/rules_bag.py +1161 -0
  75. parse_bench/evaluation/metrics/parse/rules_base.py +751 -0
  76. parse_bench/evaluation/metrics/parse/rules_chart.py +1556 -0
  77. parse_bench/evaluation/metrics/parse/rules_diagram.py +591 -0
  78. parse_bench/evaluation/metrics/parse/rules_form.py +2274 -0
  79. parse_bench/evaluation/metrics/parse/rules_formatting.py +1500 -0
  80. parse_bench/evaluation/metrics/parse/rules_heading.py +228 -0
  81. parse_bench/evaluation/metrics/parse/rules_list.py +226 -0
  82. parse_bench/evaluation/metrics/parse/rules_page_decoration.py +276 -0
  83. parse_bench/evaluation/metrics/parse/rules_table.py +1666 -0
  84. parse_bench/evaluation/metrics/parse/rules_text.py +340 -0
  85. parse_bench/evaluation/metrics/parse/rules_watermark.py +105 -0
  86. parse_bench/evaluation/metrics/parse/structural_consistency_metric.py +251 -0
  87. parse_bench/evaluation/metrics/parse/table_extraction.py +152 -0
  88. parse_bench/evaluation/metrics/parse/table_merging.py +195 -0
  89. parse_bench/evaluation/metrics/parse/table_pairing.py +87 -0
  90. parse_bench/evaluation/metrics/parse/table_parsing.py +955 -0
  91. parse_bench/evaluation/metrics/parse/table_record_match_metric.py +1453 -0
  92. parse_bench/evaluation/metrics/parse/table_splitting.py +301 -0
  93. parse_bench/evaluation/metrics/parse/table_title_stripping.py +530 -0
  94. parse_bench/evaluation/metrics/parse/teds_metric.py +600 -0
  95. parse_bench/evaluation/metrics/parse/test_rules.py +120 -0
  96. parse_bench/evaluation/metrics/parse/test_types.py +103 -0
  97. parse_bench/evaluation/metrics/parse/text_content_projection.py +175 -0
  98. parse_bench/evaluation/metrics/parse/text_similarity_metric.py +61 -0
  99. parse_bench/evaluation/metrics/parse/utils.py +885 -0
  100. parse_bench/evaluation/metrics/qa/__init__.py +5 -0
  101. parse_bench/evaluation/metrics/qa/answer_comparison.py +380 -0
  102. parse_bench/evaluation/qa/__init__.py +5 -0
  103. parse_bench/evaluation/qa/llm_service.py +335 -0
  104. parse_bench/evaluation/reports/__init__.py +8 -0
  105. parse_bench/evaluation/reports/csv.py +64 -0
  106. parse_bench/evaluation/reports/html.py +338 -0
  107. parse_bench/evaluation/reports/markdown.py +98 -0
  108. parse_bench/evaluation/reports/rule_csv.py +22 -0
  109. parse_bench/evaluation/runner.py +1864 -0
  110. parse_bench/evaluation/stats.py +104 -0
  111. parse_bench/extensions.py +72 -0
  112. parse_bench/inference/__init__.py +33 -0
  113. parse_bench/inference/chunkr_layout_extraction.py +160 -0
  114. parse_bench/inference/cli.py +484 -0
  115. parse_bench/inference/layout_extraction.py +422 -0
  116. parse_bench/inference/pipelines/__init__.py +59 -0
  117. parse_bench/inference/pipelines/extract.py +39 -0
  118. parse_bench/inference/pipelines/layout.py +142 -0
  119. parse_bench/inference/pipelines/parse.py +2603 -0
  120. parse_bench/inference/pipelines.py +0 -0
  121. parse_bench/inference/providers/__init__.py +28 -0
  122. parse_bench/inference/providers/base.py +196 -0
  123. parse_bench/inference/providers/cancellation.py +137 -0
  124. parse_bench/inference/providers/extract/__init__.py +22 -0
  125. parse_bench/inference/providers/extract/citations.py +549 -0
  126. parse_bench/inference/providers/extract/extend.py +851 -0
  127. parse_bench/inference/providers/extract/llamaextract_v2_api.py +583 -0
  128. parse_bench/inference/providers/layoutdet/__init__.py +25 -0
  129. parse_bench/inference/providers/layoutdet/adapters.py +946 -0
  130. parse_bench/inference/providers/layoutdet/base.py +203 -0
  131. parse_bench/inference/providers/layoutdet/chandra.py +449 -0
  132. parse_bench/inference/providers/layoutdet/docling.py +125 -0
  133. parse_bench/inference/providers/layoutdet/dots_ocr.py +606 -0
  134. parse_bench/inference/providers/layoutdet/layout_v3.py +137 -0
  135. parse_bench/inference/providers/layoutdet/layout_v3_byoc.py +204 -0
  136. parse_bench/inference/providers/layoutdet/paddle.py +117 -0
  137. parse_bench/inference/providers/layoutdet/qwen3vl.py +360 -0
  138. parse_bench/inference/providers/layoutdet/surya.py +250 -0
  139. parse_bench/inference/providers/layoutdet/yolo.py +109 -0
  140. parse_bench/inference/providers/parse/__init__.py +64 -0
  141. parse_bench/inference/providers/parse/_docling_common.py +233 -0
  142. parse_bench/inference/providers/parse/_layout_utils.py +611 -0
  143. parse_bench/inference/providers/parse/amazon_nova.py +515 -0
  144. parse_bench/inference/providers/parse/anthropic.py +882 -0
  145. parse_bench/inference/providers/parse/azure_document_intelligence.py +700 -0
  146. parse_bench/inference/providers/parse/chandra2.py +633 -0
  147. parse_bench/inference/providers/parse/chunkr.py +268 -0
  148. parse_bench/inference/providers/parse/databricks_ai_parse.py +724 -0
  149. parse_bench/inference/providers/parse/datalab.py +370 -0
  150. parse_bench/inference/providers/parse/deepseekocr2.py +382 -0
  151. parse_bench/inference/providers/parse/docling.py +281 -0
  152. parse_bench/inference/providers/parse/docling_serve.py +289 -0
  153. parse_bench/inference/providers/parse/dots_ocr.py +574 -0
  154. parse_bench/inference/providers/parse/extend_parse.py +710 -0
  155. parse_bench/inference/providers/parse/falconocr.py +436 -0
  156. parse_bench/inference/providers/parse/florin_parser_nano.py +559 -0
  157. parse_bench/inference/providers/parse/gemma4.py +472 -0
  158. parse_bench/inference/providers/parse/glm_zai.py +229 -0
  159. parse_bench/inference/providers/parse/google.py +1125 -0
  160. parse_bench/inference/providers/parse/google_agentic_vision.py +819 -0
  161. parse_bench/inference/providers/parse/google_docai.py +776 -0
  162. parse_bench/inference/providers/parse/google_docai_layout_normalization.py +573 -0
  163. parse_bench/inference/providers/parse/granite_vision.py +515 -0
  164. parse_bench/inference/providers/parse/infinity_parser2.py +704 -0
  165. parse_bench/inference/providers/parse/kdl_frontier_nano.py +3327 -0
  166. parse_bench/inference/providers/parse/landingai.py +452 -0
  167. parse_bench/inference/providers/parse/liteparse.py +350 -0
  168. parse_bench/inference/providers/parse/llamaparse.py +677 -0
  169. parse_bench/inference/providers/parse/llamaparse_v2_normalization.py +1013 -0
  170. parse_bench/inference/providers/parse/markitdown.py +138 -0
  171. parse_bench/inference/providers/parse/mineru25.py +405 -0
  172. parse_bench/inference/providers/parse/mineru2605pro.py +432 -0
  173. parse_bench/inference/providers/parse/mineru_diffusion.py +371 -0
  174. parse_bench/inference/providers/parse/mistral_ocr.py +546 -0
  175. parse_bench/inference/providers/parse/nemotron_omni.py +473 -0
  176. parse_bench/inference/providers/parse/oi_parser.py +222 -0
  177. parse_bench/inference/providers/parse/openai.py +740 -0
  178. parse_bench/inference/providers/parse/opendataloader.py +152 -0
  179. parse_bench/inference/providers/parse/paddleocr.py +624 -0
  180. parse_bench/inference/providers/parse/pdf_inspector.py +142 -0
  181. parse_bench/inference/providers/parse/pulse.py +785 -0
  182. parse_bench/inference/providers/parse/pymupdf.py +207 -0
  183. parse_bench/inference/providers/parse/pymupdf4llm.py +356 -0
  184. parse_bench/inference/providers/parse/pypdf.py +179 -0
  185. parse_bench/inference/providers/parse/qwen.py +678 -0
  186. parse_bench/inference/providers/parse/rakedoc_nano.py +70 -0
  187. parse_bench/inference/providers/parse/reducto.py +546 -0
  188. parse_bench/inference/providers/parse/surya2.py +372 -0
  189. parse_bench/inference/providers/parse/tesseract.py +301 -0
  190. parse_bench/inference/providers/parse/textract.py +694 -0
  191. parse_bench/inference/providers/parse/unlimitedocr.py +346 -0
  192. parse_bench/inference/providers/parse/unstructured.py +485 -0
  193. parse_bench/inference/providers/parse/warp_ingest.py +199 -0
  194. parse_bench/inference/providers/registry.py +49 -0
  195. parse_bench/inference/renormalize.py +170 -0
  196. parse_bench/inference/runner.py +2023 -0
  197. parse_bench/layout_label_mapping.py +424 -0
  198. parse_bench/layout_projection.py +179 -0
  199. parse_bench/pipeline/__init__.py +1 -0
  200. parse_bench/pipeline/cli.py +549 -0
  201. parse_bench/schemas/__init__.py +33 -0
  202. parse_bench/schemas/evaluation.py +93 -0
  203. parse_bench/schemas/extract_output.py +36 -0
  204. parse_bench/schemas/layout_detection_output.py +545 -0
  205. parse_bench/schemas/layout_ontology.py +315 -0
  206. parse_bench/schemas/metrics.py +69 -0
  207. parse_bench/schemas/parse_output.py +152 -0
  208. parse_bench/schemas/pipeline.py +22 -0
  209. parse_bench/schemas/pipeline_io.py +106 -0
  210. parse_bench/schemas/product.py +97 -0
  211. parse_bench/test_cases/__init__.py +25 -0
  212. parse_bench/test_cases/bbox_value_strict_comparator.py +880 -0
  213. parse_bench/test_cases/extract_field_paths.py +164 -0
  214. parse_bench/test_cases/layout_attribution_generation.py +287 -0
  215. parse_bench/test_cases/loader.py +652 -0
  216. parse_bench/test_cases/parse_rule_schemas.py +1071 -0
  217. parse_bench/test_cases/rule_filters.py +32 -0
  218. parse_bench/test_cases/rule_ids.py +107 -0
  219. parse_bench/test_cases/schema.py +427 -0
  220. parse_bench/utils/__init__.py +15 -0
  221. parse_bench/utils/gemini_layout_utils.py +670 -0
  222. parse_bench/utils/text_aggregation.py +100 -0
  223. parse_bench-1.0.0.dist-info/METADATA +476 -0
  224. parse_bench-1.0.0.dist-info/RECORD +227 -0
  225. parse_bench-1.0.0.dist-info/WHEEL +4 -0
  226. parse_bench-1.0.0.dist-info/entry_points.txt +2 -0
  227. parse_bench-1.0.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,682 @@
1
+ """Parse mermaid source into a plain graph so diagram rules can score *semantics*, not text.
2
+
3
+ Two correct mermaid transcriptions of the same figure rarely share source text: node ids are
4
+ arbitrary, shapes and orientation are stylistic, edge text may sit in ``|pipes|`` or between
5
+ the dashes, and a subgraph is optional. Everything the rules care about is a set of labelled
6
+ nodes and (optionally labelled, optionally directed) edges. This module turns the mermaid
7
+ dialects LlamaParse emits into that ``Graph``:
8
+
9
+ * ``flowchart`` / ``graph`` — full statement grammar (shapes, chains, ``&`` fan-out, inline and
10
+ pipe edge text, all line styles, ``subgraph … end``, style/class/click lines ignored).
11
+ * ``stateDiagram``, ``sequenceDiagram``, ``mindmap``, ``classDiagram``, ``erDiagram`` — nodes and
12
+ relations only, enough for type detection and node/edge alignment.
13
+
14
+ The parser is deliberately lenient (it is scoring an LLM's output, not validating it) but it
15
+ reports ``errors`` for statements it could not read, and a block whose body yields no node at
16
+ all is treated as unparseable by the rules.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import html
22
+ import re
23
+ from dataclasses import dataclass, field
24
+ from typing import Any
25
+
26
+ # Fenced code blocks whose info string is ``mermaid`` (case-insensitive, optional attributes).
27
+ MERMAID_FENCE_RE = re.compile(r"^([ \t]{0,3})(`{3,}|~{3,})[ \t]*mermaid\b[^\n]*\n(.*?)^\1\2[ \t]*$", re.M | re.S | re.I)
28
+
29
+ FLOW_HEADER_RE = re.compile(r"^\s*(flowchart|graph)\b\s*(TB|TD|BT|LR|RL)?\s*;?\s*$", re.I)
30
+ _TYPE_HEADERS: list[tuple[re.Pattern[str], str]] = [
31
+ (re.compile(r"^\s*(flowchart|graph)\b", re.I), "flowchart"),
32
+ (re.compile(r"^\s*stateDiagram(-v2)?\b", re.I), "state"),
33
+ (re.compile(r"^\s*sequenceDiagram\b", re.I), "sequence"),
34
+ (re.compile(r"^\s*mindmap\b", re.I), "mindmap"),
35
+ (re.compile(r"^\s*classDiagram(-v2)?\b", re.I), "class"),
36
+ (re.compile(r"^\s*erDiagram\b", re.I), "er"),
37
+ (re.compile(r"^\s*gantt\b", re.I), "gantt"),
38
+ (re.compile(r"^\s*pie\b", re.I), "pie"),
39
+ (re.compile(r"^\s*timeline\b", re.I), "timeline"),
40
+ (re.compile(r"^\s*journey\b", re.I), "journey"),
41
+ (re.compile(r"^\s*gitGraph\b", re.I), "gitgraph"),
42
+ (re.compile(r"^\s*quadrantChart\b", re.I), "quadrant"),
43
+ (re.compile(r"^\s*xychart(-beta)?\b", re.I), "xychart"),
44
+ (re.compile(r"^\s*block(-beta)?\b", re.I), "block"),
45
+ (re.compile(r"^\s*sankey(-beta)?\b", re.I), "sankey"),
46
+ (re.compile(r"^\s*C4(Context|Container|Component|Dynamic|Deployment)\b"), "c4"),
47
+ (re.compile(r"^\s*requirementDiagram\b", re.I), "requirement"),
48
+ (re.compile(r"^\s*architecture(-beta)?\b", re.I), "architecture"),
49
+ (re.compile(r"^\s*packet(-beta)?\b", re.I), "packet"),
50
+ (re.compile(r"^\s*kanban\b", re.I), "kanban"),
51
+ ]
52
+
53
+
54
+ @dataclass
55
+ class Node:
56
+ id: str
57
+ label: str
58
+ group: str | None = None
59
+
60
+
61
+ @dataclass
62
+ class Edge:
63
+ source: str
64
+ target: str
65
+ label: str = ""
66
+ directed: bool = True
67
+ # True for ``<-->`` style links: the relation exists in both directions.
68
+ bidirectional: bool = False
69
+
70
+
71
+ @dataclass
72
+ class Graph:
73
+ type: str
74
+ direction: str | None = None
75
+ nodes: dict[str, Node] = field(default_factory=dict)
76
+ edges: list[Edge] = field(default_factory=list)
77
+ groups: dict[str, str] = field(default_factory=dict) # group id -> title
78
+ errors: list[str] = field(default_factory=list)
79
+ source: str = ""
80
+
81
+ def node(self, node_id: str, label: str | None = None, group: str | None = None) -> Node:
82
+ """Return the node, creating it; the first explicit label wins over the bare id."""
83
+ n = self.nodes.get(node_id)
84
+ if n is None:
85
+ # A bare id stands in for its label; ``Buying_Box`` reads as "Buying Box".
86
+ n = Node(id=node_id, label=clean_label(label) if label else node_id.replace("_", " "), group=group)
87
+ self.nodes[node_id] = n
88
+ else:
89
+ if label and n.label == n.id:
90
+ n.label = clean_label(label)
91
+ if group and n.group is None:
92
+ n.group = group
93
+ return n
94
+
95
+ @property
96
+ def is_empty(self) -> bool:
97
+ return not self.nodes
98
+
99
+ def to_dict(self) -> dict[str, Any]:
100
+ return {
101
+ "type": self.type,
102
+ "direction": self.direction,
103
+ "nodes": [
104
+ {"id": n.id, "label": n.label, **({"group": n.group} if n.group else {})} for n in self.nodes.values()
105
+ ],
106
+ "edges": [
107
+ {
108
+ "from": e.source,
109
+ "to": e.target,
110
+ **({"label": e.label} if e.label else {}),
111
+ **({"directed": False} if not e.directed else {}),
112
+ **({"bidirectional": True} if e.bidirectional else {}),
113
+ }
114
+ for e in self.edges
115
+ ],
116
+ "groups": dict(self.groups),
117
+ "errors": list(self.errors),
118
+ }
119
+
120
+
121
+ _TAG_RE = re.compile(r"<[^>]+>")
122
+ _MD_RE = re.compile(r"(\*\*|__|\*|_|`)")
123
+
124
+
125
+ def clean_label(raw: str) -> str:
126
+ """Strip quoting, HTML breaks/tags, markdown emphasis and entity codes from a node/edge label."""
127
+ s = raw.strip()
128
+ if len(s) >= 2 and s[0] == s[-1] and s[0] in "\"'":
129
+ s = s[1:-1]
130
+ # ``"`text`"`` markdown-string labels
131
+ if len(s) >= 2 and s[0] == "`" and s[-1] == "`":
132
+ s = s[1:-1]
133
+ s = re.sub(r"<br\s*/?>", " ", s, flags=re.I)
134
+ s = _TAG_RE.sub(" ", s)
135
+ s = s.replace("\\n", " ")
136
+ s = re.sub(r"#(\w+);", lambda m: html.unescape(f"&{m.group(1)};"), s) # mermaid entity codes ``#quot;``
137
+ s = html.unescape(s)
138
+ s = _MD_RE.sub("", s)
139
+ s = re.sub(r"\s+", " ", s).strip()
140
+ return s
141
+
142
+
143
+ def extract_mermaid_blocks(markdown: str) -> list[dict[str, Any]]:
144
+ """Return ``{"source", "offset"}`` for every fenced mermaid block, in document order."""
145
+ return [{"source": m.group(3).strip("\n"), "offset": m.start()} for m in MERMAID_FENCE_RE.finditer(markdown)]
146
+
147
+
148
+ def detect_type(source: str) -> str | None:
149
+ for line in source.splitlines():
150
+ stripped = line.strip()
151
+ if not stripped or stripped.startswith("%%") or stripped.startswith("---"):
152
+ continue
153
+ for pattern, name in _TYPE_HEADERS:
154
+ if pattern.match(stripped):
155
+ return name
156
+ return None
157
+ return None
158
+
159
+
160
+ def _strip_frontmatter_and_comments(source: str) -> list[str]:
161
+ lines = source.splitlines()
162
+ # YAML front matter ``--- ... ---`` at the very top (title/config)
163
+ if lines and lines[0].strip() == "---":
164
+ try:
165
+ end = next(i for i in range(1, len(lines)) if lines[i].strip() == "---")
166
+ lines = lines[end + 1 :]
167
+ except StopIteration:
168
+ pass
169
+ out = []
170
+ for line in lines:
171
+ if line.strip().startswith("%%"):
172
+ continue
173
+ # trailing ``%% comment``
174
+ idx = line.find("%%")
175
+ if idx > 0:
176
+ line = line[:idx]
177
+ out.append(line.rstrip())
178
+ return out
179
+
180
+
181
+ def parse_mermaid(source: str) -> Graph:
182
+ """Parse one mermaid block. Unknown diagram kinds return an empty graph of that type."""
183
+ kind = detect_type(source)
184
+ lines = _strip_frontmatter_and_comments(source)
185
+ if kind == "flowchart":
186
+ graph = _parse_flowchart(lines)
187
+ elif kind == "state":
188
+ graph = _parse_state(lines)
189
+ elif kind == "sequence":
190
+ graph = _parse_sequence(lines)
191
+ elif kind == "mindmap":
192
+ graph = _parse_mindmap(lines)
193
+ elif kind == "class":
194
+ graph = _parse_class(lines)
195
+ elif kind == "er":
196
+ graph = _parse_er(lines)
197
+ else:
198
+ graph = Graph(type=kind or "unknown")
199
+ if kind is None:
200
+ graph.errors.append("no recognised diagram header")
201
+ graph.source = source
202
+ return graph
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # flowchart / graph
207
+ # ---------------------------------------------------------------------------
208
+
209
+ # Shape openers, longest first, with the closer each expects. ``@{`` is the v11 generic-shape syntax.
210
+ _SHAPES: list[tuple[str, str]] = [
211
+ ("(((", ")))"),
212
+ ("((", "))"),
213
+ ("([", "])"),
214
+ ("[[", "]]"),
215
+ ("[(", ")]"),
216
+ ("[/", "/]"), # parallelogram / trapezoid: closer may also be ``\]``
217
+ ("[\\", "\\]"),
218
+ ("{{", "}}"),
219
+ ("@{", "}"),
220
+ ("[", "]"),
221
+ ("(", ")"),
222
+ ("{", "}"),
223
+ (">", "]"),
224
+ ]
225
+ # Ids may contain ``-`` and ``.`` but must stop before an edge start (``--``, ``==``, ``-.``, ``~~``) so
226
+ # ``a-->b`` reads as node ``a``, edge, node ``b``.
227
+ _NODE_ID_RE = re.compile(r"(?:(?!--|==|-\.|~~)[A-Za-z0-9_\-.:À-ÿ])+")
228
+ _EDGE_TEXT_RE = re.compile(
229
+ r"\s*(?P<lhead><)?(?P<open>-{2,}|={2,}|-\.)\s*(?P<text>[^\n]+?)\s*(?P<close>-{2,}|={2,}|\.-+|\.-*\.?-)(?P<rhead>[>xo])?"
230
+ )
231
+ _EDGE_PLAIN_RE = re.compile(r"\s*(?P<lhead><)?(?P<body>={2,}[>xo]?|-{2,}[>xo]?|-\.+-[>xo]?|~{3,})")
232
+ _PIPE_TEXT_RE = re.compile(r"\s*\|(?P<text>[^|]*)\|")
233
+ _SUBGRAPH_RE = re.compile(r"^subgraph\s+(?P<rest>.+)$", re.I)
234
+ _IGNORED_FLOW_PREFIX = ("classDef", "class ", "style ", "linkStyle", "click ", "direction ", "accTitle", "accDescr")
235
+
236
+
237
+ def _find_closer(text: str, start: int, closer: str, alt_closer: str | None = None) -> int:
238
+ """Index of ``closer`` after ``start``, skipping over quoted strings."""
239
+ i = start
240
+ quote: str | None = None
241
+ while i < len(text):
242
+ ch = text[i]
243
+ if quote:
244
+ if ch == quote:
245
+ quote = None
246
+ i += 1
247
+ continue
248
+ if ch in "\"'":
249
+ quote = ch
250
+ i += 1
251
+ continue
252
+ if text.startswith(closer, i):
253
+ return i
254
+ if alt_closer and text.startswith(alt_closer, i):
255
+ return i
256
+ i += 1
257
+ return -1
258
+
259
+
260
+ def _parse_flow_node(text: str, pos: int, graph: Graph, group: str | None) -> tuple[str | None, int]:
261
+ """Parse ``id`` or ``id[label]`` at ``pos``; return (node id, new pos)."""
262
+ m = _NODE_ID_RE.match(text, pos)
263
+ if not m:
264
+ return None, pos
265
+ node_id = m.group(0)
266
+ pos = m.end()
267
+ label: str | None = None
268
+ for opener, closer in _SHAPES:
269
+ if text.startswith(opener, pos):
270
+ alt = None
271
+ if opener in ("[/", "[\\"):
272
+ alt = "\\]" if closer == "/]" else "/]"
273
+ end = _find_closer(text, pos + len(opener), closer, alt)
274
+ if end < 0:
275
+ graph.errors.append(f"unclosed shape for node {node_id!r}")
276
+ return None, len(text)
277
+ inner = text[pos + len(opener) : end]
278
+ if opener == "@{":
279
+ lm = re.search(r"label\s*:\s*(\"[^\"]*\"|'[^']*'|[^,}]+)", inner)
280
+ label = lm.group(1) if lm else node_id
281
+ else:
282
+ label = inner
283
+ closer_len = len(closer) if not alt or text.startswith(closer, end) else len(alt)
284
+ pos = end + closer_len
285
+ break
286
+ # ``:::className`` suffix
287
+ if text.startswith(":::", pos):
288
+ cm = re.match(r":::\s*[\w-]+", text[pos:])
289
+ pos += cm.end() if cm else 3
290
+ graph.node(node_id, label, group)
291
+ return node_id, pos
292
+
293
+
294
+ def _parse_flow_node_group(text: str, pos: int, graph: Graph, group: str | None) -> tuple[list[str], int]:
295
+ """``A & B & C`` — a fan-out group of nodes."""
296
+ ids: list[str] = []
297
+ while True:
298
+ while pos < len(text) and text[pos] in " \t":
299
+ pos += 1
300
+ node_id, pos = _parse_flow_node(text, pos, graph, group)
301
+ if node_id is None:
302
+ break
303
+ ids.append(node_id)
304
+ am = re.match(r"\s*&\s*", text[pos:])
305
+ if not am:
306
+ break
307
+ pos += am.end()
308
+ return ids, pos
309
+
310
+
311
+ def _parse_flow_edge(text: str, pos: int) -> tuple[dict[str, Any] | None, int]:
312
+ m = _EDGE_TEXT_RE.match(text, pos)
313
+ edge: dict[str, Any] | None = None
314
+ if m and not m.group("text").lstrip().startswith(("-", "=", ">", "|", ".")):
315
+ edge = {
316
+ "label": m.group("text"),
317
+ "directed": bool(m.group("rhead")) or bool(m.group("lhead")),
318
+ "bidirectional": bool(m.group("lhead") and m.group("rhead")),
319
+ "invisible": False,
320
+ }
321
+ pos = m.end()
322
+ else:
323
+ m = _EDGE_PLAIN_RE.match(text, pos)
324
+ if not m:
325
+ return None, pos
326
+ body = m.group("body")
327
+ rhead = body[-1] in ">xo"
328
+ edge = {
329
+ "label": "",
330
+ "directed": rhead or bool(m.group("lhead")),
331
+ "bidirectional": bool(m.group("lhead")) and rhead,
332
+ "invisible": body.startswith("~"),
333
+ }
334
+ pos = m.end()
335
+ pm = _PIPE_TEXT_RE.match(text, pos)
336
+ if pm:
337
+ edge["label"] = pm.group("text")
338
+ pos = pm.end()
339
+ return edge, pos
340
+
341
+
342
+ def _split_statements(line: str) -> list[str]:
343
+ """Split on ``;`` outside quotes/brackets."""
344
+ out: list[str] = []
345
+ buf: list[str] = []
346
+ depth = 0
347
+ quote: str | None = None
348
+ for ch in line:
349
+ if quote:
350
+ buf.append(ch)
351
+ if ch == quote:
352
+ quote = None
353
+ continue
354
+ if ch in "\"'":
355
+ quote = ch
356
+ elif ch in "[({":
357
+ depth += 1
358
+ elif ch in "])}":
359
+ depth = max(0, depth - 1)
360
+ if ch == ";" and depth == 0:
361
+ out.append("".join(buf))
362
+ buf = []
363
+ continue
364
+ buf.append(ch)
365
+ out.append("".join(buf))
366
+ return [s.strip() for s in out if s.strip()]
367
+
368
+
369
+ def _parse_flowchart(lines: list[str]) -> Graph:
370
+ graph = Graph(type="flowchart")
371
+ group_stack: list[str] = []
372
+ header_seen = False
373
+ for raw in lines:
374
+ line = raw.strip()
375
+ if not line:
376
+ continue
377
+ if not header_seen:
378
+ hm = FLOW_HEADER_RE.match(line)
379
+ if hm:
380
+ graph.direction = (hm.group(2) or "TB").upper()
381
+ header_seen = True
382
+ continue
383
+ # header with trailing statement on the same line (``graph LR; A-->B``)
384
+ hm2 = re.match(r"^(flowchart|graph)\s+(TB|TD|BT|LR|RL)\s*;?\s*(.*)$", line, re.I)
385
+ if hm2:
386
+ graph.direction = hm2.group(2).upper()
387
+ header_seen = True
388
+ line = hm2.group(3).strip()
389
+ if not line:
390
+ continue
391
+ for stmt in _split_statements(line):
392
+ if stmt.startswith(_IGNORED_FLOW_PREFIX):
393
+ continue
394
+ sm = _SUBGRAPH_RE.match(stmt)
395
+ if sm:
396
+ rest = sm.group("rest").strip()
397
+ gm = re.match(r"^(?P<id>[\w\-.]+)\s*\[(?P<title>.*)\]\s*$", rest)
398
+ if gm:
399
+ gid, title = gm.group("id"), clean_label(gm.group("title"))
400
+ else:
401
+ title = clean_label(rest)
402
+ gid = title
403
+ graph.groups[gid] = title
404
+ group_stack.append(gid)
405
+ continue
406
+ if re.match(r"^end\b", stmt, re.I):
407
+ if group_stack:
408
+ group_stack.pop()
409
+ continue
410
+ group = group_stack[-1] if group_stack else None
411
+ _parse_flow_statement(stmt, graph, group)
412
+ return graph
413
+
414
+
415
+ def _parse_flow_statement(stmt: str, graph: Graph, group: str | None) -> None:
416
+ pos = 0
417
+ left, pos = _parse_flow_node_group(stmt, pos, graph, group)
418
+ if not left:
419
+ graph.errors.append(f"unreadable statement: {stmt[:60]!r}")
420
+ return
421
+ while pos < len(stmt):
422
+ edge, npos = _parse_flow_edge(stmt, pos)
423
+ if edge is None:
424
+ rest = stmt[pos:].strip()
425
+ if rest:
426
+ graph.errors.append(f"trailing text in statement: {rest[:40]!r}")
427
+ break
428
+ right, pos = _parse_flow_node_group(stmt, npos, graph, group)
429
+ if not right:
430
+ graph.errors.append(f"edge without target: {stmt[:60]!r}")
431
+ break
432
+ if not edge["invisible"]:
433
+ for s in left:
434
+ for t in right:
435
+ graph.edges.append(
436
+ Edge(
437
+ source=s,
438
+ target=t,
439
+ label=clean_label(edge["label"]),
440
+ directed=edge["directed"],
441
+ bidirectional=edge["bidirectional"],
442
+ )
443
+ )
444
+ left = right
445
+
446
+
447
+ # ---------------------------------------------------------------------------
448
+ # stateDiagram
449
+ # ---------------------------------------------------------------------------
450
+
451
+ _STATE_EDGE_RE = re.compile(r"^(?P<a>\[\*\]|[\w.\-]+)\s*-->\s*(?P<b>\[\*\]|[\w.\-]+)\s*(?::\s*(?P<label>.*))?$")
452
+ _STATE_ALIAS_RE = re.compile(r"^state\s+\"(?P<label>[^\"]+)\"\s+as\s+(?P<id>[\w.\-]+)")
453
+ _STATE_DESC_RE = re.compile(r"^(?P<id>[\w.\-]+)\s*:\s*(?P<label>.+)$")
454
+ _STATE_OPEN_RE = re.compile(r"^state\s+(?:\"(?P<label>[^\"]+)\"\s+as\s+)?(?P<id>[\w.\-]+)\s*\{")
455
+
456
+
457
+ def _parse_state(lines: list[str]) -> Graph:
458
+ graph = Graph(type="state")
459
+ stack: list[str] = []
460
+ start_count = 0
461
+ for raw in lines:
462
+ line = raw.strip()
463
+ if (
464
+ not line
465
+ or re.match(r"^stateDiagram", line, re.I)
466
+ or line.startswith(("direction", "note", "classDef", "class "))
467
+ ):
468
+ continue
469
+ om = _STATE_OPEN_RE.match(line)
470
+ if om:
471
+ graph.node(om.group("id"), om.group("label"))
472
+ graph.groups[om.group("id")] = clean_label(om.group("label") or om.group("id"))
473
+ stack.append(om.group("id"))
474
+ continue
475
+ if line == "}":
476
+ if stack:
477
+ stack.pop()
478
+ continue
479
+ if line in ("--", "---"):
480
+ continue
481
+ am = _STATE_ALIAS_RE.match(line)
482
+ if am:
483
+ graph.node(am.group("id"), am.group("label"), stack[-1] if stack else None)
484
+ continue
485
+ em = _STATE_EDGE_RE.match(line)
486
+ if em:
487
+ ids = []
488
+ for key in ("a", "b"):
489
+ v = em.group(key)
490
+ if v == "[*]":
491
+ start_count += 1
492
+ v = f"[*]{start_count}"
493
+ graph.node(v, "start/end", stack[-1] if stack else None)
494
+ else:
495
+ graph.node(v, None, stack[-1] if stack else None)
496
+ ids.append(v)
497
+ graph.edges.append(Edge(ids[0], ids[1], clean_label(em.group("label") or "")))
498
+ continue
499
+ dm = _STATE_DESC_RE.match(line)
500
+ if dm and not line.startswith("state "):
501
+ n = graph.node(dm.group("id"), None, stack[-1] if stack else None)
502
+ n.label = clean_label(dm.group("label"))
503
+ continue
504
+ if re.match(r"^state\s+[\w.\-]+\s*(<<\w+>>)?$", line):
505
+ graph.node(line.split()[1])
506
+ continue
507
+ graph.errors.append(f"unreadable statement: {line[:60]!r}")
508
+ return graph
509
+
510
+
511
+ # ---------------------------------------------------------------------------
512
+ # sequenceDiagram
513
+ # ---------------------------------------------------------------------------
514
+
515
+ _SEQ_PARTICIPANT_RE = re.compile(
516
+ r"^(participant|actor)\s+(?P<id>[^\s]+(?:\s+[^\s]+)*?)(?:\s+as\s+(?P<alias>.+))?$", re.I
517
+ )
518
+ _SEQ_MSG_RE = re.compile(
519
+ r"^(?P<a>[^-\s][^-]*?)\s*(?P<arrow>-{1,2}(?:>>|>|x|\)|\))?)\s*(?P<b>[^:]+?)\s*(?::\s*(?P<label>.*))?$"
520
+ )
521
+
522
+
523
+ def _parse_sequence(lines: list[str]) -> Graph:
524
+ graph = Graph(type="sequence")
525
+ for raw in lines:
526
+ line = raw.strip()
527
+ if not line or re.match(r"^sequenceDiagram", line, re.I):
528
+ continue
529
+ if re.match(
530
+ r"^(activate|deactivate|loop|alt|else|opt|par|and|critical|option|break|rect|end|note|autonumber|box|title|links?)\b",
531
+ line,
532
+ re.I,
533
+ ):
534
+ continue
535
+ pm = _SEQ_PARTICIPANT_RE.match(line)
536
+ if pm:
537
+ graph.node(pm.group("id").strip(), (pm.group("alias") or pm.group("id")).strip())
538
+ continue
539
+ mm = _SEQ_MSG_RE.match(line)
540
+ if mm and "-" in mm.group("arrow"):
541
+ a, b = mm.group("a").strip(), mm.group("b").strip()
542
+ for pid in (a, b):
543
+ graph.node(pid)
544
+ graph.edges.append(Edge(a, b, clean_label(mm.group("label") or "")))
545
+ continue
546
+ graph.errors.append(f"unreadable statement: {line[:60]!r}")
547
+ return graph
548
+
549
+
550
+ # ---------------------------------------------------------------------------
551
+ # mindmap
552
+ # ---------------------------------------------------------------------------
553
+
554
+ _MIND_NODE_RE = re.compile(
555
+ r"^(?P<id>[\w\-.]+)?(?:\(\((?P<circle>.*)\)\)|\)(?P<bang>.*)\(|\((?P<round>.*)\)|\[(?P<square>.*)\]|\{\{(?P<hex>.*)\}\})?(?P<plain>.*)$"
556
+ )
557
+
558
+
559
+ def _parse_mindmap(lines: list[str]) -> Graph:
560
+ graph = Graph(type="mindmap")
561
+ stack: list[tuple[int, str]] = []
562
+ counter = 0
563
+ for raw in lines:
564
+ if not raw.strip() or re.match(r"^\s*mindmap\b", raw, re.I):
565
+ continue
566
+ stripped = raw.strip()
567
+ if stripped.startswith(("::icon", ":::")):
568
+ continue
569
+ indent = len(raw) - len(raw.lstrip())
570
+ m = _MIND_NODE_RE.match(stripped)
571
+ label = None
572
+ if m:
573
+ label = next(
574
+ (m.group(k) for k in ("circle", "bang", "round", "square", "hex") if m.group(k) is not None), None
575
+ )
576
+ if label is None:
577
+ label = (m.group("id") or "") + (m.group("plain") or "")
578
+ counter += 1
579
+ node_id = f"m{counter}"
580
+ graph.node(node_id, label or stripped)
581
+ while stack and stack[-1][0] >= indent:
582
+ stack.pop()
583
+ if stack:
584
+ graph.edges.append(Edge(stack[-1][1], node_id))
585
+ stack.append((indent, node_id))
586
+ return graph
587
+
588
+
589
+ # ---------------------------------------------------------------------------
590
+ # classDiagram / erDiagram (relations only)
591
+ # ---------------------------------------------------------------------------
592
+
593
+ _CLASS_REL_RE = re.compile(
594
+ r"^(?P<a>[\w.\-~]+)\s*(?P<rel>(?:<\|--|<\|\.\.|\*--|o--|-->|\.\.>|--\|>|\.\.\|>|--\*|--o|--|\.\.|<--|<\.\.))\s*(?P<b>[\w.\-~]+)\s*(?::\s*(?P<label>.*))?$"
595
+ )
596
+ _ER_REL_RE = re.compile(r"^(?P<a>[\w\-]+)\s+(?P<rel>[|}{o]+[-.]+[|}{o]+)\s+(?P<b>[\w\-]+)\s*:\s*(?P<label>.*)$")
597
+
598
+
599
+ def _parse_class(lines: list[str]) -> Graph:
600
+ graph = Graph(type="class")
601
+ depth = 0
602
+ for raw in lines:
603
+ line = raw.strip()
604
+ if not line or re.match(r"^classDiagram", line, re.I):
605
+ continue
606
+ if depth:
607
+ depth += line.count("{") - line.count("}")
608
+ continue
609
+ cm = re.match(r"^class\s+(?P<id>[\w.\-~]+)(?:\[\"(?P<label>[^\"]+)\"\])?\s*(\{)?", line)
610
+ if cm:
611
+ graph.node(cm.group("id"), cm.group("label"))
612
+ if cm.group(3):
613
+ depth = 1 + line.count("{") - 1 - line.count("}")
614
+ continue
615
+ rm = _CLASS_REL_RE.match(line)
616
+ if rm:
617
+ a, b, rel = rm.group("a"), rm.group("b"), rm.group("rel")
618
+ graph.node(a)
619
+ graph.node(b)
620
+ # ``A <|-- B`` reads "B inherits A": arrow head on the left points at the source concept.
621
+ if rel.startswith("<"):
622
+ graph.edges.append(Edge(b, a, clean_label(rm.group("label") or "")))
623
+ else:
624
+ graph.edges.append(
625
+ Edge(a, b, clean_label(rm.group("label") or ""), directed=rel.endswith((">", "*", "o")))
626
+ )
627
+ continue
628
+ if ":" in line and not line.startswith("note"):
629
+ continue # member line ``Class : +method()``
630
+ if not line.startswith(("note", "direction", "style", "classDef", "cssClass", "link", "click", "callback")):
631
+ graph.errors.append(f"unreadable statement: {line[:60]!r}")
632
+ return graph
633
+
634
+
635
+ def _parse_er(lines: list[str]) -> Graph:
636
+ graph = Graph(type="er")
637
+ depth = 0
638
+ for raw in lines:
639
+ line = raw.strip()
640
+ if not line or re.match(r"^erDiagram", line, re.I):
641
+ continue
642
+ if depth:
643
+ depth += line.count("{") - line.count("}")
644
+ continue
645
+ rm = _ER_REL_RE.match(line)
646
+ if rm:
647
+ graph.node(rm.group("a"))
648
+ graph.node(rm.group("b"))
649
+ graph.edges.append(Edge(rm.group("a"), rm.group("b"), clean_label(rm.group("label")), directed=False))
650
+ continue
651
+ em = re.match(r"^(?P<id>[\w\-]+)\s*(\{)?", line)
652
+ if em:
653
+ graph.node(em.group("id"))
654
+ if em.group(2):
655
+ depth = 1
656
+ continue
657
+ graph.errors.append(f"unreadable statement: {line[:60]!r}")
658
+ return graph
659
+
660
+
661
+ def graph_from_dict(data: dict[str, Any]) -> Graph:
662
+ """Build a ``Graph`` from the ground-truth JSON shape (``nodes``/``edges``/``groups``)."""
663
+ graph = Graph(type=str(data.get("type") or "flowchart"))
664
+ for n in data.get("nodes") or []:
665
+ graph.node(str(n["id"]), str(n.get("label") or n["id"]), n.get("group"))
666
+ for e in data.get("edges") or []:
667
+ graph.edges.append(
668
+ Edge(
669
+ source=str(e["from"]),
670
+ target=str(e["to"]),
671
+ label=clean_label(str(e.get("label") or "")),
672
+ directed=bool(e.get("directed", True)),
673
+ bidirectional=bool(e.get("bidirectional", False)),
674
+ )
675
+ )
676
+ for g in data.get("groups") or []:
677
+ if isinstance(g, dict):
678
+ graph.groups[str(g.get("id") or g.get("label"))] = str(g.get("label") or g.get("id"))
679
+ for member in g.get("members") or []:
680
+ if member in graph.nodes:
681
+ graph.nodes[member].group = str(g.get("id") or g.get("label"))
682
+ return graph