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,2066 @@
1
+ """HTML report generator for pipeline comparisons.
2
+
3
+ Generates a rich, interactive comparison report matching the dashboard's
4
+ warm editorial design system (Newsreader / Plus Jakarta Sans / JetBrains Mono).
5
+ """
6
+
7
+ import base64
8
+ import html
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from parse_bench.analysis.metric_definitions import (
14
+ TOOLTIP_CSS,
15
+ TOOLTIP_JS,
16
+ display_name_dict,
17
+ tooltip_dict,
18
+ )
19
+
20
+
21
+ def _get_file_data_url(file_path: Path) -> str | None:
22
+ """Convert a file to a data URL for embedding in HTML."""
23
+ if not file_path.exists():
24
+ return None
25
+
26
+ try:
27
+ # Determine MIME type
28
+ suffix = file_path.suffix.lower()
29
+ mime_types = {
30
+ ".pdf": "application/pdf",
31
+ ".png": "image/png",
32
+ ".jpg": "image/jpeg",
33
+ ".jpeg": "image/jpeg",
34
+ ".gif": "image/gif",
35
+ }
36
+ mime_type = mime_types.get(suffix, "application/octet-stream")
37
+
38
+ # Read file and encode
39
+ with open(file_path, "rb") as f:
40
+ file_data = f.read()
41
+ encoded = base64.b64encode(file_data).decode("utf-8")
42
+ return f"data:{mime_type};base64,{encoded}"
43
+ except Exception:
44
+ return None
45
+
46
+
47
+ METRIC_DISPLAY_NAMES: dict[str, str] = display_name_dict()
48
+
49
+
50
+ def _format_pct(value: float | None, decimals: int = 1) -> str:
51
+ if value is None:
52
+ return "N/A"
53
+ try:
54
+ return f"{float(value) * 100:.{decimals}f}%"
55
+ except (TypeError, ValueError):
56
+ return "N/A"
57
+
58
+
59
+ def _embed_input_files(
60
+ matched_results: list[dict[str, Any]],
61
+ test_cases_dir: Path | None,
62
+ original_base_path: str,
63
+ ) -> None:
64
+ """Embed image input files as base64 data URLs and compute relative paths (in-place)."""
65
+ import os
66
+
67
+ # Use original_base_path as the base for relative path computation
68
+ # (may differ from test_cases_dir which requires local existence)
69
+ base_for_rel = original_base_path or (str(test_cases_dir) if test_cases_dir else "")
70
+
71
+ for result in matched_results:
72
+ input_file = result.get("input_file")
73
+ if not input_file:
74
+ continue
75
+ fp = Path(input_file)
76
+ if not fp.exists() and test_cases_dir:
77
+ rel = fp.name
78
+ candidate = test_cases_dir / rel
79
+ if candidate.exists():
80
+ fp = candidate
81
+
82
+ # Compute relative path for PDF.js URL resolution
83
+ # Try file-system-based relpath first, fall back to string manipulation
84
+ if test_cases_dir and fp.exists():
85
+ try:
86
+ result["input_file_rel"] = os.path.relpath(str(fp.resolve()), str(test_cases_dir.resolve()))
87
+ except ValueError:
88
+ result["input_file_rel"] = str(fp)
89
+ elif base_for_rel and input_file.startswith(base_for_rel):
90
+ # String-based relative path (for CI paths that don't exist locally)
91
+ rel_path = input_file[len(base_for_rel) :]
92
+ if rel_path.startswith("/"):
93
+ rel_path = rel_path[1:]
94
+ result["input_file_rel"] = rel_path
95
+
96
+ # Embed images as base64 (not PDFs — they're too large, use PDF.js instead)
97
+ if fp.exists() and fp.suffix.lower() in (".png", ".jpg", ".jpeg", ".gif"):
98
+ data_url = _get_file_data_url(fp)
99
+ if data_url:
100
+ result["input_data_url"] = data_url
101
+
102
+
103
+ def generate_comparison_html(comparison_data: dict[str, Any], output_path: Path | None = None) -> str | Path:
104
+ """
105
+ Generate an interactive HTML report for pipeline comparison.
106
+
107
+ :param comparison_data: Comparison data from compare_pipelines()
108
+ :param output_path: Optional path to save the HTML report. If None, returns HTML string.
109
+ :return: HTML string if output_path is None, otherwise Path to the generated file
110
+ """
111
+ matched_results = comparison_data["matched_results"]
112
+ stats = comparison_data["stats"]
113
+ product_type = comparison_data.get("product_type", "extract")
114
+ comparison_metric = comparison_data.get("comparison_metric", "accuracy")
115
+ original_base_path = comparison_data.get("original_base_path", "")
116
+ pdf_base_url = comparison_data.get("pdf_base_url", "")
117
+
118
+ metric_display_name = METRIC_DISPLAY_NAMES.get(comparison_metric, comparison_metric)
119
+
120
+ # Embed input images as data URLs and compute relative paths
121
+ test_cases_dir_str = comparison_data.get("original_base_path", "")
122
+ test_cases_dir = Path(test_cases_dir_str) if test_cases_dir_str else None
123
+ _embed_input_files(matched_results, test_cases_dir, original_base_path)
124
+
125
+ pipeline_a_name = html.escape(stats["pipeline_a_name"])
126
+ pipeline_b_name = html.escape(stats["pipeline_b_name"])
127
+
128
+ # Sort matched results by test_id
129
+ matched_results_sorted = sorted(matched_results, key=lambda r: r["test_id"])
130
+
131
+ html_content = _build_html(
132
+ matched_results_sorted,
133
+ stats,
134
+ product_type,
135
+ comparison_metric,
136
+ metric_display_name,
137
+ pipeline_a_name,
138
+ pipeline_b_name,
139
+ original_base_path,
140
+ pdf_base_url,
141
+ )
142
+
143
+ if output_path is None:
144
+ return html_content
145
+
146
+ output_path.parent.mkdir(parents=True, exist_ok=True)
147
+ with open(output_path, "w") as f:
148
+ f.write(html_content)
149
+ return output_path
150
+
151
+
152
+ def _build_html(
153
+ matched_results: list[dict[str, Any]],
154
+ stats: dict[str, Any],
155
+ product_type: str,
156
+ comparison_metric: str,
157
+ metric_display_name: str,
158
+ pipeline_a_name: str,
159
+ pipeline_b_name: str,
160
+ original_base_path: str,
161
+ pdf_base_url: str = "",
162
+ ) -> str:
163
+ """Build the full HTML document."""
164
+
165
+ # Pre-compute result rows HTML
166
+ rows_html = []
167
+ for i, result in enumerate(matched_results):
168
+ rows_html.append(_build_result_row(result, stats, i))
169
+
170
+ title = f"Pipeline Comparison: {pipeline_a_name} vs {pipeline_b_name}"
171
+
172
+ return f"""<!DOCTYPE html>
173
+ <html lang="en">
174
+ <head>
175
+ <meta charset="UTF-8">
176
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
177
+ <title>{title}</title>
178
+ <link rel="preconnect" href="https://fonts.googleapis.com">
179
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
180
+ <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
181
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
182
+ {_css()}
183
+ </head>
184
+ <body>
185
+ <header class="page-header">
186
+ <div class="header-inner">
187
+ <h1>Pipeline Comparison</h1>
188
+ <p class="subtitle">{pipeline_a_name} <span class="vs">vs</span> {pipeline_b_name}</p>
189
+ <div class="metric-selector">
190
+ <label class="metric-selector-label" for="metricSelect">Primary metric:</label>
191
+ <select id="metricSelect" onchange="switchMetric(this.value)"></select>
192
+ </div>
193
+ </div>
194
+ </header>
195
+
196
+ <main class="container">
197
+ {_build_stats_section(stats, pipeline_a_name, pipeline_b_name)}
198
+
199
+ <div class="path-config">
200
+ <div class="path-config-header">
201
+ <span class="path-config-title">Data Path Configuration</span>
202
+ <button class="path-config-toggle" onclick="togglePathConfig()">Configure</button>
203
+ </div>
204
+ <div class="path-config-body" id="pathConfigBody">
205
+ <p class="hint">If you received this report from someone else, set your local path to the test data folder:</p>
206
+ <input type="text" id="dataBasePath" placeholder="e.g., /Users/yourname/data/financial_tables" onchange="updateBasePath()" />
207
+ <p class="current-path">Current: <span id="currentBasePath">(using original paths)</span></p>
208
+ </div>
209
+ </div>
210
+
211
+ {_build_filter_bar(stats, pipeline_a_name, pipeline_b_name)}
212
+
213
+ <div class="results-list" id="resultsList">
214
+ <div class="results-table-header">
215
+ <div class="col-id">Test ID</div>
216
+ <div class="col-metric">{pipeline_a_name}</div>
217
+ <div class="col-metric">{pipeline_b_name}</div>
218
+ <div class="col-delta">Delta</div>
219
+ <div class="col-category">Category</div>
220
+ </div>
221
+ {"".join(rows_html)}
222
+ </div>
223
+ </main>
224
+
225
+ <script>
226
+ const comparisonData = {json.dumps(matched_results)};
227
+ const pipelineAName = {json.dumps(stats["pipeline_a_name"])};
228
+ const pipelineBName = {json.dumps(stats["pipeline_b_name"])};
229
+ const productType = {json.dumps(product_type)};
230
+ let comparisonMetric = {json.dumps(comparison_metric)};
231
+ let metricDisplayName = {json.dumps(METRIC_DISPLAY_NAMES.get(comparison_metric, comparison_metric))};
232
+ const originalBasePath = {json.dumps(original_base_path)};
233
+ const pdfBaseUrl = {json.dumps(pdf_base_url)};
234
+ const metricTooltips = {json.dumps(tooltip_dict())};
235
+ </script>
236
+ {_javascript()}
237
+ </body>
238
+ </html>"""
239
+
240
+
241
+ def _build_result_row(result: dict[str, Any], stats: dict[str, Any], index: int) -> str:
242
+ """Build a single result row with expandable detail area."""
243
+ test_id = html.escape(result["test_id"])
244
+ metric_a = result["pipeline_a"].get("metric_value")
245
+ metric_b = result["pipeline_b"].get("metric_value")
246
+ category = result["category"]
247
+
248
+ def fmt(val: float | None) -> str:
249
+ if val is None:
250
+ return '<span class="na">N/A</span>'
251
+ pct = val * 100
252
+ css = _metric_color_class(val)
253
+ return f'<span class="metric-val {css}">{pct:.1f}%</span>'
254
+
255
+ def delta_str(a: float | None, b: float | None) -> str:
256
+ if a is None or b is None:
257
+ return '<span class="na">&mdash;</span>'
258
+ d = (a - b) * 100
259
+ sign = "+" if d > 0 else ""
260
+ css = "delta-pos" if d > 0 else ("delta-neg" if d < 0 else "delta-zero")
261
+ return f'<span class="{css}">{sign}{d:.1f}pp</span>'
262
+
263
+ category_labels = {
264
+ "a_better": f"{stats['pipeline_a_name']} Better",
265
+ "b_better": f"{stats['pipeline_b_name']} Better",
266
+ "both_bad": "Both Bad",
267
+ "tie": "Tie",
268
+ }
269
+ cat_label = html.escape(category_labels.get(category, category))
270
+
271
+ return f"""
272
+ <div class="result-row" data-category="{category}" data-index="{index}">
273
+ <div class="row-summary" onclick="toggleRow({index})">
274
+ <div class="col-id"><span class="expand-icon" id="icon-{index}">&#9654;</span> {test_id}</div>
275
+ <div class="col-metric">{fmt(metric_a)}</div>
276
+ <div class="col-metric">{fmt(metric_b)}</div>
277
+ <div class="col-delta">{delta_str(metric_a, metric_b)}</div>
278
+ <div class="col-category"><span class="badge badge-{category.replace("_", "-")}">{cat_label}</span></div>
279
+ </div>
280
+ <div class="row-detail" id="detail-{index}"></div>
281
+ </div>"""
282
+
283
+
284
+ def _build_stats_section(stats: dict[str, Any], a_name: str, b_name: str) -> str:
285
+ return f"""
286
+ <section class="stats-grid">
287
+ <div class="stat-card" data-filter="all" onclick="filterFromCard(this)">
288
+ <div class="stat-value">{stats["total_matched"]}</div>
289
+ <div class="stat-label">Total</div>
290
+ </div>
291
+ <div class="stat-card stat-a-better" data-filter="a_better" onclick="filterFromCard(this)">
292
+ <div class="stat-value">{stats["a_better"]}</div>
293
+ <div class="stat-label">{a_name} Better</div>
294
+ </div>
295
+ <div class="stat-card stat-b-better" data-filter="b_better" onclick="filterFromCard(this)">
296
+ <div class="stat-value">{stats["b_better"]}</div>
297
+ <div class="stat-label">{b_name} Better</div>
298
+ </div>
299
+ <div class="stat-card stat-tie" data-filter="tie" onclick="filterFromCard(this)">
300
+ <div class="stat-value">{stats["tie"]}</div>
301
+ <div class="stat-label">Tie</div>
302
+ </div>
303
+ <div class="stat-card stat-bad" data-filter="both_bad" onclick="filterFromCard(this)">
304
+ <div class="stat-value">{stats["both_bad"]}</div>
305
+ <div class="stat-label">Both Bad</div>
306
+ </div>
307
+ </section>"""
308
+
309
+
310
+ def _build_filter_bar(stats: dict[str, Any], a_name: str, b_name: str) -> str:
311
+ return f"""
312
+ <div class="filter-bar">
313
+ <button class="filter-btn active" data-filter="all">All ({stats["total_matched"]})</button>
314
+ <button class="filter-btn" data-filter="a_better">{a_name} Better ({stats["a_better"]})</button>
315
+ <button class="filter-btn" data-filter="b_better">{b_name} Better ({stats["b_better"]})</button>
316
+ <button class="filter-btn" data-filter="tie">Tie ({stats["tie"]})</button>
317
+ <button class="filter-btn" data-filter="both_bad">Both Bad ({stats["both_bad"]})</button>
318
+ </div>"""
319
+
320
+
321
+ def _metric_color_class(val: float | None) -> str:
322
+ if val is None:
323
+ return "metric-na"
324
+ if val >= 0.9:
325
+ return "metric-high"
326
+ if val >= 0.7:
327
+ return "metric-mid"
328
+ if val >= 0.5:
329
+ return "metric-low"
330
+ return "metric-bad"
331
+
332
+
333
+ # ---------------------------------------------------------------------------
334
+ # CSS
335
+ # ---------------------------------------------------------------------------
336
+
337
+
338
+ def _css() -> str:
339
+ return (
340
+ """<style>
341
+ :root {
342
+ --bg: #f8f7f4;
343
+ --fg: #1c1917;
344
+ --card: #ffffff;
345
+ --border: #e7e5e4;
346
+ --border-light: #f0efed;
347
+ --muted: #78716c;
348
+ --muted-light: #a8a29e;
349
+ --cream: #faf9f6;
350
+
351
+ --emerald: #059669;
352
+ --amber: #d97706;
353
+ --yellow: #ca8a04;
354
+ --red: #dc2626;
355
+
356
+ --font-heading: 'Newsreader', Georgia, serif;
357
+ --font-body: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
358
+ --font-mono: 'JetBrains Mono', 'SF Mono', monospace;
359
+ }
360
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
361
+ body {
362
+ font-family: var(--font-body);
363
+ background: var(--bg);
364
+ color: var(--fg);
365
+ line-height: 1.6;
366
+ -webkit-font-smoothing: antialiased;
367
+ }
368
+
369
+ /* Header */
370
+ .page-header {
371
+ background: var(--cream);
372
+ border-bottom: 1px solid var(--border);
373
+ padding: 2.5rem 2rem 2rem;
374
+ }
375
+ .header-inner {
376
+ max-width: 1400px;
377
+ margin: 0 auto;
378
+ }
379
+ .page-header h1 {
380
+ font-family: var(--font-heading);
381
+ font-size: 2.25rem;
382
+ font-weight: 600;
383
+ letter-spacing: -0.02em;
384
+ color: var(--fg);
385
+ }
386
+ .page-header .subtitle {
387
+ font-size: 1.1rem;
388
+ color: var(--muted);
389
+ margin-top: 0.25rem;
390
+ }
391
+ .page-header .vs {
392
+ color: var(--muted-light);
393
+ font-style: italic;
394
+ font-family: var(--font-heading);
395
+ }
396
+ .page-header .metric-label {
397
+ font-size: 0.85rem;
398
+ color: var(--muted-light);
399
+ margin-top: 0.35rem;
400
+ font-family: var(--font-mono);
401
+ }
402
+ .metric-selector {
403
+ display: flex;
404
+ align-items: center;
405
+ gap: 0.5rem;
406
+ margin-top: 0.35rem;
407
+ }
408
+ .metric-selector-label {
409
+ font-size: 0.85rem;
410
+ color: var(--muted-light);
411
+ font-family: var(--font-mono);
412
+ }
413
+ .metric-selector select {
414
+ font-family: var(--font-mono);
415
+ font-size: 0.85rem;
416
+ padding: 0.25rem 0.5rem;
417
+ border: 1px solid var(--border);
418
+ border-radius: 6px;
419
+ background: var(--card);
420
+ color: var(--fg);
421
+ cursor: pointer;
422
+ }
423
+ .metric-selector select:hover {
424
+ border-color: var(--muted);
425
+ }
426
+
427
+ /* Container */
428
+ .container {
429
+ max-width: 1400px;
430
+ margin: 0 auto;
431
+ padding: 1.5rem 2rem 3rem;
432
+ }
433
+
434
+ /* Stats */
435
+ .stats-grid {
436
+ display: grid;
437
+ grid-template-columns: repeat(5, 1fr);
438
+ gap: 1rem;
439
+ margin-bottom: 1.5rem;
440
+ }
441
+ .stat-card {
442
+ background: var(--card);
443
+ border: 1px solid var(--border);
444
+ border-radius: 10px;
445
+ padding: 1.25rem 1rem;
446
+ text-align: center;
447
+ }
448
+ .stat-value {
449
+ font-family: var(--font-mono);
450
+ font-size: 1.75rem;
451
+ font-weight: 600;
452
+ color: var(--fg);
453
+ }
454
+ .stat-label {
455
+ font-size: 0.8rem;
456
+ color: var(--muted);
457
+ margin-top: 0.25rem;
458
+ }
459
+ .stat-card { cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s; }
460
+ .stat-card:hover { border-color: var(--muted-light); }
461
+ .stat-card.active { border-color: var(--fg); box-shadow: 0 0 0 1px var(--fg); }
462
+ .stat-a-better .stat-value { color: var(--emerald); }
463
+ .stat-b-better .stat-value { color: #2563eb; }
464
+ .stat-tie .stat-value { color: var(--muted); }
465
+ .stat-bad .stat-value { color: var(--red); }
466
+
467
+ /* Path config */
468
+ .path-config {
469
+ background: var(--cream);
470
+ border: 1px solid var(--border);
471
+ border-radius: 10px;
472
+ padding: 0.75rem 1rem;
473
+ margin-bottom: 1.5rem;
474
+ font-size: 0.85rem;
475
+ }
476
+ .path-config-header {
477
+ display: flex;
478
+ justify-content: space-between;
479
+ align-items: center;
480
+ }
481
+ .path-config-title { color: var(--muted); font-weight: 500; }
482
+ .path-config-toggle {
483
+ background: none;
484
+ border: none;
485
+ color: var(--muted);
486
+ cursor: pointer;
487
+ font-size: 0.8rem;
488
+ text-decoration: underline;
489
+ font-family: var(--font-body);
490
+ }
491
+ .path-config-body { display: none; margin-top: 0.75rem; }
492
+ .path-config-body.expanded { display: block; }
493
+ .path-config input {
494
+ width: 100%;
495
+ padding: 0.5rem;
496
+ border: 1px solid var(--border);
497
+ border-radius: 6px;
498
+ font-family: var(--font-mono);
499
+ font-size: 0.8rem;
500
+ margin-top: 0.5rem;
501
+ background: var(--card);
502
+ }
503
+ .path-config .hint { color: var(--muted); font-size: 0.8rem; margin-top: 0.5rem; }
504
+ .path-config .current-path { color: var(--muted-light); font-size: 0.75rem; font-family: var(--font-mono); margin-top: 0.25rem; }
505
+
506
+ /* Filters */
507
+ .filter-bar {
508
+ display: flex;
509
+ gap: 0.5rem;
510
+ margin-bottom: 1.25rem;
511
+ flex-wrap: wrap;
512
+ }
513
+ .filter-btn {
514
+ padding: 0.5rem 1rem;
515
+ border: 1px solid var(--border);
516
+ border-radius: 8px;
517
+ cursor: pointer;
518
+ font-size: 0.85rem;
519
+ font-weight: 500;
520
+ font-family: var(--font-body);
521
+ background: var(--card);
522
+ color: var(--muted);
523
+ transition: all 0.15s;
524
+ }
525
+ .filter-btn:hover {
526
+ border-color: var(--muted);
527
+ color: var(--fg);
528
+ }
529
+ .filter-btn.active {
530
+ background: var(--fg);
531
+ color: var(--card);
532
+ border-color: var(--fg);
533
+ }
534
+
535
+ /* Results table */
536
+ .results-list {
537
+ background: var(--card);
538
+ border: 1px solid var(--border);
539
+ border-radius: 10px;
540
+ overflow: hidden;
541
+ }
542
+ .results-table-header {
543
+ display: grid;
544
+ grid-template-columns: 2fr 0.8fr 0.8fr 0.8fr 1.2fr;
545
+ gap: 0.5rem;
546
+ padding: 0.75rem 1rem;
547
+ background: var(--cream);
548
+ border-bottom: 1px solid var(--border);
549
+ font-size: 0.75rem;
550
+ font-weight: 600;
551
+ color: var(--muted);
552
+ text-transform: uppercase;
553
+ letter-spacing: 0.04em;
554
+ }
555
+ .col-metric, .col-delta, .col-category { text-align: center; }
556
+
557
+ .result-row {
558
+ border-bottom: 1px solid var(--border-light);
559
+ }
560
+ .result-row:last-child { border-bottom: none; }
561
+ .row-summary {
562
+ display: grid;
563
+ grid-template-columns: 2fr 0.8fr 0.8fr 0.8fr 1.2fr;
564
+ gap: 0.5rem;
565
+ padding: 0.75rem 1rem;
566
+ cursor: pointer;
567
+ transition: background 0.1s;
568
+ align-items: center;
569
+ }
570
+ .row-summary:hover { background: var(--cream); }
571
+ .col-id {
572
+ font-weight: 500;
573
+ font-size: 0.9rem;
574
+ display: flex;
575
+ align-items: center;
576
+ gap: 0.5rem;
577
+ overflow: hidden;
578
+ text-overflow: ellipsis;
579
+ }
580
+ .expand-icon {
581
+ font-size: 0.6rem;
582
+ color: var(--muted-light);
583
+ transition: transform 0.15s;
584
+ display: inline-block;
585
+ flex-shrink: 0;
586
+ }
587
+ .expand-icon.expanded { transform: rotate(90deg); }
588
+
589
+ /* Metric values */
590
+ .metric-val {
591
+ font-family: var(--font-mono);
592
+ font-size: 0.85rem;
593
+ font-weight: 500;
594
+ }
595
+ .metric-high { color: var(--emerald); }
596
+ .metric-mid { color: var(--amber); }
597
+ .metric-low { color: var(--yellow); }
598
+ .metric-bad { color: var(--red); }
599
+ .metric-na { color: var(--muted-light); }
600
+ .na { color: var(--muted-light); font-size: 0.8rem; }
601
+
602
+ .delta-pos { color: var(--emerald); font-family: var(--font-mono); font-size: 0.8rem; font-weight: 500; }
603
+ .delta-neg { color: var(--red); font-family: var(--font-mono); font-size: 0.8rem; font-weight: 500; }
604
+ .delta-zero { color: var(--muted); font-family: var(--font-mono); font-size: 0.8rem; }
605
+
606
+ /* Badge */
607
+ .badge {
608
+ display: inline-block;
609
+ padding: 0.2rem 0.6rem;
610
+ border-radius: 6px;
611
+ font-size: 0.75rem;
612
+ font-weight: 500;
613
+ }
614
+ .badge-a-better { background: #ecfdf5; color: var(--emerald); }
615
+ .badge-b-better { background: #eff6ff; color: #2563eb; }
616
+ .badge-tie { background: #f5f5f4; color: var(--muted); }
617
+ .badge-both-bad { background: #fef2f2; color: var(--red); }
618
+
619
+ /* Detail pane */
620
+ .row-detail {
621
+ display: none;
622
+ padding: 0 1rem 1rem;
623
+ background: var(--cream);
624
+ border-top: 1px solid var(--border-light);
625
+ }
626
+ .row-detail.open { display: block; }
627
+
628
+ /* Two-column detail layout */
629
+ .detail-two-col {
630
+ display: grid;
631
+ grid-template-columns: 35% 1fr;
632
+ gap: 1rem;
633
+ margin-top: 0.75rem;
634
+ }
635
+ .detail-left {
636
+ position: sticky;
637
+ top: 0;
638
+ align-self: start;
639
+ }
640
+ .detail-right {
641
+ min-width: 0;
642
+ }
643
+
644
+ /* Metric pills */
645
+ .metric-pills {
646
+ display: flex;
647
+ flex-wrap: wrap;
648
+ gap: 0.4rem;
649
+ margin-top: 0.75rem;
650
+ }
651
+ .pill {
652
+ display: flex;
653
+ flex-direction: column;
654
+ padding: 0.35rem 0.6rem;
655
+ border: 1px solid var(--border);
656
+ border-radius: 8px;
657
+ background: var(--card);
658
+ font-size: 0.75rem;
659
+ min-width: 0;
660
+ }
661
+ .pill-primary {
662
+ border-color: var(--fg);
663
+ background: var(--fg);
664
+ }
665
+ .pill-primary .pill-label {
666
+ color: var(--muted-light);
667
+ }
668
+ .pill-primary .pill-values {
669
+ color: #fff;
670
+ }
671
+ .pill-label {
672
+ font-size: 0.65rem;
673
+ color: var(--muted);
674
+ font-weight: 500;
675
+ text-transform: uppercase;
676
+ letter-spacing: 0.03em;
677
+ margin-bottom: 1px;
678
+ white-space: nowrap;
679
+ overflow: hidden;
680
+ text-overflow: ellipsis;
681
+ }
682
+ .pill-values {
683
+ font-family: var(--font-mono);
684
+ font-size: 0.75rem;
685
+ font-weight: 500;
686
+ white-space: nowrap;
687
+ }
688
+ .pill-delta {
689
+ font-size: 0.7rem;
690
+ margin-left: 4px;
691
+ }
692
+
693
+ /* Collapsible sections */
694
+ .collapsible-section {
695
+ border: 1px solid var(--border);
696
+ border-radius: 8px;
697
+ margin-top: 0.75rem;
698
+ background: var(--card);
699
+ overflow: hidden;
700
+ }
701
+ .collapsible-section > summary {
702
+ padding: 0.6rem 0.75rem;
703
+ cursor: pointer;
704
+ font-size: 0.85rem;
705
+ font-weight: 600;
706
+ color: var(--muted);
707
+ list-style: none;
708
+ display: flex;
709
+ align-items: center;
710
+ gap: 0.5rem;
711
+ user-select: none;
712
+ }
713
+ .collapsible-section > summary::-webkit-details-marker { display: none; }
714
+ .collapsible-section > summary::before {
715
+ content: '\\25B6';
716
+ font-size: 0.6rem;
717
+ transition: transform 0.15s;
718
+ color: var(--muted-light);
719
+ }
720
+ .collapsible-section[open] > summary::before {
721
+ transform: rotate(90deg);
722
+ }
723
+ .collapsible-section > summary:hover {
724
+ color: var(--fg);
725
+ }
726
+ .collapsible-section > :not(summary) {
727
+ padding: 0 0.75rem 0.75rem;
728
+ }
729
+
730
+ @media (max-width: 1100px) {
731
+ .detail-two-col {
732
+ grid-template-columns: 1fr;
733
+ }
734
+ .detail-left {
735
+ position: static;
736
+ }
737
+ }
738
+
739
+ /* Tabs */
740
+ .tab-bar {
741
+ display: flex;
742
+ gap: 0;
743
+ border-bottom: 1px solid var(--border);
744
+ margin-bottom: 1rem;
745
+ margin-top: 0.75rem;
746
+ }
747
+ .tab-btn {
748
+ padding: 0.6rem 1.25rem;
749
+ border: none;
750
+ background: none;
751
+ cursor: pointer;
752
+ font-family: var(--font-body);
753
+ font-size: 0.85rem;
754
+ font-weight: 500;
755
+ color: var(--muted);
756
+ border-bottom: 2px solid transparent;
757
+ transition: all 0.15s;
758
+ }
759
+ .tab-btn:hover { color: var(--fg); }
760
+ .tab-btn.active {
761
+ color: var(--fg);
762
+ border-bottom-color: var(--fg);
763
+ }
764
+ .tab-panel { display: none; }
765
+ .tab-panel.active { display: block; }
766
+
767
+ /* Metrics comparison table */
768
+ .metrics-table {
769
+ width: 100%;
770
+ border-collapse: collapse;
771
+ font-size: 0.85rem;
772
+ margin-bottom: 1rem;
773
+ }
774
+ .metrics-table th {
775
+ background: var(--cream);
776
+ padding: 0.6rem 0.75rem;
777
+ text-align: left;
778
+ font-weight: 600;
779
+ font-size: 0.75rem;
780
+ color: var(--muted);
781
+ text-transform: uppercase;
782
+ letter-spacing: 0.04em;
783
+ border-bottom: 1px solid var(--border);
784
+ }
785
+ .metrics-table td {
786
+ padding: 0.6rem 0.75rem;
787
+ border-bottom: 1px solid var(--border-light);
788
+ }
789
+ .metrics-table tr:last-child td { border-bottom: none; }
790
+ .metrics-table .metric-name { font-weight: 500; display: flex; align-items: center; overflow: visible; }
791
+ .metrics-table .val-cell {
792
+ font-family: var(--font-mono);
793
+ font-size: 0.85rem;
794
+ text-align: center;
795
+ }
796
+
797
+ /* Rule diff table */
798
+ .rule-table {
799
+ width: 100%;
800
+ border-collapse: collapse;
801
+ font-size: 0.8rem;
802
+ margin-bottom: 1rem;
803
+ }
804
+ .rule-table th {
805
+ background: var(--cream);
806
+ padding: 0.5rem 0.6rem;
807
+ text-align: left;
808
+ font-weight: 600;
809
+ font-size: 0.7rem;
810
+ color: var(--muted);
811
+ text-transform: uppercase;
812
+ letter-spacing: 0.04em;
813
+ border-bottom: 1px solid var(--border);
814
+ }
815
+ .rule-table td {
816
+ padding: 0.5rem 0.6rem;
817
+ border-bottom: 1px solid var(--border-light);
818
+ vertical-align: top;
819
+ }
820
+ .rule-table tr:last-child td { border-bottom: none; }
821
+ .rule-pass { color: var(--emerald); font-weight: 600; }
822
+ .rule-fail { color: var(--red); font-weight: 600; }
823
+ .rule-diff-row { background: #fffbeb; }
824
+ .rule-same-pass { background: #f0fdf4; }
825
+ .rule-same-fail { background: #fef2f2; }
826
+ .rule-explanation {
827
+ font-size: 0.75rem;
828
+ color: var(--muted);
829
+ margin-top: 0.25rem;
830
+ cursor: pointer;
831
+ max-height: 0;
832
+ overflow: hidden;
833
+ transition: max-height 0.2s;
834
+ }
835
+ .rule-explanation.open { max-height: 200px; overflow-y: auto; }
836
+ .rule-expander {
837
+ font-size: 0.7rem;
838
+ color: var(--muted-light);
839
+ cursor: pointer;
840
+ user-select: none;
841
+ }
842
+
843
+ /* Output comparison */
844
+ .output-grid {
845
+ display: grid;
846
+ grid-template-columns: 1fr 1fr;
847
+ gap: 1rem;
848
+ }
849
+ .output-panel {
850
+ border: 1px solid var(--border);
851
+ border-radius: 8px;
852
+ overflow: hidden;
853
+ background: var(--card);
854
+ }
855
+ .output-panel-header {
856
+ padding: 0.5rem 0.75rem;
857
+ background: var(--cream);
858
+ border-bottom: 1px solid var(--border);
859
+ font-size: 0.8rem;
860
+ font-weight: 600;
861
+ color: var(--muted);
862
+ display: flex;
863
+ justify-content: space-between;
864
+ align-items: center;
865
+ }
866
+ .output-panel-body {
867
+ padding: 0.75rem;
868
+ max-height: 500px;
869
+ overflow-y: auto;
870
+ font-size: 0.85rem;
871
+ }
872
+ .output-panel-body.json-view {
873
+ font-family: var(--font-mono);
874
+ font-size: 0.8rem;
875
+ white-space: pre-wrap;
876
+ word-break: break-word;
877
+ }
878
+ .output-panel-body.md-view h1 { font-size: 1.3rem; margin: 0.75rem 0 0.4rem; }
879
+ .output-panel-body.md-view h2 { font-size: 1.1rem; margin: 0.6rem 0 0.3rem; }
880
+ .output-panel-body.md-view h3 { font-size: 1rem; margin: 0.5rem 0 0.25rem; }
881
+ .output-panel-body.md-view p { margin-bottom: 0.4rem; }
882
+ .output-panel-body.md-view table {
883
+ border-collapse: collapse;
884
+ width: 100%;
885
+ font-size: 0.8rem;
886
+ margin: 0.5rem 0;
887
+ }
888
+ .output-panel-body.md-view th, .output-panel-body.md-view td {
889
+ border: 1px solid var(--border);
890
+ padding: 4px 8px;
891
+ text-align: left;
892
+ }
893
+ .output-panel-body.md-view th { background: var(--cream); font-weight: 600; }
894
+ .output-panel-body.md-view code {
895
+ background: #f5f5f4;
896
+ padding: 1px 4px;
897
+ border-radius: 3px;
898
+ font-family: var(--font-mono);
899
+ font-size: 0.85em;
900
+ }
901
+ .output-panel-body.md-view pre {
902
+ background: #f5f5f4;
903
+ padding: 0.75rem;
904
+ border-radius: 6px;
905
+ overflow-x: auto;
906
+ }
907
+ .output-panel-body.md-view pre code { background: none; padding: 0; }
908
+
909
+ .view-toggle {
910
+ display: flex;
911
+ gap: 2px;
912
+ }
913
+ .view-toggle button {
914
+ padding: 2px 8px;
915
+ border: 1px solid var(--border);
916
+ background: var(--card);
917
+ cursor: pointer;
918
+ font-family: var(--font-body);
919
+ font-size: 0.7rem;
920
+ color: var(--muted);
921
+ }
922
+ .view-toggle button:first-child { border-radius: 4px 0 0 4px; }
923
+ .view-toggle button:last-child { border-radius: 0 4px 4px 0; }
924
+ .view-toggle button.active { background: var(--fg); color: var(--card); border-color: var(--fg); }
925
+
926
+ /* Diff view */
927
+ .diff-container {
928
+ border: 1px solid var(--border);
929
+ border-radius: 8px;
930
+ overflow: hidden;
931
+ background: var(--card);
932
+ margin-top: 1rem;
933
+ }
934
+ .diff-header {
935
+ padding: 0.5rem 0.75rem;
936
+ background: var(--cream);
937
+ border-bottom: 1px solid var(--border);
938
+ font-size: 0.8rem;
939
+ font-weight: 600;
940
+ color: var(--muted);
941
+ }
942
+ .diff-body {
943
+ max-height: 500px;
944
+ overflow-y: auto;
945
+ font-family: var(--font-mono);
946
+ font-size: 0.8rem;
947
+ line-height: 1.5;
948
+ }
949
+ .diff-line { padding: 1px 12px; white-space: pre-wrap; word-break: break-all; }
950
+ .diff-add { background: #ecfdf5; color: #065f46; }
951
+ .diff-del { background: #fef2f2; color: #991b1b; }
952
+ .diff-ctx { color: var(--muted); }
953
+ .diff-hunk { background: #f5f3ff; color: #6d28d9; font-weight: 500; padding: 4px 12px; }
954
+
955
+ /* Input preview */
956
+ .input-preview {
957
+ margin-top: 0.5rem;
958
+ }
959
+ .input-preview img {
960
+ max-width: 100%;
961
+ max-height: 600px;
962
+ border: 1px solid var(--border);
963
+ border-radius: 8px;
964
+ }
965
+ .input-preview .file-path {
966
+ font-family: var(--font-mono);
967
+ font-size: 0.75rem;
968
+ color: var(--muted);
969
+ margin-bottom: 0.5rem;
970
+ word-break: break-all;
971
+ }
972
+ .input-preview iframe {
973
+ width: 100%;
974
+ height: 600px;
975
+ border: 1px solid var(--border);
976
+ border-radius: 8px;
977
+ }
978
+
979
+ /* Layout detection styles */
980
+ .layoutdet-grid {
981
+ display: grid;
982
+ grid-template-columns: 1fr 1fr 1fr;
983
+ gap: 1rem;
984
+ }
985
+ .layoutdet-panel {
986
+ border: 1px solid var(--border);
987
+ border-radius: 8px;
988
+ overflow: hidden;
989
+ background: var(--card);
990
+ }
991
+ .layoutdet-panel-header {
992
+ padding: 0.5rem 0.75rem;
993
+ background: var(--cream);
994
+ border-bottom: 1px solid var(--border);
995
+ font-size: 0.8rem;
996
+ font-weight: 600;
997
+ color: var(--muted);
998
+ display: flex;
999
+ justify-content: space-between;
1000
+ align-items: center;
1001
+ }
1002
+ .layoutdet-panel-body {
1003
+ position: relative;
1004
+ display: inline-block;
1005
+ width: 100%;
1006
+ }
1007
+ .layoutdet-panel-body img {
1008
+ max-width: 100%;
1009
+ height: auto;
1010
+ display: block;
1011
+ }
1012
+ .layoutdet-canvas {
1013
+ position: absolute;
1014
+ top: 0;
1015
+ left: 0;
1016
+ pointer-events: none;
1017
+ }
1018
+ .bbox-btn {
1019
+ padding: 2px 8px;
1020
+ border: 1px solid var(--border);
1021
+ border-radius: 4px;
1022
+ background: var(--card);
1023
+ cursor: pointer;
1024
+ font-family: var(--font-body);
1025
+ font-size: 0.7rem;
1026
+ color: var(--muted);
1027
+ }
1028
+ .bbox-btn.active {
1029
+ background: var(--fg);
1030
+ color: var(--card);
1031
+ border-color: var(--fg);
1032
+ }
1033
+ .comparison-legend {
1034
+ display: flex;
1035
+ gap: 16px;
1036
+ margin-top: 0.75rem;
1037
+ padding: 0.5rem 0.75rem;
1038
+ background: var(--cream);
1039
+ border: 1px solid var(--border);
1040
+ border-radius: 8px;
1041
+ font-size: 0.75rem;
1042
+ color: var(--muted);
1043
+ flex-wrap: wrap;
1044
+ }
1045
+ .legend-item { display: flex; align-items: center; gap: 4px; }
1046
+ .legend-swatch {
1047
+ width: 16px;
1048
+ height: 10px;
1049
+ border-radius: 2px;
1050
+ }
1051
+
1052
+ /* Responsive */
1053
+ @media (max-width: 900px) {
1054
+ .stats-grid { grid-template-columns: repeat(3, 1fr); }
1055
+ .output-grid { grid-template-columns: 1fr; }
1056
+ .layoutdet-grid { grid-template-columns: 1fr; }
1057
+ .results-table-header, .row-summary {
1058
+ grid-template-columns: 2fr 0.6fr 0.6fr 0.6fr 1fr;
1059
+ font-size: 0.8rem;
1060
+ }
1061
+ }
1062
+ """
1063
+ + TOOLTIP_CSS
1064
+ + """</style>"""
1065
+ )
1066
+
1067
+
1068
+ # ---------------------------------------------------------------------------
1069
+ # JavaScript
1070
+ # ---------------------------------------------------------------------------
1071
+
1072
+
1073
+ def _javascript() -> str:
1074
+ return (
1075
+ """<script>
1076
+ // ---- Path configuration ----
1077
+ let customBasePath = localStorage.getItem('comparisonDataBasePath') || '';
1078
+
1079
+ function togglePathConfig() {
1080
+ const body = document.getElementById('pathConfigBody');
1081
+ body.classList.toggle('expanded');
1082
+ }
1083
+ function updateBasePath() {
1084
+ customBasePath = document.getElementById('dataBasePath').value.trim();
1085
+ localStorage.setItem('comparisonDataBasePath', customBasePath);
1086
+ document.getElementById('currentBasePath').textContent = customBasePath || '(using original paths)';
1087
+ }
1088
+ function resolveFilePath(originalPath) {
1089
+ if (!originalPath) return '';
1090
+ if (!customBasePath || !originalBasePath) return originalPath;
1091
+ if (originalPath.startsWith(originalBasePath)) {
1092
+ return customBasePath + originalPath.slice(originalBasePath.length);
1093
+ }
1094
+ return originalPath;
1095
+ }
1096
+ document.addEventListener('DOMContentLoaded', () => {
1097
+ if (customBasePath) {
1098
+ document.getElementById('dataBasePath').value = customBasePath;
1099
+ document.getElementById('currentBasePath').textContent = customBasePath;
1100
+ }
1101
+ populateMetricSelector();
1102
+ });
1103
+
1104
+ // ---- Metric selector ----
1105
+ const metricDisplayNames = """
1106
+ + json.dumps(METRIC_DISPLAY_NAMES)
1107
+ + """;
1108
+ let currentFilter = 'all';
1109
+
1110
+ function populateMetricSelector() {
1111
+ const select = document.getElementById('metricSelect');
1112
+ if (!select) return;
1113
+ // Discover all metric names across all results
1114
+ const names = new Set();
1115
+ comparisonData.forEach(r => {
1116
+ (r.pipeline_a.all_metrics || []).forEach(m => names.add(m.metric_name));
1117
+ (r.pipeline_b.all_metrics || []).forEach(m => names.add(m.metric_name));
1118
+ });
1119
+ // Sort with current primary first, then alphabetical
1120
+ const sorted = [...names].sort((a, b) => {
1121
+ if (a === comparisonMetric) return -1;
1122
+ if (b === comparisonMetric) return 1;
1123
+ return a.localeCompare(b);
1124
+ });
1125
+ select.innerHTML = '';
1126
+ sorted.forEach(name => {
1127
+ const opt = document.createElement('option');
1128
+ opt.value = name;
1129
+ opt.textContent = metricDisplayNames[name] || name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
1130
+ if (name === comparisonMetric) opt.selected = true;
1131
+ select.appendChild(opt);
1132
+ });
1133
+ }
1134
+
1135
+ function getMetricValue(allMetrics, metricName) {
1136
+ if (!allMetrics) return null;
1137
+ const m = allMetrics.find(m => m.metric_name === metricName);
1138
+ return m ? m.value : null;
1139
+ }
1140
+
1141
+ function switchMetric(newMetric) {
1142
+ comparisonMetric = newMetric;
1143
+ metricDisplayName = metricDisplayNames[newMetric] || newMetric;
1144
+
1145
+ // Recompute categories and metric values for each row
1146
+ const counts = { total: comparisonData.length, a_better: 0, b_better: 0, tie: 0, both_bad: 0 };
1147
+
1148
+ comparisonData.forEach((r, i) => {
1149
+ const vA = getMetricValue(r.pipeline_a.all_metrics, newMetric);
1150
+ const vB = getMetricValue(r.pipeline_b.all_metrics, newMetric);
1151
+
1152
+ // Update metric_value on the data so detail panes use it
1153
+ r.pipeline_a.metric_value = vA;
1154
+ r.pipeline_b.metric_value = vB;
1155
+
1156
+ // Recompute category
1157
+ let cat;
1158
+ if (vA != null && vB != null) {
1159
+ cat = vA > vB ? 'a_better' : vB > vA ? 'b_better' : 'tie';
1160
+ } else if (vA == null && vB == null) {
1161
+ cat = 'both_bad';
1162
+ } else if (vA == null) {
1163
+ cat = 'b_better';
1164
+ } else {
1165
+ cat = 'a_better';
1166
+ }
1167
+ r.category = cat;
1168
+ counts[cat] = (counts[cat] || 0) + 1;
1169
+
1170
+ // Update DOM for this row
1171
+ const row = document.querySelector(`.result-row[data-index="${i}"]`);
1172
+ if (!row) return;
1173
+ row.dataset.category = cat;
1174
+
1175
+ const cols = row.querySelector('.row-summary');
1176
+ if (!cols) return;
1177
+ const metricCols = cols.querySelectorAll('.col-metric');
1178
+ const deltaCol = cols.querySelector('.col-delta');
1179
+ const catCol = cols.querySelector('.col-category');
1180
+
1181
+ if (metricCols[0]) metricCols[0].innerHTML = fmtMetric(vA);
1182
+ if (metricCols[1]) metricCols[1].innerHTML = fmtMetric(vB);
1183
+ if (deltaCol) deltaCol.innerHTML = fmtDelta(vA, vB);
1184
+ if (catCol) {
1185
+ const catLabels = {
1186
+ a_better: pipelineAName + ' Better',
1187
+ b_better: pipelineBName + ' Better',
1188
+ tie: 'Tie',
1189
+ both_bad: 'Both Bad',
1190
+ };
1191
+ catCol.innerHTML = `<span class="badge badge-${cat.replace('_', '-')}">${esc(catLabels[cat] || cat)}</span>`;
1192
+ }
1193
+
1194
+ // If detail pane is open, rebuild it
1195
+ const detail = document.getElementById('detail-' + i);
1196
+ if (detail && detail.classList.contains('open')) {
1197
+ detail.innerHTML = buildDetailContent(i);
1198
+ initDetailInteractions(i);
1199
+ }
1200
+ });
1201
+
1202
+ // Update stat cards
1203
+ const statCards = document.querySelectorAll('.stat-card');
1204
+ statCards.forEach(card => {
1205
+ const filter = card.dataset.filter;
1206
+ const valueEl = card.querySelector('.stat-value');
1207
+ if (!valueEl) return;
1208
+ if (filter === 'all') valueEl.textContent = counts.total;
1209
+ else if (counts[filter] !== undefined) valueEl.textContent = counts[filter];
1210
+ });
1211
+
1212
+ // Update filter bar
1213
+ document.querySelectorAll('.filter-btn').forEach(btn => {
1214
+ const filter = btn.dataset.filter;
1215
+ if (filter === 'all') btn.textContent = `All (${counts.total})`;
1216
+ else if (filter === 'a_better') btn.textContent = `${pipelineAName} Better (${counts.a_better})`;
1217
+ else if (filter === 'b_better') btn.textContent = `${pipelineBName} Better (${counts.b_better})`;
1218
+ else if (filter === 'tie') btn.textContent = `Tie (${counts.tie})`;
1219
+ else if (filter === 'both_bad') btn.textContent = `Both Bad (${counts.both_bad})`;
1220
+ });
1221
+
1222
+ // Re-apply current filter
1223
+ applyFilter(currentFilter);
1224
+ }
1225
+
1226
+ function fmtMetric(val) {
1227
+ if (val === null || val === undefined) return '<span class="na">N/A</span>';
1228
+ const pct = val * 100;
1229
+ return `<span class="metric-val ${metricColorClass(val)}">${pct.toFixed(1)}%</span>`;
1230
+ }
1231
+
1232
+ function fmtDelta(a, b) {
1233
+ if (a === null || a === undefined || b === null || b === undefined) return '<span class="na">&mdash;</span>';
1234
+ const d = (a - b) * 100;
1235
+ const sign = d > 0 ? '+' : '';
1236
+ const cls = d > 0 ? 'delta-pos' : d < 0 ? 'delta-neg' : 'delta-zero';
1237
+ return `<span class="${cls}">${sign}${d.toFixed(1)}pp</span>`;
1238
+ }
1239
+
1240
+ // ---- Filter ----
1241
+ function applyFilter(filter) {
1242
+ currentFilter = filter;
1243
+ // Update filter buttons
1244
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
1245
+ const matchBtn = document.querySelector(`.filter-btn[data-filter="${filter}"]`);
1246
+ if (matchBtn) matchBtn.classList.add('active');
1247
+ // Update stat cards
1248
+ document.querySelectorAll('.stat-card').forEach(c => c.classList.remove('active'));
1249
+ const matchCard = document.querySelector(`.stat-card[data-filter="${filter}"]`);
1250
+ if (matchCard) matchCard.classList.add('active');
1251
+ // Filter rows
1252
+ document.querySelectorAll('.result-row').forEach(row => {
1253
+ row.style.display = (filter === 'all' || row.dataset.category === filter) ? '' : 'none';
1254
+ });
1255
+ }
1256
+
1257
+ document.querySelectorAll('.filter-btn').forEach(btn => {
1258
+ btn.addEventListener('click', () => applyFilter(btn.dataset.filter));
1259
+ });
1260
+
1261
+ function filterFromCard(card) {
1262
+ applyFilter(card.dataset.filter);
1263
+ }
1264
+
1265
+ // ---- Expand / collapse rows ----
1266
+ const openRows = new Set();
1267
+
1268
+ function toggleRow(index) {
1269
+ const detail = document.getElementById('detail-' + index);
1270
+ const icon = document.getElementById('icon-' + index);
1271
+ if (openRows.has(index)) {
1272
+ detail.classList.remove('open');
1273
+ icon.classList.remove('expanded');
1274
+ openRows.delete(index);
1275
+ } else {
1276
+ // Build detail content if empty
1277
+ if (!detail.innerHTML.trim()) {
1278
+ detail.innerHTML = buildDetailContent(index);
1279
+ initDetailInteractions(index);
1280
+ }
1281
+ detail.classList.add('open');
1282
+ icon.classList.add('expanded');
1283
+ openRows.add(index);
1284
+ }
1285
+ }
1286
+
1287
+ // ---- Build detail content ----
1288
+ function buildDetailContent(index) {
1289
+ const r = comparisonData[index];
1290
+ if (!r) return '<p>No data</p>';
1291
+
1292
+ if (productType === 'layout_detection') {
1293
+ return buildLayoutDetectionDetail(r, index);
1294
+ }
1295
+
1296
+ let html = '<div class="detail-two-col">';
1297
+
1298
+ // --- Left column: input preview (sticky) ---
1299
+ html += '<div class="detail-left">';
1300
+ html += buildInputPanel(r);
1301
+ html += buildMetricPills(r);
1302
+ html += '</div>';
1303
+
1304
+ // --- Right column: output + metrics + rules ---
1305
+ html += '<div class="detail-right">';
1306
+ html += buildOutputTab(r, index);
1307
+
1308
+ // Collapsible full metrics
1309
+ const fullMetrics = buildFullMetricsSection(r);
1310
+ if (fullMetrics) {
1311
+ html += `<details class="collapsible-section"><summary>All Metrics</summary>${fullMetrics}</details>`;
1312
+ }
1313
+
1314
+ // Collapsible rules
1315
+ const rulesHtml = buildRulesSection(r);
1316
+ if (rulesHtml) {
1317
+ html += `<details class="collapsible-section"><summary>${rulesHtml.summary}</summary>${rulesHtml.body}</details>`;
1318
+ }
1319
+
1320
+ html += '</div>';
1321
+ html += '</div>';
1322
+
1323
+ return html;
1324
+ }
1325
+
1326
+ // Compact metric pills for the left column
1327
+ function buildMetricPills(r) {
1328
+ const metricsA = r.pipeline_a.all_metrics || [];
1329
+ const metricsB = r.pipeline_b.all_metrics || [];
1330
+ const statsA = r.pipeline_a.all_stats || [];
1331
+ const statsB = r.pipeline_b.all_stats || [];
1332
+ if (metricsA.length === 0 && metricsB.length === 0 && statsA.length === 0 && statsB.length === 0) return '';
1333
+
1334
+ const metricDisplayNames = """
1335
+ + json.dumps(METRIC_DISPLAY_NAMES)
1336
+ + """;
1337
+
1338
+ // Collect all metric names, primary first
1339
+ const names = new Set();
1340
+ metricsA.forEach(m => names.add(m.metric_name));
1341
+ metricsB.forEach(m => names.add(m.metric_name));
1342
+ const sorted = [...names].sort((a, b) => {
1343
+ if (a === comparisonMetric) return -1;
1344
+ if (b === comparisonMetric) return 1;
1345
+ return a.localeCompare(b);
1346
+ });
1347
+
1348
+ let html = '<div class="metric-pills">';
1349
+ sorted.forEach(name => {
1350
+ const mA = metricsA.find(m => m.metric_name === name);
1351
+ const mB = metricsB.find(m => m.metric_name === name);
1352
+ const vA = mA ? mA.value : null;
1353
+ const vB = mB ? mB.value : null;
1354
+ const displayName = metricDisplayNames[name] || name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
1355
+ const isPrimary = name === comparisonMetric;
1356
+
1357
+ const fmtVal = v => v === null || v === undefined ? 'N/A' : (v * 100).toFixed(1) + '%';
1358
+ let deltaHtml = '';
1359
+ if (vA != null && vB != null) {
1360
+ const d = (vA - vB) * 100;
1361
+ const sign = d > 0 ? '+' : '';
1362
+ const cls = d > 0 ? 'delta-pos' : d < 0 ? 'delta-neg' : 'delta-zero';
1363
+ deltaHtml = `<span class="pill-delta ${cls}">${sign}${d.toFixed(1)}</span>`;
1364
+ }
1365
+
1366
+ html += `<div class="pill${isPrimary ? ' pill-primary' : ''}">`;
1367
+ html += `<span class="pill-label">${esc(displayName)}${tooltipIcon(name)}</span>`;
1368
+ html += `<span class="pill-values"><span class="${metricColorClass(vA)}">${fmtVal(vA)}</span> / <span class="${metricColorClass(vB)}">${fmtVal(vB)}</span>${deltaHtml}</span>`;
1369
+ html += '</div>';
1370
+ });
1371
+
1372
+ // Stats pills (raw value + unit, not percentage)
1373
+ const statNames = new Set();
1374
+ statsA.forEach(s => statNames.add(s.name));
1375
+ statsB.forEach(s => statNames.add(s.name));
1376
+ [...statNames].sort().forEach(name => {
1377
+ const sA = statsA.find(s => s.name === name);
1378
+ const sB = statsB.find(s => s.name === name);
1379
+ const vA = sA ? sA.value : null;
1380
+ const vB = sB ? sB.value : null;
1381
+ const unit = (sA || sB || {}).unit || '';
1382
+ const displayName = name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
1383
+ const fmtStat = v => v === null || v === undefined ? 'N/A' : v.toFixed(0) + unit;
1384
+ let deltaHtml = '';
1385
+ if (vA != null && vB != null) {
1386
+ const d = vA - vB;
1387
+ const sign = d > 0 ? '+' : '';
1388
+ const cls = d > 0 ? 'delta-neg' : d < 0 ? 'delta-pos' : 'delta-zero';
1389
+ deltaHtml = `<span class="pill-delta ${cls}">${sign}${d.toFixed(0)}${unit}</span>`;
1390
+ }
1391
+ html += `<div class="pill">`;
1392
+ html += `<span class="pill-label">${esc(displayName)}${tooltipIcon(name)}</span>`;
1393
+ html += `<span class="pill-values">${fmtStat(vA)} / ${fmtStat(vB)}${deltaHtml}</span>`;
1394
+ html += '</div>';
1395
+ });
1396
+
1397
+ html += '</div>';
1398
+ return html;
1399
+ }
1400
+
1401
+ // Input panel for left column
1402
+ function buildInputPanel(r) {
1403
+ return buildInputTab(r);
1404
+ }
1405
+
1406
+ // Full metrics table (for collapsible section)
1407
+ function buildFullMetricsSection(r) {
1408
+ const metricsA = r.pipeline_a.all_metrics || [];
1409
+ const metricsB = r.pipeline_b.all_metrics || [];
1410
+ const statsA = r.pipeline_a.all_stats || [];
1411
+ const statsB = r.pipeline_b.all_stats || [];
1412
+ const metricNames = new Set();
1413
+ metricsA.forEach(m => metricNames.add(m.metric_name));
1414
+ metricsB.forEach(m => metricNames.add(m.metric_name));
1415
+ const statNames = new Set();
1416
+ statsA.forEach(s => statNames.add(s.name));
1417
+ statsB.forEach(s => statNames.add(s.name));
1418
+ if (metricNames.size === 0 && statNames.size === 0) return '';
1419
+
1420
+ const metricDisplayNames = """
1421
+ + json.dumps(METRIC_DISPLAY_NAMES)
1422
+ + """;
1423
+ const sorted = [...metricNames].sort((a, b) => {
1424
+ if (a === comparisonMetric) return -1;
1425
+ if (b === comparisonMetric) return 1;
1426
+ return a.localeCompare(b);
1427
+ });
1428
+
1429
+ let html = `<table class="metrics-table"><thead><tr><th>Metric</th><th style="text-align:center">${esc(pipelineAName)}</th><th style="text-align:center">${esc(pipelineBName)}</th><th style="text-align:center">Delta</th></tr></thead><tbody>`;
1430
+
1431
+ sorted.forEach(name => {
1432
+ const mA = metricsA.find(m => m.metric_name === name);
1433
+ const mB = metricsB.find(m => m.metric_name === name);
1434
+ const vA = mA ? mA.value : null;
1435
+ const vB = mB ? mB.value : null;
1436
+ const displayName = metricDisplayNames[name] || name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
1437
+ const fmtVal = v => v === null || v === undefined ? '<span class="na">N/A</span>' : `<span class="${metricColorClass(v)}">${(v * 100).toFixed(1)}%</span>`;
1438
+ const delta = (vA != null && vB != null)
1439
+ ? (() => { const d = (vA - vB) * 100; const sign = d > 0 ? '+' : ''; const cls = d > 0 ? 'delta-pos' : d < 0 ? 'delta-neg' : 'delta-zero'; return `<span class="${cls}">${sign}${d.toFixed(1)}pp</span>`; })()
1440
+ : '<span class="na">&mdash;</span>';
1441
+ const isPrimary = name === comparisonMetric ? ' style="font-weight:600;"' : '';
1442
+ html += `<tr${isPrimary}><td class="metric-name">${esc(displayName)}${tooltipIcon(name)}</td><td class="val-cell">${fmtVal(vA)}</td><td class="val-cell">${fmtVal(vB)}</td><td class="val-cell">${delta}</td></tr>`;
1443
+ });
1444
+
1445
+ html += '</tbody></table>';
1446
+
1447
+ // Stats table (raw value + unit)
1448
+ if (statNames.size > 0) {
1449
+ html += `<table class="metrics-table" style="margin-top:10px"><thead><tr><th>Stat</th><th style="text-align:center">${esc(pipelineAName)}</th><th style="text-align:center">${esc(pipelineBName)}</th><th style="text-align:center">Delta</th></tr></thead><tbody>`;
1450
+ [...statNames].sort().forEach(name => {
1451
+ const sA = statsA.find(s => s.name === name);
1452
+ const sB = statsB.find(s => s.name === name);
1453
+ const vA = sA ? sA.value : null;
1454
+ const vB = sB ? sB.value : null;
1455
+ const unit = (sA || sB || {}).unit || '';
1456
+ const displayName = name.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
1457
+ const fmtStat = v => v === null || v === undefined ? '<span class="na">N/A</span>' : v.toFixed(0) + unit;
1458
+ const delta = (vA != null && vB != null)
1459
+ ? (() => { const d = vA - vB; const sign = d > 0 ? '+' : ''; const cls = d > 0 ? 'delta-neg' : d < 0 ? 'delta-pos' : 'delta-zero'; return `<span class="${cls}">${sign}${d.toFixed(0)}${unit}</span>`; })()
1460
+ : '<span class="na">&mdash;</span>';
1461
+ html += `<tr><td class="metric-name">${esc(displayName)}</td><td class="val-cell">${fmtStat(vA)}</td><td class="val-cell">${fmtStat(vB)}</td><td class="val-cell">${delta}</td></tr>`;
1462
+ });
1463
+ html += '</tbody></table>';
1464
+ }
1465
+
1466
+ return html;
1467
+ }
1468
+
1469
+ // Rules section (returns {summary, body} or null)
1470
+ function buildRulesSection(r) {
1471
+ const metricsA = r.pipeline_a.all_metrics || [];
1472
+ const metricsB = r.pipeline_b.all_metrics || [];
1473
+
1474
+ function getRules(metrics) {
1475
+ for (const m of metrics) {
1476
+ if (m.metric_name === 'rule_pass_rate' && m.metadata && m.metadata.rule_results) return m.metadata.rule_results;
1477
+ }
1478
+ for (const m of metrics) {
1479
+ if (m.metadata && m.metadata.rule_results) return m.metadata.rule_results;
1480
+ }
1481
+ return [];
1482
+ }
1483
+
1484
+ const rulesA = getRules(metricsA);
1485
+ const rulesB = getRules(metricsB);
1486
+ if (rulesA.length === 0 && rulesB.length === 0) return null;
1487
+
1488
+ const maxLen = Math.max(rulesA.length, rulesB.length);
1489
+ let diffCount = 0;
1490
+ for (let i = 0; i < maxLen; i++) {
1491
+ const pA = (rulesA[i] || {}).passed;
1492
+ const pB = (rulesB[i] || {}).passed;
1493
+ if (pA !== undefined && pB !== undefined && pA !== pB) diffCount++;
1494
+ }
1495
+
1496
+ const summary = `Rules (${maxLen} total${diffCount > 0 ? `, <strong>${diffCount} differ</strong>` : ''})`;
1497
+
1498
+ let body = `<table class="rule-table"><thead><tr><th>#</th><th>Type</th><th>Name</th><th>Page</th><th style="text-align:center">${esc(pipelineAName)}</th><th style="text-align:center">${esc(pipelineBName)}</th><th></th></tr></thead><tbody>`;
1499
+
1500
+ for (let i = 0; i < maxLen; i++) {
1501
+ const rA = rulesA[i] || {};
1502
+ const rB = rulesB[i] || {};
1503
+ const passA = rA.passed;
1504
+ const passB = rB.passed;
1505
+ let rowClass = '';
1506
+ if (passA !== undefined && passB !== undefined) {
1507
+ rowClass = passA === passB ? (passA ? 'rule-same-pass' : 'rule-same-fail') : 'rule-diff-row';
1508
+ }
1509
+ const type = rA.type || rB.type || '';
1510
+ const name = rA.name || rB.name || '';
1511
+ const page = rA.page || rB.page || '';
1512
+ const fmtPass = v => v === true ? '<span class="rule-pass">PASS</span>' : v === false ? '<span class="rule-fail">FAIL</span>' : '<span class="na">—</span>';
1513
+ const explA = rA.explanation || '';
1514
+ const explB = rB.explanation || '';
1515
+ const hasExpl = explA || explB;
1516
+ body += `<tr class="${rowClass}"><td>${i+1}</td><td>${esc(type)}</td><td>${esc(name)}</td><td>${page||''}</td><td style="text-align:center">${fmtPass(passA)}</td><td style="text-align:center">${fmtPass(passB)}</td><td>${hasExpl ? '<span class="rule-expander" onclick="toggleRuleExpl(this)">details</span>' : ''}</td></tr>`;
1517
+ if (hasExpl) {
1518
+ body += `<tr class="${rowClass}" style="display:none;" data-expl-row="1"><td colspan="7" style="padding:0.25rem 0.6rem;"><div style="font-size:0.75rem;color:var(--muted);">`;
1519
+ if (explA) body += `<div><strong>${esc(pipelineAName)}:</strong> ${esc(explA)}</div>`;
1520
+ if (explB) body += `<div><strong>${esc(pipelineBName)}:</strong> ${esc(explB)}</div>`;
1521
+ body += '</div></td></tr>';
1522
+ }
1523
+ }
1524
+ body += '</tbody></table>';
1525
+
1526
+ return { summary, body };
1527
+ }
1528
+
1529
+ function toggleRuleExpl(el) {
1530
+ const tr = el.closest('tr');
1531
+ const next = tr.nextElementSibling;
1532
+ if (next && next.dataset.explRow) {
1533
+ next.style.display = next.style.display === 'none' ? '' : 'none';
1534
+ }
1535
+ }
1536
+
1537
+ // ---- Output tab ----
1538
+ function buildOutputTab(r, index) {
1539
+ if (productType === 'parse') {
1540
+ return buildParseOutputTab(r, index);
1541
+ } else if (productType === 'extract') {
1542
+ return buildExtractOutputTab(r, index);
1543
+ }
1544
+ return '<p class="na" style="padding:1rem;">No output comparison available for this product type.</p>';
1545
+ }
1546
+
1547
+ function buildParseOutputTab(r, index) {
1548
+ const outA = r.pipeline_a.output || '';
1549
+ const outB = r.pipeline_b.output || '';
1550
+
1551
+ let html = '';
1552
+
1553
+ // View mode toggle
1554
+ html += `<div style="display:flex;gap:0.5rem;margin-bottom:0.75rem;">`;
1555
+ html += `<div class="view-toggle">`;
1556
+ html += `<button class="active" onclick="setParseView(${index}, 'rendered', this)">Rendered</button>`;
1557
+ html += `<button onclick="setParseView(${index}, 'raw', this)">Raw</button>`;
1558
+ html += `<button onclick="setParseView(${index}, 'diff', this)">Diff</button>`;
1559
+ html += `</div></div>`;
1560
+
1561
+ // Side-by-side rendered
1562
+ html += `<div id="parse-rendered-${index}" class="output-grid">`;
1563
+ html += `<div class="output-panel"><div class="output-panel-header">${esc(pipelineAName)}</div>`;
1564
+ html += `<div class="output-panel-body md-view" id="md-a-${index}">${outA ? marked.parse(outA) : '<em>No output</em>'}</div></div>`;
1565
+ html += `<div class="output-panel"><div class="output-panel-header">${esc(pipelineBName)}</div>`;
1566
+ html += `<div class="output-panel-body md-view" id="md-b-${index}">${outB ? marked.parse(outB) : '<em>No output</em>'}</div></div>`;
1567
+ html += `</div>`;
1568
+
1569
+ // Side-by-side raw
1570
+ html += `<div id="parse-raw-${index}" class="output-grid" style="display:none;">`;
1571
+ html += `<div class="output-panel"><div class="output-panel-header">${esc(pipelineAName)}</div>`;
1572
+ html += `<div class="output-panel-body json-view">${esc(outA) || '<em>No output</em>'}</div></div>`;
1573
+ html += `<div class="output-panel"><div class="output-panel-header">${esc(pipelineBName)}</div>`;
1574
+ html += `<div class="output-panel-body json-view">${esc(outB) || '<em>No output</em>'}</div></div>`;
1575
+ html += `</div>`;
1576
+
1577
+ // Diff view
1578
+ html += `<div id="parse-diff-${index}" style="display:none;">`;
1579
+ html += `<div class="diff-container"><div class="diff-header">Unified Diff</div>`;
1580
+ html += `<div class="diff-body">${computeLineDiff(outA, outB)}</div></div>`;
1581
+ html += `</div>`;
1582
+
1583
+ return html;
1584
+ }
1585
+
1586
+ function setParseView(index, mode, btn) {
1587
+ const toggle = btn.closest('.view-toggle');
1588
+ toggle.querySelectorAll('button').forEach(b => b.classList.remove('active'));
1589
+ btn.classList.add('active');
1590
+ ['rendered', 'raw', 'diff'].forEach(m => {
1591
+ const el = document.getElementById(`parse-${m}-${index}`);
1592
+ if (el) el.style.display = m === mode ? '' : 'none';
1593
+ });
1594
+ if (mode === 'rendered' || mode === 'raw') {
1595
+ setupSyncScroll(index, mode);
1596
+ }
1597
+ }
1598
+
1599
+ function buildExtractOutputTab(r, index) {
1600
+ const outA = r.pipeline_a.output;
1601
+ const outB = r.pipeline_b.output;
1602
+ const jsonA = outA != null ? JSON.stringify(outA, null, 2) : '';
1603
+ const jsonB = outB != null ? JSON.stringify(outB, null, 2) : '';
1604
+
1605
+ let html = `<div style="display:flex;gap:0.5rem;margin-bottom:0.75rem;">`;
1606
+ html += `<div class="view-toggle">`;
1607
+ html += `<button class="active" onclick="setExtractView(${index}, 'side', this)">Side by Side</button>`;
1608
+ html += `<button onclick="setExtractView(${index}, 'diff', this)">Diff</button>`;
1609
+ html += `</div></div>`;
1610
+
1611
+ // Side by side JSON
1612
+ html += `<div id="extract-side-${index}" class="output-grid">`;
1613
+ html += `<div class="output-panel"><div class="output-panel-header">${esc(pipelineAName)}</div>`;
1614
+ html += `<div class="output-panel-body json-view">${esc(jsonA) || '<em>No output</em>'}</div></div>`;
1615
+ html += `<div class="output-panel"><div class="output-panel-header">${esc(pipelineBName)}</div>`;
1616
+ html += `<div class="output-panel-body json-view">${esc(jsonB) || '<em>No output</em>'}</div></div>`;
1617
+ html += `</div>`;
1618
+
1619
+ // Diff
1620
+ html += `<div id="extract-diff-${index}" style="display:none;">`;
1621
+ html += `<div class="diff-container"><div class="diff-header">Unified Diff</div>`;
1622
+ html += `<div class="diff-body">${computeLineDiff(jsonA, jsonB)}</div></div>`;
1623
+ html += `</div>`;
1624
+
1625
+ return html;
1626
+ }
1627
+
1628
+ function setExtractView(index, mode, btn) {
1629
+ const toggle = btn.closest('.view-toggle');
1630
+ toggle.querySelectorAll('button').forEach(b => b.classList.remove('active'));
1631
+ btn.classList.add('active');
1632
+ ['side', 'diff'].forEach(m => {
1633
+ const el = document.getElementById(`extract-${m}-${index}`);
1634
+ if (el) el.style.display = m === mode ? '' : 'none';
1635
+ });
1636
+ }
1637
+
1638
+ // ---- Input tab ----
1639
+ function buildInputTab(r) {
1640
+ const inputFile = r.input_file;
1641
+ const inputFileRel = r.input_file_rel;
1642
+ const dataUrl = r.input_data_url;
1643
+
1644
+ if (!inputFile && !dataUrl) {
1645
+ return '<p class="na" style="padding:1rem;">No input file available.</p>';
1646
+ }
1647
+
1648
+ const ext = inputFile ? inputFile.split('.').pop().toLowerCase() : '';
1649
+ const isPdf = ext === 'pdf';
1650
+
1651
+ let html = '<div class="input-preview">';
1652
+
1653
+ if (inputFile) {
1654
+ html += `<p class="file-path">${esc(inputFile)}</p>`;
1655
+ }
1656
+
1657
+ if (dataUrl && !isPdf) {
1658
+ // Embedded image
1659
+ html += `<img src="${dataUrl}" />`;
1660
+ } else if (isPdf) {
1661
+ // Resolve PDF URL: use pdfBaseUrl + relative path, like the detailed report
1662
+ let pdfSrc = '';
1663
+ if (pdfBaseUrl && inputFileRel) {
1664
+ const base = pdfBaseUrl.endsWith('/') ? pdfBaseUrl.slice(0, -1) : pdfBaseUrl;
1665
+ const rel = inputFileRel.startsWith('/') ? inputFileRel.slice(1) : inputFileRel;
1666
+ pdfSrc = base + '/' + rel;
1667
+ } else if (inputFile) {
1668
+ pdfSrc = resolveFilePath(inputFile);
1669
+ }
1670
+ if (pdfSrc) {
1671
+ const viewerId = 'pdfviewer-' + Math.random().toString(36).slice(2, 10);
1672
+ html += `<div id="${viewerId}" class="pdfjs-viewer" data-pdf-src="${esc(pdfSrc)}" data-pdf-pending="true" style="max-height:700px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:8px;background:#f5f5f4;"><p class="na">Loading PDF...</p></div>`;
1673
+ } else {
1674
+ html += `<p class="na">PDF preview not available — no base URL configured.</p>`;
1675
+ }
1676
+ } else if (inputFile) {
1677
+ if (['png', 'jpg', 'jpeg', 'gif'].includes(ext)) {
1678
+ // Resolve image URL via file server, same as PDFs
1679
+ let imgSrc = '';
1680
+ if (pdfBaseUrl && inputFileRel) {
1681
+ const base = pdfBaseUrl.endsWith('/') ? pdfBaseUrl.slice(0, -1) : pdfBaseUrl;
1682
+ const rel = inputFileRel.startsWith('/') ? inputFileRel.slice(1) : inputFileRel;
1683
+ imgSrc = base + '/' + rel;
1684
+ } else {
1685
+ imgSrc = resolveFilePath(inputFile);
1686
+ }
1687
+ html += `<img src="${esc(imgSrc)}" />`;
1688
+ } else {
1689
+ html += `<p class="na">Preview not available for .${esc(ext)} files</p>`;
1690
+ }
1691
+ }
1692
+
1693
+ html += '</div>';
1694
+ return html;
1695
+ }
1696
+
1697
+ // ---- Layout detection detail ----
1698
+ const LAYOUTDET_COLORS = {
1699
+ 'Caption': '#E91E63', 'Footnote': '#9C27B0', 'Formula': '#673AB7',
1700
+ 'List-item': '#3F51B5', 'Page-footer': '#2196F3', 'Page-header': '#00BCD4',
1701
+ 'Picture': '#4CAF50', 'Section-header': '#FF9800', 'Table': '#FF5722',
1702
+ 'Text': '#795548', 'Title': '#F44336'
1703
+ };
1704
+
1705
+ function buildLayoutDetectionDetail(r, index) {
1706
+ const predA = r.pipeline_a.predictions || [];
1707
+ const predB = r.pipeline_b.predictions || [];
1708
+ const gt = r.gt_annotations || [];
1709
+ const imgPath = r.input_data_url || resolveFilePath(r.input_file || '');
1710
+
1711
+ // Store in window for drawing
1712
+ window[`predA_${index}`] = predA;
1713
+ window[`predB_${index}`] = predB;
1714
+ window[`gt_${index}`] = gt;
1715
+ window[`bboxState_${index}`] = { showA: true, showB: true, showGT: true };
1716
+
1717
+ const metricA = r.pipeline_a.metric_value != null ? (r.pipeline_a.metric_value * 100).toFixed(1) + '%' : 'N/A';
1718
+ const metricB = r.pipeline_b.metric_value != null ? (r.pipeline_b.metric_value * 100).toFixed(1) + '%' : 'N/A';
1719
+
1720
+ let html = '';
1721
+
1722
+ // Metric pills at top
1723
+ html += buildMetricPills(r);
1724
+
1725
+ // 3-column grid
1726
+ html += `<div class="layoutdet-grid">`;
1727
+
1728
+ // Panel A
1729
+ html += `<div class="layoutdet-panel"><div class="layoutdet-panel-header"><span>${esc(pipelineAName)} (${metricA})</span>`;
1730
+ html += `<button class="bbox-btn active" onclick="toggleLayoutBbox(${index}, 'A', this)">Bboxes</button>`;
1731
+ html += `</div><div class="layoutdet-panel-body"><img id="ld-img-a-${index}" src="${imgPath}" onload="drawLayoutPanel(${index}, 'A')" /><canvas id="ld-canvas-a-${index}" class="layoutdet-canvas"></canvas></div></div>`;
1732
+
1733
+ // Panel B
1734
+ html += `<div class="layoutdet-panel"><div class="layoutdet-panel-header"><span>${esc(pipelineBName)} (${metricB})</span>`;
1735
+ html += `<button class="bbox-btn active" onclick="toggleLayoutBbox(${index}, 'B', this)">Bboxes</button>`;
1736
+ html += `</div><div class="layoutdet-panel-body"><img id="ld-img-b-${index}" src="${imgPath}" onload="drawLayoutPanel(${index}, 'B')" /><canvas id="ld-canvas-b-${index}" class="layoutdet-canvas"></canvas></div></div>`;
1737
+
1738
+ // GT Panel
1739
+ html += `<div class="layoutdet-panel"><div class="layoutdet-panel-header"><span>Ground Truth</span>`;
1740
+ html += `<span style="display:flex;gap:4px;">`;
1741
+ html += `<button class="bbox-btn active" onclick="toggleLayoutOverlay(${index}, 'GT', this)">GT</button>`;
1742
+ html += `<button class="bbox-btn active" onclick="toggleLayoutOverlay(${index}, 'A', this)">A</button>`;
1743
+ html += `<button class="bbox-btn active" onclick="toggleLayoutOverlay(${index}, 'B', this)">B</button>`;
1744
+ html += `</span></div><div class="layoutdet-panel-body"><img id="ld-img-gt-${index}" src="${imgPath}" onload="drawLayoutOverlay(${index})" /><canvas id="ld-canvas-gt-${index}" class="layoutdet-canvas"></canvas></div></div>`;
1745
+
1746
+ html += `</div>`;
1747
+
1748
+ // Legend
1749
+ html += `<div class="comparison-legend">`;
1750
+ html += `<div class="legend-item"><div class="legend-swatch" style="background:#4CAF50;border:2px dashed #4CAF50;"></div><span>Ground Truth</span></div>`;
1751
+ html += `<div class="legend-item"><div class="legend-swatch" style="background:#2196F3;"></div><span>${esc(pipelineAName)}</span></div>`;
1752
+ html += `<div class="legend-item"><div class="legend-swatch" style="background:#9C27B0;border:2px dashed #9C27B0;"></div><span>${esc(pipelineBName)}</span></div>`;
1753
+ html += `</div>`;
1754
+
1755
+ return html;
1756
+ }
1757
+
1758
+ function drawLayoutPanel(index, which) {
1759
+ const canvas = document.getElementById(`ld-canvas-${which.toLowerCase()}-${index}`);
1760
+ const img = document.getElementById(`ld-img-${which.toLowerCase()}-${index}`);
1761
+ const preds = window[`pred${which}_${index}`];
1762
+ const state = window[`bboxState_${index}`];
1763
+ if (!canvas || !img) return;
1764
+ const ctx = canvas.getContext('2d');
1765
+ const scale = img.clientWidth / img.naturalWidth;
1766
+ canvas.width = img.clientWidth;
1767
+ canvas.height = img.clientHeight;
1768
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
1769
+ if (!state[`show${which}`] || !preds) return;
1770
+ preds.forEach(p => {
1771
+ const bbox = p.bbox.map(v => v * scale);
1772
+ const color = LAYOUTDET_COLORS[p.class] || '#999';
1773
+ ctx.fillStyle = color + '1a';
1774
+ ctx.fillRect(bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1]);
1775
+ ctx.strokeStyle = color;
1776
+ ctx.lineWidth = 2;
1777
+ ctx.strokeRect(bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1]);
1778
+ const label = p.score != null ? `${p.class} (${(p.score * 100).toFixed(0)}%)` : p.class;
1779
+ ctx.font = '10px Arial';
1780
+ const tw = ctx.measureText(label).width;
1781
+ ctx.fillStyle = color;
1782
+ ctx.fillRect(bbox[0], bbox[1] - 13, tw + 4, 13);
1783
+ ctx.fillStyle = '#fff';
1784
+ ctx.fillText(label, bbox[0] + 2, bbox[1] - 2);
1785
+ });
1786
+ }
1787
+
1788
+ function drawLayoutOverlay(index) {
1789
+ const canvas = document.getElementById(`ld-canvas-gt-${index}`);
1790
+ const img = document.getElementById(`ld-img-gt-${index}`);
1791
+ if (!canvas || !img) return;
1792
+ const ctx = canvas.getContext('2d');
1793
+ const scale = img.clientWidth / img.naturalWidth;
1794
+ canvas.width = img.clientWidth;
1795
+ canvas.height = img.clientHeight;
1796
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
1797
+ const state = window[`bboxState_${index}`];
1798
+
1799
+ // GT (dashed green)
1800
+ const gt = window[`gt_${index}`] || [];
1801
+ if (state.showGT && gt.length) {
1802
+ ctx.setLineDash([5, 3]);
1803
+ gt.forEach(item => {
1804
+ const bbox = [item.bbox[0], item.bbox[1], item.bbox[0] + item.bbox[2], item.bbox[1] + item.bbox[3]].map(v => v * scale);
1805
+ ctx.strokeStyle = '#4CAF50';
1806
+ ctx.lineWidth = 2;
1807
+ ctx.strokeRect(bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1]);
1808
+ });
1809
+ }
1810
+ // Pred A (solid blue)
1811
+ const predA = window[`predA_${index}`] || [];
1812
+ if (state.showA && predA.length) {
1813
+ ctx.setLineDash([]);
1814
+ predA.forEach(item => {
1815
+ const bbox = item.bbox.map(v => v * scale);
1816
+ ctx.strokeStyle = '#2196F3';
1817
+ ctx.lineWidth = 2;
1818
+ ctx.strokeRect(bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1]);
1819
+ });
1820
+ }
1821
+ // Pred B (dashed purple)
1822
+ const predB = window[`predB_${index}`] || [];
1823
+ if (state.showB && predB.length) {
1824
+ ctx.setLineDash([3, 2]);
1825
+ predB.forEach(item => {
1826
+ const bbox = item.bbox.map(v => v * scale);
1827
+ ctx.strokeStyle = '#9C27B0';
1828
+ ctx.lineWidth = 2;
1829
+ ctx.strokeRect(bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1]);
1830
+ });
1831
+ }
1832
+ ctx.setLineDash([]);
1833
+ }
1834
+
1835
+ function toggleLayoutBbox(index, which, btn) {
1836
+ const state = window[`bboxState_${index}`];
1837
+ state[`show${which}`] = !state[`show${which}`];
1838
+ btn.classList.toggle('active', state[`show${which}`]);
1839
+ drawLayoutPanel(index, which);
1840
+ }
1841
+
1842
+ function toggleLayoutOverlay(index, which, btn) {
1843
+ const state = window[`bboxState_${index}`];
1844
+ state[`show${which}`] = !state[`show${which}`];
1845
+ btn.classList.toggle('active', state[`show${which}`]);
1846
+ drawLayoutOverlay(index);
1847
+ // Also redraw individual panels if A or B toggled
1848
+ if (which === 'A' || which === 'B') {
1849
+ drawLayoutPanel(index, which);
1850
+ }
1851
+ }
1852
+
1853
+ // ---- Diff computation (simple line diff) ----
1854
+ function computeLineDiff(textA, textB) {
1855
+ if (!textA && !textB) return '<div class="diff-line diff-ctx">(both empty)</div>';
1856
+ const linesA = (textA || '').split('\\n');
1857
+ const linesB = (textB || '').split('\\n');
1858
+
1859
+ // Simple LCS-based diff
1860
+ const m = linesA.length, n = linesB.length;
1861
+
1862
+ // For very large files, fall back to simple comparison
1863
+ if (m + n > 4000) {
1864
+ let html = '';
1865
+ const maxLen = Math.max(m, n);
1866
+ for (let i = 0; i < maxLen; i++) {
1867
+ const a = i < m ? linesA[i] : undefined;
1868
+ const b = i < n ? linesB[i] : undefined;
1869
+ if (a === b) {
1870
+ html += `<div class="diff-line diff-ctx"> ${esc(a)}</div>`;
1871
+ } else {
1872
+ if (a !== undefined) html += `<div class="diff-line diff-del">-${esc(a)}</div>`;
1873
+ if (b !== undefined) html += `<div class="diff-line diff-add">+${esc(b)}</div>`;
1874
+ }
1875
+ }
1876
+ return html;
1877
+ }
1878
+
1879
+ // Build LCS table
1880
+ const dp = Array.from({length: m + 1}, () => new Uint16Array(n + 1));
1881
+ for (let i = 1; i <= m; i++) {
1882
+ for (let j = 1; j <= n; j++) {
1883
+ dp[i][j] = linesA[i-1] === linesB[j-1] ? dp[i-1][j-1] + 1 : Math.max(dp[i-1][j], dp[i][j-1]);
1884
+ }
1885
+ }
1886
+
1887
+ // Backtrack
1888
+ const ops = [];
1889
+ let i = m, j = n;
1890
+ while (i > 0 || j > 0) {
1891
+ if (i > 0 && j > 0 && linesA[i-1] === linesB[j-1]) {
1892
+ ops.push({type: 'ctx', line: linesA[i-1]});
1893
+ i--; j--;
1894
+ } else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) {
1895
+ ops.push({type: 'add', line: linesB[j-1]});
1896
+ j--;
1897
+ } else {
1898
+ ops.push({type: 'del', line: linesA[i-1]});
1899
+ i--;
1900
+ }
1901
+ }
1902
+ ops.reverse();
1903
+
1904
+ // Render with context collapsing
1905
+ let html = '';
1906
+ let ctxCount = 0;
1907
+ const CTX_LIMIT = 3;
1908
+
1909
+ ops.forEach((op, idx) => {
1910
+ if (op.type === 'ctx') {
1911
+ // Show context lines near changes
1912
+ const nearChange = ops.slice(Math.max(0, idx - CTX_LIMIT), idx).some(o => o.type !== 'ctx')
1913
+ || ops.slice(idx + 1, idx + CTX_LIMIT + 1).some(o => o.type !== 'ctx');
1914
+ if (nearChange) {
1915
+ if (ctxCount > 0) {
1916
+ html += `<div class="diff-line diff-hunk">@@ ${ctxCount} unchanged lines @@</div>`;
1917
+ ctxCount = 0;
1918
+ }
1919
+ html += `<div class="diff-line diff-ctx"> ${esc(op.line)}</div>`;
1920
+ } else {
1921
+ ctxCount++;
1922
+ }
1923
+ } else {
1924
+ if (ctxCount > 0) {
1925
+ html += `<div class="diff-line diff-hunk">@@ ${ctxCount} unchanged lines @@</div>`;
1926
+ ctxCount = 0;
1927
+ }
1928
+ const cls = op.type === 'add' ? 'diff-add' : 'diff-del';
1929
+ const prefix = op.type === 'add' ? '+' : '-';
1930
+ html += `<div class="diff-line ${cls}">${prefix}${esc(op.line)}</div>`;
1931
+ }
1932
+ });
1933
+
1934
+ if (ctxCount > 0) {
1935
+ html += `<div class="diff-line diff-hunk">@@ ${ctxCount} unchanged lines @@</div>`;
1936
+ }
1937
+
1938
+ return html || '<div class="diff-line diff-ctx">(no differences)</div>';
1939
+ }
1940
+
1941
+ // ---- Sync scroll ----
1942
+ function setupSyncScroll(index, mode) {
1943
+ const container = document.getElementById(`parse-${mode}-${index}`);
1944
+ if (!container) return;
1945
+ const panels = container.querySelectorAll('.output-panel-body');
1946
+ if (panels.length < 2) return;
1947
+
1948
+ let syncing = false;
1949
+ panels.forEach((pane, i) => {
1950
+ pane.addEventListener('scroll', () => {
1951
+ if (syncing) return;
1952
+ syncing = true;
1953
+ const other = panels[1 - i];
1954
+ const pct = pane.scrollTop / (pane.scrollHeight - pane.clientHeight || 1);
1955
+ other.scrollTop = pct * (other.scrollHeight - other.clientHeight);
1956
+ setTimeout(() => { syncing = false; }, 10);
1957
+ });
1958
+ });
1959
+ }
1960
+
1961
+ // ---- Detail interactions initialization ----
1962
+ function initDetailInteractions(index) {
1963
+ // Setup sync scroll for rendered view
1964
+ if (productType === 'parse') {
1965
+ setTimeout(() => setupSyncScroll(index, 'rendered'), 100);
1966
+ }
1967
+ // Render any pending PDF viewers
1968
+ const detail = document.getElementById('detail-' + index);
1969
+ if (detail) {
1970
+ detail.querySelectorAll('[data-pdf-pending]').forEach(el => {
1971
+ el.removeAttribute('data-pdf-pending');
1972
+ renderEmbeddedPdf(el.id);
1973
+ });
1974
+ }
1975
+ }
1976
+
1977
+ // ---- Utilities ----
1978
+ function metricColorClass(v) {
1979
+ if (v === null || v === undefined) return 'metric-na';
1980
+ if (v >= 0.9) return 'metric-high';
1981
+ if (v >= 0.7) return 'metric-mid';
1982
+ if (v >= 0.5) return 'metric-low';
1983
+ return 'metric-bad';
1984
+ }
1985
+
1986
+ function esc(str) {
1987
+ if (str === null || str === undefined) return '';
1988
+ const div = document.createElement('div');
1989
+ div.textContent = String(str);
1990
+ return div.innerHTML;
1991
+ }
1992
+
1993
+ """
1994
+ + TOOLTIP_JS
1995
+ + """
1996
+
1997
+ // ---- PDF.js rendering ----
1998
+ const pdfJsCdnList = [
1999
+ { src: 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.min.js',
2000
+ worker: 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.worker.min.js' },
2001
+ { src: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.4.120/build/pdf.min.js',
2002
+ worker: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.4.120/build/pdf.worker.min.js' },
2003
+ ];
2004
+ let pdfJsLoadPromise = null;
2005
+
2006
+ function ensurePdfJsLoaded() {
2007
+ if (window.pdfjsLib) return Promise.resolve(true);
2008
+ if (pdfJsLoadPromise) return pdfJsLoadPromise;
2009
+ pdfJsLoadPromise = new Promise(resolve => {
2010
+ let idx = 0;
2011
+ const tryLoad = () => {
2012
+ if (idx >= pdfJsCdnList.length) { resolve(false); return; }
2013
+ const { src, worker } = pdfJsCdnList[idx++];
2014
+ const s = document.createElement('script');
2015
+ s.src = src; s.async = true;
2016
+ s.onload = () => {
2017
+ if (window.pdfjsLib) {
2018
+ pdfjsLib.GlobalWorkerOptions.workerSrc = worker;
2019
+ resolve(true);
2020
+ } else tryLoad();
2021
+ };
2022
+ s.onerror = tryLoad;
2023
+ document.head.appendChild(s);
2024
+ };
2025
+ tryLoad();
2026
+ });
2027
+ return pdfJsLoadPromise;
2028
+ }
2029
+
2030
+ async function renderEmbeddedPdf(viewerId) {
2031
+ const viewer = document.getElementById(viewerId);
2032
+ if (!viewer) return;
2033
+ const pdfSrc = viewer.getAttribute('data-pdf-src');
2034
+ if (!pdfSrc) { viewer.innerHTML = '<p class="na">No PDF data</p>'; return; }
2035
+
2036
+ const ready = await ensurePdfJsLoaded();
2037
+ if (!ready) { viewer.innerHTML = '<p class="na">Failed to load PDF.js</p>'; return; }
2038
+
2039
+ try {
2040
+ const loadingTask = pdfjsLib.getDocument(pdfSrc);
2041
+ const pdfDoc = await loadingTask.promise;
2042
+ viewer.innerHTML = '';
2043
+
2044
+ for (let pageNum = 1; pageNum <= pdfDoc.numPages; pageNum++) {
2045
+ const page = await pdfDoc.getPage(pageNum);
2046
+ const containerWidth = viewer.clientWidth - 16;
2047
+ const unscaledViewport = page.getViewport({ scale: 1 });
2048
+ const scale = containerWidth / unscaledViewport.width;
2049
+ const viewport = page.getViewport({ scale });
2050
+
2051
+ const canvas = document.createElement('canvas');
2052
+ canvas.width = viewport.width;
2053
+ canvas.height = viewport.height;
2054
+ canvas.style.display = 'block';
2055
+ canvas.style.marginBottom = '8px';
2056
+ canvas.style.borderRadius = '4px';
2057
+ viewer.appendChild(canvas);
2058
+
2059
+ await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
2060
+ }
2061
+ } catch (e) {
2062
+ viewer.innerHTML = `<p class="na">Failed to render PDF: ${esc(e.message || '')}</p>`;
2063
+ }
2064
+ }
2065
+ </script>"""
2066
+ )