|
|
@@ -1,6 +1,8 @@
|
|
1
|
1
|
from __future__ import annotations
|
|
2
|
2
|
|
|
3
|
3
|
import json
|
|
|
4
|
+import re
|
|
|
5
|
+import sys
|
|
4
|
6
|
from pathlib import Path
|
|
5
|
7
|
|
|
6
|
8
|
from bs4 import BeautifulSoup
|
|
|
@@ -13,8 +15,93 @@ LATEX_PREAMBLE = r"""\documentclass[12pt,a4paper]{article}
|
|
13
|
15
|
\usepackage{geometry}
|
|
14
|
16
|
\usepackage{booktabs}
|
|
15
|
17
|
\geometry{margin=2cm}
|
|
16
|
|
-\begin{document}
|
|
17
|
18
|
"""
|
|
|
19
|
+LATEX_BEGIN = "\\begin{document}\n"
|
|
|
20
|
+LATEX_END = "\n\\end{document}\n"
|
|
|
21
|
+
|
|
|
22
|
+HTML_TEMPLATE = """\
|
|
|
23
|
+<!DOCTYPE html>
|
|
|
24
|
+<html lang="en">
|
|
|
25
|
+<head>
|
|
|
26
|
+<meta charset="utf-8">
|
|
|
27
|
+<title>{title}</title>
|
|
|
28
|
+<script>
|
|
|
29
|
+MathJax = {{
|
|
|
30
|
+ tex: {{
|
|
|
31
|
+ inlineMath: [['$', '$']],
|
|
|
32
|
+ displayMath: [['$$', '$$']],
|
|
|
33
|
+ packages: {{'[+]': ['ams']}}
|
|
|
34
|
+ }},
|
|
|
35
|
+ loader: {{load: ['[tex]/ams']}}
|
|
|
36
|
+}};
|
|
|
37
|
+</script>
|
|
|
38
|
+<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js" async></script>
|
|
|
39
|
+<style>
|
|
|
40
|
+ body {{ font-family: serif; max-width: 800px; margin: 2em auto; padding: 0 1em; line-height: 1.6; }}
|
|
|
41
|
+ h2 {{ margin-top: 1.5em; }}
|
|
|
42
|
+ .page-sep {{ text-align: center; color: #888; margin: 2em 0; font-size: 0.9em; }}
|
|
|
43
|
+ hr {{ border: none; border-top: 1px solid #ccc; }}
|
|
|
44
|
+ table {{ border-collapse: collapse; margin: 1em 0; width: 100%; }}
|
|
|
45
|
+ th, td {{ border: 1px solid #aaa; padding: 4px 8px; text-align: center; }}
|
|
|
46
|
+ th {{ background: #eaeaea; }}
|
|
|
47
|
+ .equation {{ text-align: center; margin: 1em 0; }}
|
|
|
48
|
+</style>
|
|
|
49
|
+</head>
|
|
|
50
|
+<body>
|
|
|
51
|
+{body}
|
|
|
52
|
+</body>
|
|
|
53
|
+</html>
|
|
|
54
|
+"""
|
|
|
55
|
+
|
|
|
56
|
+MD_HEADING: re.Pattern = re.compile(r"^#{1,6}\s+")
|
|
|
57
|
+MD_TABLE_SEP: re.Pattern = re.compile(r"^\|?[\s:-]+\|[\s|:-]+\|?$")
|
|
|
58
|
+
|
|
|
59
|
+
|
|
|
60
|
+def _is_md_table(text: str) -> bool:
|
|
|
61
|
+ lines = text.strip().splitlines()
|
|
|
62
|
+ if len(lines) < 3:
|
|
|
63
|
+ return False
|
|
|
64
|
+ has_sep = any(MD_TABLE_SEP.match(line) for line in lines)
|
|
|
65
|
+ if not has_sep:
|
|
|
66
|
+ return False
|
|
|
67
|
+ return all("|" in line for line in lines)
|
|
|
68
|
+
|
|
|
69
|
+
|
|
|
70
|
+def _parse_md_table(text: str) -> list[list[str]]:
|
|
|
71
|
+ lines = text.strip().splitlines()
|
|
|
72
|
+ rows: list[list[str]] = []
|
|
|
73
|
+ for line in lines:
|
|
|
74
|
+ if MD_TABLE_SEP.match(line):
|
|
|
75
|
+ continue
|
|
|
76
|
+ cells = [c.strip() for c in line.split("|")[1:-1]]
|
|
|
77
|
+ rows.append(cells)
|
|
|
78
|
+ return rows
|
|
|
79
|
+
|
|
|
80
|
+
|
|
|
81
|
+def _md_table_to_html(text: str) -> str:
|
|
|
82
|
+ rows = _parse_md_table(text)
|
|
|
83
|
+ if not rows:
|
|
|
84
|
+ return ""
|
|
|
85
|
+ html_rows: list[str] = []
|
|
|
86
|
+ for i, row in enumerate(rows):
|
|
|
87
|
+ tag = "th" if i == 0 or not html_rows else "td"
|
|
|
88
|
+ cells = "".join(f"<{tag}>{c}</{tag}>" for c in row)
|
|
|
89
|
+ html_rows.append(f"<tr>{cells}</tr>")
|
|
|
90
|
+ return "<table>\n" + "\n".join(html_rows) + "\n</table>"
|
|
|
91
|
+
|
|
|
92
|
+
|
|
|
93
|
+def _md_table_to_latex(text: str) -> str:
|
|
|
94
|
+ rows = _parse_md_table(text)
|
|
|
95
|
+ if not rows:
|
|
|
96
|
+ return ""
|
|
|
97
|
+ ncols = max(len(row) for row in rows)
|
|
|
98
|
+ lines = [f"\\begin{{tabular}}{{|{'c|' * ncols}}}", "\\hline"]
|
|
|
99
|
+ for row in rows:
|
|
|
100
|
+ padded = row + [""] * (ncols - len(row))
|
|
|
101
|
+ lines.append(" & ".join(padded) + " \\\\")
|
|
|
102
|
+ lines.append("\\hline")
|
|
|
103
|
+ lines.append("\\end{tabular}")
|
|
|
104
|
+ return "\n".join(lines)
|
|
18
|
105
|
|
|
19
|
106
|
|
|
20
|
107
|
def _detect_format(data: dict) -> str:
|
|
|
@@ -22,16 +109,20 @@ def _detect_format(data: dict) -> str:
|
|
22
|
109
|
first = data["blocks"][0] if data["blocks"] else {}
|
|
23
|
110
|
if "html" in first:
|
|
24
|
111
|
return "surya"
|
|
|
112
|
+ if "content" in first:
|
|
|
113
|
+ return "qwen"
|
|
25
|
114
|
if "parsing_res_list" in data:
|
|
26
|
115
|
return "paddle"
|
|
27
|
|
- raise ValueError("Неизвестный формат JSON: ожидается PaddleOCR или Surya")
|
|
|
116
|
+ raise ValueError("Неизвестный формат JSON: ожидается PaddleOCR, Surya или Qwen")
|
|
28
|
117
|
|
|
29
|
118
|
|
|
30
|
|
-def _escape(text: str) -> str:
|
|
31
|
|
- for char, repl in [("\\", "\\textbackslash{}"), ("&", "\\&"), ("%", "\\%"),
|
|
32
|
|
- ("$", "\\$"), ("#", "\\#"), ("_", "\\_"),
|
|
33
|
|
- ("{", "\\{"), ("}", "\\}"), ("~", "\\textasciitilde{}"),
|
|
34
|
|
- ("^", "\\^{}"), ("<", "\\textless{}"), (">", "\\textgreater{}")]:
|
|
|
119
|
+def _escape_latex(text: str) -> str:
|
|
|
120
|
+ for char, repl in [
|
|
|
121
|
+ ("\\", "\\textbackslash{}"), ("&", "\\&"), ("%", "\\%"),
|
|
|
122
|
+ ("$", "\\$"), ("#", "\\#"), ("_", "\\_"),
|
|
|
123
|
+ ("{", "\\{"), ("}", "\\}"), ("~", "\\textasciitilde{}"),
|
|
|
124
|
+ ("^", "\\^{}"), ("<", "\\textless{}"), (">", "\\textgreater{}"),
|
|
|
125
|
+ ]:
|
|
35
|
126
|
text = text.replace(char, repl)
|
|
36
|
127
|
return text
|
|
37
|
128
|
|
|
|
@@ -47,11 +138,11 @@ def _paddle_to_body(data: dict) -> str:
|
|
47
|
138
|
if label == "formula":
|
|
48
|
139
|
lines.append(f"\\[\n{content}\n\\]\n")
|
|
49
|
140
|
elif label in ("paragraph_title", "title", "section_header"):
|
|
50
|
|
- lines.append(f"\\section*{{{_escape(content)}}}")
|
|
|
141
|
+ lines.append(f"\\section*{{{_escape_latex(content)}}}")
|
|
51
|
142
|
elif label == "text":
|
|
52
|
|
- lines.append(f"\n{_escape(content)}\n")
|
|
|
143
|
+ lines.append(f"\n{_escape_latex(content)}\n")
|
|
53
|
144
|
else:
|
|
54
|
|
- lines.append(f"\n{_escape(content)}\n")
|
|
|
145
|
+ lines.append(f"\n{_escape_latex(content)}\n")
|
|
55
|
146
|
return "\n".join(lines)
|
|
56
|
147
|
|
|
57
|
148
|
|
|
|
@@ -93,6 +184,30 @@ def _surya_to_body(data: dict) -> str:
|
|
93
|
184
|
return "\n".join(lines)
|
|
94
|
185
|
|
|
95
|
186
|
|
|
|
187
|
+def _qwen_to_body(data: dict) -> str:
|
|
|
188
|
+ lines: list[str] = []
|
|
|
189
|
+ for b in data.get("blocks", []):
|
|
|
190
|
+ content = b.get("content", "").strip()
|
|
|
191
|
+ if not content:
|
|
|
192
|
+ continue
|
|
|
193
|
+ label = b.get("label", "text")
|
|
|
194
|
+
|
|
|
195
|
+ if label == "formula":
|
|
|
196
|
+ formula = content.strip()
|
|
|
197
|
+ if (formula.startswith("$$") and formula.endswith("$$")) or \
|
|
|
198
|
+ (formula.startswith("\\[") and formula.endswith("\\]")):
|
|
|
199
|
+ formula = formula[2:-2].strip()
|
|
|
200
|
+ lines.append(f"\\[\n{formula}\n\\]\n")
|
|
|
201
|
+ elif label == "SectionHeader":
|
|
|
202
|
+ heading = MD_HEADING.sub("", content).strip()
|
|
|
203
|
+ lines.append(f"\\section*{{{_escape_latex(heading)}}}")
|
|
|
204
|
+ elif _is_md_table(content):
|
|
|
205
|
+ lines.append(f"\n{_md_table_to_latex(content)}\n")
|
|
|
206
|
+ else:
|
|
|
207
|
+ lines.append(f"\n{content}\n")
|
|
|
208
|
+ return "\n".join(lines)
|
|
|
209
|
+
|
|
|
210
|
+
|
|
96
|
211
|
def _table_to_latex(soup: BeautifulSoup) -> str:
|
|
97
|
212
|
rows = soup.find_all("tr")
|
|
98
|
213
|
if not rows:
|
|
|
@@ -113,10 +228,12 @@ def json_to_latex(json_path: str | Path) -> str:
|
|
113
|
228
|
|
|
114
|
229
|
if fmt == "paddle":
|
|
115
|
230
|
body = _paddle_to_body(data)
|
|
116
|
|
- else:
|
|
|
231
|
+ elif fmt == "surya":
|
|
117
|
232
|
body = _surya_to_body(data)
|
|
|
233
|
+ else:
|
|
|
234
|
+ body = _qwen_to_body(data)
|
|
118
|
235
|
|
|
119
|
|
- return LATEX_PREAMBLE + body + "\n\\end{document}\n"
|
|
|
236
|
+ return LATEX_PREAMBLE + LATEX_BEGIN + body + LATEX_END
|
|
120
|
237
|
|
|
121
|
238
|
|
|
122
|
239
|
def multi_json_to_latex(json_dir: str | Path, output_path: str | Path | None = None) -> str:
|
|
|
@@ -127,8 +244,8 @@ def multi_json_to_latex(json_dir: str | Path, output_path: str | Path | None = N
|
|
127
|
244
|
continue
|
|
128
|
245
|
try:
|
|
129
|
246
|
body = json_to_latex(jp)
|
|
130
|
|
- start = body.find("\\begin{document}") + len("\\begin{document}")
|
|
131
|
|
- end = body.find("\\end{document}")
|
|
|
247
|
+ start = body.find(LATEX_BEGIN) + len(LATEX_BEGIN)
|
|
|
248
|
+ end = body.find(LATEX_END)
|
|
132
|
249
|
pages.append(f"% {jp.stem}\n{body[start:end].strip()}\n\\newpage")
|
|
133
|
250
|
except (json.JSONDecodeError, KeyError, ValueError):
|
|
134
|
251
|
continue
|
|
|
@@ -138,9 +255,124 @@ def multi_json_to_latex(json_dir: str | Path, output_path: str | Path | None = N
|
|
138
|
255
|
|
|
139
|
256
|
first_path = min(json_dir.glob("[!.]*.json"), key=lambda p: p.stem)
|
|
140
|
257
|
first = json_to_latex(first_path)
|
|
141
|
|
- preamble_end = first.find("\\begin{document}") + len("\\begin{document}")
|
|
142
|
|
- full = first[:preamble_end] + "\n" + "\n".join(pages) + "\n\\end{document}\n"
|
|
|
258
|
+ preamble_end = first.find(LATEX_BEGIN) + len(LATEX_BEGIN)
|
|
|
259
|
+ full = first[:preamble_end] + "\n" + "\n".join(pages) + LATEX_END
|
|
143
|
260
|
|
|
144
|
261
|
if output_path:
|
|
145
|
262
|
Path(output_path).write_text(full, encoding="utf-8")
|
|
146
|
263
|
return full
|
|
|
264
|
+
|
|
|
265
|
+
|
|
|
266
|
+def _json_block_to_html(block: dict, fmt: str) -> str:
|
|
|
267
|
+ if fmt == "qwen":
|
|
|
268
|
+ label = block.get("label", "text")
|
|
|
269
|
+ content = block.get("content", "").strip()
|
|
|
270
|
+ if not content:
|
|
|
271
|
+ return ""
|
|
|
272
|
+ if label == "formula":
|
|
|
273
|
+ clean = content.strip()
|
|
|
274
|
+ for prefix, suffix in [("$$", "$$"), ("\\[", "\\]")]:
|
|
|
275
|
+ if clean.startswith(prefix) and clean.endswith(suffix):
|
|
|
276
|
+ clean = clean[len(prefix):-len(suffix)].strip()
|
|
|
277
|
+ return f'\n<div class="equation">$$\n{clean}\n$$</div>'
|
|
|
278
|
+ if label == "SectionHeader":
|
|
|
279
|
+ heading = MD_HEADING.sub("", content).strip()
|
|
|
280
|
+ return f"<h2>{heading}</h2>"
|
|
|
281
|
+ if _is_md_table(content):
|
|
|
282
|
+ return _md_table_to_html(content)
|
|
|
283
|
+ return f"<p>{content}</p>"
|
|
|
284
|
+
|
|
|
285
|
+ if fmt == "paddle":
|
|
|
286
|
+ content = block.get("block_content", "").strip()
|
|
|
287
|
+ if not content:
|
|
|
288
|
+ return ""
|
|
|
289
|
+ label = block.get("block_label", "text")
|
|
|
290
|
+ if label == "formula":
|
|
|
291
|
+ return f'\n<div class="equation">$$\n{content}\n$$</div>'
|
|
|
292
|
+ if label in ("paragraph_title", "title", "section_header"):
|
|
|
293
|
+ return f"<h2>{content}</h2>"
|
|
|
294
|
+ return f"<p>{content}</p>"
|
|
|
295
|
+
|
|
|
296
|
+ if fmt == "surya":
|
|
|
297
|
+ label = block.get("label", "")
|
|
|
298
|
+ raw_html = block.get("html", "").strip()
|
|
|
299
|
+ if not raw_html:
|
|
|
300
|
+ return ""
|
|
|
301
|
+ if label == "Equation":
|
|
|
302
|
+ clean = raw_html.replace('<math display="block">', "").replace("</math>", "").strip()
|
|
|
303
|
+ return f'\n<div class="equation">$$\n{clean}\n$$</div>'
|
|
|
304
|
+ soup = BeautifulSoup(raw_html, "html.parser")
|
|
|
305
|
+ for math_tag in soup.find_all("math"):
|
|
|
306
|
+ display = math_tag.get("display", "") == "block"
|
|
|
307
|
+ latex = math_tag.get_text().strip()
|
|
|
308
|
+ math_tag.replace_with(f"$$\n{latex}\n$$" if display else f"${latex}$")
|
|
|
309
|
+ text = " ".join(soup.stripped_strings)
|
|
|
310
|
+ if not text:
|
|
|
311
|
+ return ""
|
|
|
312
|
+ if label in ("SectionHeader",):
|
|
|
313
|
+ return f"<h2>{text}</h2>"
|
|
|
314
|
+ if label == "PageHeader":
|
|
|
315
|
+ return f'<p class="header">{text}</p>'
|
|
|
316
|
+ return f"<p>{text}</p>"
|
|
|
317
|
+ return ""
|
|
|
318
|
+
|
|
|
319
|
+
|
|
|
320
|
+def json_to_html(json_path: str | Path) -> str:
|
|
|
321
|
+ data = json.loads(Path(json_path).read_text())
|
|
|
322
|
+ fmt = _detect_format(data)
|
|
|
323
|
+ title = Path(json_path).stem
|
|
|
324
|
+
|
|
|
325
|
+ blocks = data.get("blocks", data.get("parsing_res_list", []))
|
|
|
326
|
+ html_parts = [_json_block_to_html(b, fmt) for b in blocks]
|
|
|
327
|
+ body = "\n".join(p for p in html_parts if p)
|
|
|
328
|
+
|
|
|
329
|
+ return HTML_TEMPLATE.format(title=title, body=body)
|
|
|
330
|
+
|
|
|
331
|
+
|
|
|
332
|
+def _generate_all(json_dir: Path) -> None:
|
|
|
333
|
+ dirname = json_dir.name or json_dir.parent.name
|
|
|
334
|
+ json_dir = json_dir.resolve()
|
|
|
335
|
+ tex_path = json_dir / f"{dirname}.tex"
|
|
|
336
|
+ html_path = json_dir / f"{dirname}.html"
|
|
|
337
|
+
|
|
|
338
|
+ _ = multi_json_to_latex(json_dir, tex_path)
|
|
|
339
|
+ print(f"LaTeX: {tex_path}")
|
|
|
340
|
+
|
|
|
341
|
+ pages: list[str] = []
|
|
|
342
|
+ for jp in sorted(json_dir.glob("[!.]*.json")):
|
|
|
343
|
+ if jp.stem == dirname:
|
|
|
344
|
+ continue
|
|
|
345
|
+ try:
|
|
|
346
|
+ page_html = json_to_html(jp)
|
|
|
347
|
+ inner = page_html[page_html.find("<body>") + 6:page_html.find("</body>")]
|
|
|
348
|
+ pages.append(f'<div class="page-sep">{jp.stem}</div>\n<hr>\n{inner}')
|
|
|
349
|
+ except (json.JSONDecodeError, KeyError, ValueError):
|
|
|
350
|
+ continue
|
|
|
351
|
+
|
|
|
352
|
+ if pages:
|
|
|
353
|
+ merged = HTML_TEMPLATE.format(title=dirname, body="\n".join(pages))
|
|
|
354
|
+ html_path.write_text(merged, encoding="utf-8")
|
|
|
355
|
+ print(f"HTML: {html_path}")
|
|
|
356
|
+
|
|
|
357
|
+
|
|
|
358
|
+def main() -> None:
|
|
|
359
|
+ if len(sys.argv) < 2:
|
|
|
360
|
+ print("Usage: python -m src.latex.json_to_latex <file.json | dir/>", file=sys.stderr)
|
|
|
361
|
+ sys.exit(1)
|
|
|
362
|
+
|
|
|
363
|
+ input_path = Path(sys.argv[1]).resolve()
|
|
|
364
|
+
|
|
|
365
|
+ if input_path.is_dir():
|
|
|
366
|
+ _generate_all(input_path)
|
|
|
367
|
+ else:
|
|
|
368
|
+ output = input_path.with_suffix(".tex")
|
|
|
369
|
+ output.write_text(json_to_latex(input_path), encoding="utf-8")
|
|
|
370
|
+ print(f"LaTeX: {output}")
|
|
|
371
|
+
|
|
|
372
|
+ html_out = input_path.with_suffix(".html")
|
|
|
373
|
+ html_out.write_text(json_to_html(input_path), encoding="utf-8")
|
|
|
374
|
+ print(f"HTML: {html_out}")
|
|
|
375
|
+
|
|
|
376
|
+
|
|
|
377
|
+if __name__ == "__main__":
|
|
|
378
|
+ main()
|