ar2md 0.1.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.
- ar2md/__init__.py +3 -0
- ar2md/arxiv.lua +277 -0
- ar2md/cli.py +380 -0
- ar2md-0.1.0.dist-info/METADATA +143 -0
- ar2md-0.1.0.dist-info/RECORD +8 -0
- ar2md-0.1.0.dist-info/WHEEL +4 -0
- ar2md-0.1.0.dist-info/entry_points.txt +2 -0
- ar2md-0.1.0.dist-info/licenses/LICENSE +21 -0
ar2md/__init__.py
ADDED
ar2md/arxiv.lua
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
local referenced_ids = {}
|
|
2
|
+
local algorithm_sources = {}
|
|
3
|
+
|
|
4
|
+
local function has_class(element, class_name)
|
|
5
|
+
for _, class in ipairs(element.classes or {}) do
|
|
6
|
+
if class == class_name then
|
|
7
|
+
return true
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
return false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
local function anchor_block(identifier)
|
|
14
|
+
return pandoc.RawBlock("html", '<a id="' .. identifier .. '"></a>')
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
local function anchor_inline(identifier)
|
|
18
|
+
return pandoc.RawInline("markdown", '<a id="' .. identifier .. '"></a>')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
local function referenced_anchor_block(identifier)
|
|
22
|
+
if identifier ~= "" and referenced_ids[identifier] then
|
|
23
|
+
return anchor_block(identifier)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
local function internal_identifier(target)
|
|
28
|
+
local fragment = target:match("^#(.+)$")
|
|
29
|
+
if fragment then
|
|
30
|
+
return fragment
|
|
31
|
+
end
|
|
32
|
+
if target:match("^%a[%w+%.%-]*:") or target:match("^//") then
|
|
33
|
+
return nil
|
|
34
|
+
end
|
|
35
|
+
return target:match("%.html#(.+)$")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
local function rewrite_link(link)
|
|
40
|
+
if link.target:match("^data:") or link.target:match("^javascript:") then
|
|
41
|
+
return {}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
local identifier = internal_identifier(link.target)
|
|
45
|
+
if identifier then
|
|
46
|
+
referenced_ids[identifier] = true
|
|
47
|
+
link.target = "#" .. identifier
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
link.title = ""
|
|
51
|
+
link.identifier = ""
|
|
52
|
+
link.classes = {}
|
|
53
|
+
link.attributes = {}
|
|
54
|
+
return link
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
local function collect_references(document)
|
|
58
|
+
document:walk({
|
|
59
|
+
Link = function(link)
|
|
60
|
+
local identifier = internal_identifier(link.target)
|
|
61
|
+
if identifier then
|
|
62
|
+
referenced_ids[identifier] = true
|
|
63
|
+
end
|
|
64
|
+
end,
|
|
65
|
+
})
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
local function collect_algorithm_sources(document)
|
|
69
|
+
document:walk({
|
|
70
|
+
Figure = function(figure)
|
|
71
|
+
if not figure.identifier:match("^alg%d+$") then
|
|
72
|
+
return
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
figure:walk({
|
|
76
|
+
Link = function(link)
|
|
77
|
+
if link.target:match("^data:text/plain") then
|
|
78
|
+
local _, contents = pandoc.mediabag.fetch(link.target)
|
|
79
|
+
if contents then
|
|
80
|
+
algorithm_sources[figure.identifier] = contents
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end,
|
|
84
|
+
})
|
|
85
|
+
end,
|
|
86
|
+
})
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
local function clean_div(div)
|
|
90
|
+
local content = div.content
|
|
91
|
+
local anchor = referenced_anchor_block(div.identifier)
|
|
92
|
+
if anchor then
|
|
93
|
+
table.insert(content, 1, anchor)
|
|
94
|
+
end
|
|
95
|
+
return content
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
local function clean_span(span)
|
|
99
|
+
if has_class(span, "ltx_ERROR") or has_class(span, "ltx_bib_cited") then
|
|
100
|
+
return {}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if has_class(span, "ltx_role_footnotemark") then
|
|
104
|
+
local mark = pandoc.utils.stringify(span.content):match("%d")
|
|
105
|
+
return pandoc.Superscript({ pandoc.Str(mark or "*") })
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if has_class(span, "ltx_role_footnote") then
|
|
109
|
+
local note_content = span.content
|
|
110
|
+
while note_content[1] and note_content[1].tag == "Superscript" do
|
|
111
|
+
table.remove(note_content, 1)
|
|
112
|
+
end
|
|
113
|
+
if note_content[1] and note_content[1].tag == "Str" and note_content[1].text:match("^%d+$") then
|
|
114
|
+
table.remove(note_content, 1)
|
|
115
|
+
end
|
|
116
|
+
return pandoc.Note({ pandoc.Para(note_content) })
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
local content = span.content
|
|
120
|
+
if has_class(span, "ltx_font_bold") then
|
|
121
|
+
content = { pandoc.Strong(content) }
|
|
122
|
+
elseif has_class(span, "ltx_font_italic") then
|
|
123
|
+
content = { pandoc.Emph(content) }
|
|
124
|
+
elseif has_class(span, "ltx_font_typewriter") then
|
|
125
|
+
content = { pandoc.Code(pandoc.utils.stringify(content)) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
if span.identifier ~= "" and referenced_ids[span.identifier] then
|
|
129
|
+
table.insert(content, 1, anchor_inline(span.identifier))
|
|
130
|
+
end
|
|
131
|
+
return content
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
local function clean_header(header)
|
|
135
|
+
local identifier = header.identifier
|
|
136
|
+
header.identifier = ""
|
|
137
|
+
header.classes = {}
|
|
138
|
+
header.attributes = {}
|
|
139
|
+
|
|
140
|
+
local anchor = referenced_anchor_block(identifier)
|
|
141
|
+
if anchor then
|
|
142
|
+
return { anchor, header }
|
|
143
|
+
end
|
|
144
|
+
return header
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
local function clean_code(code)
|
|
148
|
+
code.identifier = ""
|
|
149
|
+
code.classes = {}
|
|
150
|
+
code.attributes = {}
|
|
151
|
+
return code
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
local function clean_code_block(code_block)
|
|
155
|
+
code_block.identifier = ""
|
|
156
|
+
code_block.classes = {}
|
|
157
|
+
code_block.attributes = {}
|
|
158
|
+
return code_block
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
local function clean_figure(figure)
|
|
162
|
+
local blocks = {}
|
|
163
|
+
local caption_text = pandoc.utils.stringify(figure.caption.long)
|
|
164
|
+
if caption_text ~= "" then
|
|
165
|
+
figure = figure:walk({
|
|
166
|
+
Image = function(image)
|
|
167
|
+
local alt_text = pandoc.utils.stringify(image.caption)
|
|
168
|
+
if alt_text == "" or alt_text == "Refer to caption" then
|
|
169
|
+
image.caption = { pandoc.Str(caption_text) }
|
|
170
|
+
end
|
|
171
|
+
return image
|
|
172
|
+
end,
|
|
173
|
+
})
|
|
174
|
+
end
|
|
175
|
+
local anchor = referenced_anchor_block(figure.identifier)
|
|
176
|
+
if anchor then
|
|
177
|
+
table.insert(blocks, anchor)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
local algorithm_source = algorithm_sources[figure.identifier]
|
|
181
|
+
if algorithm_source then
|
|
182
|
+
table.insert(blocks, pandoc.CodeBlock(algorithm_source))
|
|
183
|
+
else
|
|
184
|
+
for _, block in ipairs(figure.content) do
|
|
185
|
+
table.insert(blocks, block)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
for _, block in ipairs(figure.caption.long) do
|
|
189
|
+
table.insert(blocks, block)
|
|
190
|
+
end
|
|
191
|
+
return blocks
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
local function clean_image(image)
|
|
195
|
+
image.identifier = ""
|
|
196
|
+
image.classes = {}
|
|
197
|
+
image.attributes = {}
|
|
198
|
+
image.title = ""
|
|
199
|
+
|
|
200
|
+
if image.src:match("^data:image/") then
|
|
201
|
+
return {}
|
|
202
|
+
end
|
|
203
|
+
return image
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
local function clean_table(table_element)
|
|
207
|
+
local identifier = table_element.identifier
|
|
208
|
+
|
|
209
|
+
if has_class(table_element, "ltx_equation") then
|
|
210
|
+
local equation
|
|
211
|
+
local equation_number
|
|
212
|
+
|
|
213
|
+
table_element:walk({
|
|
214
|
+
Math = function(math)
|
|
215
|
+
equation = equation or math
|
|
216
|
+
end,
|
|
217
|
+
Str = function(text)
|
|
218
|
+
equation_number = equation_number or text.text:match("^%((%d+)%)$")
|
|
219
|
+
end,
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
local blocks = {}
|
|
223
|
+
if identifier ~= "" then
|
|
224
|
+
table.insert(blocks, anchor_block(identifier))
|
|
225
|
+
end
|
|
226
|
+
if equation then
|
|
227
|
+
equation.mathtype = "DisplayMath"
|
|
228
|
+
table.insert(blocks, pandoc.Para({ equation }))
|
|
229
|
+
end
|
|
230
|
+
if equation_number then
|
|
231
|
+
table.insert(blocks, pandoc.Para({ pandoc.Str("Equation (" .. equation_number .. ")") }))
|
|
232
|
+
end
|
|
233
|
+
return blocks
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
table_element.identifier = ""
|
|
237
|
+
table_element.classes = {}
|
|
238
|
+
table_element.attributes = {}
|
|
239
|
+
|
|
240
|
+
local anchor = referenced_anchor_block(identifier)
|
|
241
|
+
if anchor then
|
|
242
|
+
return { anchor, table_element }
|
|
243
|
+
end
|
|
244
|
+
return table_element
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
function Pandoc(document)
|
|
248
|
+
local article_blocks
|
|
249
|
+
|
|
250
|
+
document:walk({
|
|
251
|
+
Div = function(div)
|
|
252
|
+
if has_class(div, "ltx_page_content") then
|
|
253
|
+
article_blocks = div.content
|
|
254
|
+
end
|
|
255
|
+
end,
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
if not article_blocks then
|
|
259
|
+
error("arXiv article container (.ltx_page_content) not found")
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
local article = pandoc.Pandoc(article_blocks, document.meta)
|
|
263
|
+
collect_references(article)
|
|
264
|
+
collect_algorithm_sources(article)
|
|
265
|
+
|
|
266
|
+
return article:walk({
|
|
267
|
+
Code = clean_code,
|
|
268
|
+
CodeBlock = clean_code_block,
|
|
269
|
+
Div = clean_div,
|
|
270
|
+
Header = clean_header,
|
|
271
|
+
Figure = clean_figure,
|
|
272
|
+
Image = clean_image,
|
|
273
|
+
Link = rewrite_link,
|
|
274
|
+
Span = clean_span,
|
|
275
|
+
Table = clean_table,
|
|
276
|
+
})
|
|
277
|
+
end
|
ar2md/cli.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from importlib.metadata import version
|
|
10
|
+
from html.parser import HTMLParser
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Sequence
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
ARXIV_ID_PATTERN = re.compile(
|
|
16
|
+
r"^(?:https?://(?:www\.)?arxiv\.org/(?:abs|html|pdf)/)?"
|
|
17
|
+
r"(?P<base>\d{4}\.\d{4,5})\.?v(?P<version>\d+)"
|
|
18
|
+
r"(?:\.pdf)?/?$"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _TitleParser(HTMLParser):
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
super().__init__()
|
|
25
|
+
self.in_title = False
|
|
26
|
+
self.parts: list[str] = []
|
|
27
|
+
|
|
28
|
+
def handle_starttag(
|
|
29
|
+
self,
|
|
30
|
+
tag: str,
|
|
31
|
+
attrs: list[tuple[str, str | None]],
|
|
32
|
+
) -> None:
|
|
33
|
+
if tag == "title":
|
|
34
|
+
self.in_title = True
|
|
35
|
+
|
|
36
|
+
def handle_endtag(self, tag: str) -> None:
|
|
37
|
+
if tag == "title":
|
|
38
|
+
self.in_title = False
|
|
39
|
+
|
|
40
|
+
def handle_data(self, data: str) -> None:
|
|
41
|
+
if self.in_title:
|
|
42
|
+
self.parts.append(data)
|
|
43
|
+
|
|
44
|
+
def title(self) -> str:
|
|
45
|
+
return " ".join("".join(self.parts).split())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_arxiv_id(value: str) -> str:
|
|
49
|
+
match = ARXIV_ID_PATTERN.fullmatch(value)
|
|
50
|
+
if not match:
|
|
51
|
+
raise argparse.ArgumentTypeError(
|
|
52
|
+
"expected a versioned arXiv ID or URL, for example 1706.03762v7"
|
|
53
|
+
)
|
|
54
|
+
return f"{match.group('base')}v{match.group('version')}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def require_tool(name: str) -> str:
|
|
58
|
+
configured = os.environ.get(name.upper(), name)
|
|
59
|
+
executable = shutil.which(configured)
|
|
60
|
+
if not executable:
|
|
61
|
+
raise SystemExit(
|
|
62
|
+
f"{name} is required but was not found; "
|
|
63
|
+
f"install it or set {name.upper()}=/path/to/{name}"
|
|
64
|
+
)
|
|
65
|
+
return executable
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run(command: list[str], *, cwd: Path) -> None:
|
|
69
|
+
subprocess.run(command, cwd=cwd, check=True)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _download_html(arxiv_id: str, host: str, wget: str, output_dir: Path) -> Path:
|
|
73
|
+
html_path = output_dir / host / "html" / f"{arxiv_id}.html"
|
|
74
|
+
url = f"https://{host}/html/{arxiv_id}"
|
|
75
|
+
completed = subprocess.run(
|
|
76
|
+
[
|
|
77
|
+
wget,
|
|
78
|
+
"--page-requisites",
|
|
79
|
+
"--convert-links",
|
|
80
|
+
"--adjust-extension",
|
|
81
|
+
"--no-parent",
|
|
82
|
+
url,
|
|
83
|
+
],
|
|
84
|
+
cwd=output_dir,
|
|
85
|
+
text=True,
|
|
86
|
+
capture_output=True,
|
|
87
|
+
)
|
|
88
|
+
if completed.returncode:
|
|
89
|
+
raise subprocess.CalledProcessError(
|
|
90
|
+
completed.returncode,
|
|
91
|
+
completed.args,
|
|
92
|
+
output=completed.stdout,
|
|
93
|
+
stderr=completed.stderr,
|
|
94
|
+
)
|
|
95
|
+
if not html_path.is_file():
|
|
96
|
+
raise SystemExit(f"wget completed without creating {html_path}")
|
|
97
|
+
|
|
98
|
+
sys.stdout.write(completed.stdout)
|
|
99
|
+
sys.stderr.write(completed.stderr)
|
|
100
|
+
return html_path
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _show_download_error(error: subprocess.CalledProcessError) -> None:
|
|
104
|
+
if error.stdout:
|
|
105
|
+
sys.stdout.write(error.stdout)
|
|
106
|
+
if error.stderr:
|
|
107
|
+
sys.stderr.write(error.stderr)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def fetch_html(arxiv_id: str, wget: str, output_dir: Path) -> Path:
|
|
111
|
+
arxiv_path = output_dir / "arxiv.org" / "html" / f"{arxiv_id}.html"
|
|
112
|
+
ar5iv_path = output_dir / "ar5iv.labs.arxiv.org" / "html" / f"{arxiv_id}.html"
|
|
113
|
+
for html_path in (arxiv_path, ar5iv_path):
|
|
114
|
+
if html_path.exists():
|
|
115
|
+
print(f"Using existing HTML: {html_path}")
|
|
116
|
+
return html_path
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
return _download_html(arxiv_id, "arxiv.org", wget, output_dir)
|
|
120
|
+
except subprocess.CalledProcessError as error:
|
|
121
|
+
if error.returncode != 8 or not re.search(r"(?:ERROR\s+|HTTP/\S+\s+)404\b", error.stderr or ""):
|
|
122
|
+
_show_download_error(error)
|
|
123
|
+
raise
|
|
124
|
+
|
|
125
|
+
print(
|
|
126
|
+
f"arXiv HTML is unavailable for {arxiv_id}; falling back to ar5iv.",
|
|
127
|
+
file=sys.stderr,
|
|
128
|
+
)
|
|
129
|
+
try:
|
|
130
|
+
return _download_html(arxiv_id, "ar5iv.labs.arxiv.org", wget, output_dir)
|
|
131
|
+
except subprocess.CalledProcessError as error:
|
|
132
|
+
_show_download_error(error)
|
|
133
|
+
raise
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def recover_foreign_object_content(source: str) -> tuple[str, int]:
|
|
137
|
+
svg_pattern = re.compile(
|
|
138
|
+
r'<svg\b[^>]*\bltx_picture\b[^>]*>.*?</svg>',
|
|
139
|
+
re.IGNORECASE | re.DOTALL,
|
|
140
|
+
)
|
|
141
|
+
foreign_object_pattern = re.compile(
|
|
142
|
+
r"<foreignobject\b[^>]*>(.*?)</foreignobject>",
|
|
143
|
+
re.IGNORECASE | re.DOTALL,
|
|
144
|
+
)
|
|
145
|
+
recovered_count = 0
|
|
146
|
+
|
|
147
|
+
def recover(svg_match: re.Match[str]) -> str:
|
|
148
|
+
nonlocal recovered_count
|
|
149
|
+
fragments = foreign_object_pattern.findall(svg_match.group(0))
|
|
150
|
+
if not fragments:
|
|
151
|
+
return svg_match.group(0)
|
|
152
|
+
recovered_count += 1
|
|
153
|
+
return "\n".join(fragments)
|
|
154
|
+
|
|
155
|
+
return svg_pattern.sub(recover, source), recovered_count
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def convert_html(html_path: Path, pandoc: str) -> Path:
|
|
159
|
+
output_path = html_path.with_suffix(".md")
|
|
160
|
+
temporary_path = output_path.with_suffix(".md.tmp")
|
|
161
|
+
prepared_path = html_path.with_suffix(".pandoc.html.tmp")
|
|
162
|
+
temporary_path.unlink(missing_ok=True)
|
|
163
|
+
prepared_path.unlink(missing_ok=True)
|
|
164
|
+
|
|
165
|
+
source_path = html_path
|
|
166
|
+
source = html_path.read_text(encoding="utf-8", errors="replace")
|
|
167
|
+
recovered_source, recovered_count = recover_foreign_object_content(source)
|
|
168
|
+
if recovered_count:
|
|
169
|
+
prepared_path.write_text(recovered_source, encoding="utf-8")
|
|
170
|
+
source_path = prepared_path
|
|
171
|
+
|
|
172
|
+
filter_resource = resources.files("ar2md").joinpath("arxiv.lua")
|
|
173
|
+
try:
|
|
174
|
+
with resources.as_file(filter_resource) as filter_path:
|
|
175
|
+
run(
|
|
176
|
+
[
|
|
177
|
+
pandoc,
|
|
178
|
+
source_path.name,
|
|
179
|
+
"--from=html",
|
|
180
|
+
"--to=markdown+pipe_tables+tex_math_dollars",
|
|
181
|
+
"--wrap=none",
|
|
182
|
+
f"--lua-filter={filter_path}",
|
|
183
|
+
f"--output={temporary_path.name}",
|
|
184
|
+
],
|
|
185
|
+
cwd=html_path.parent,
|
|
186
|
+
)
|
|
187
|
+
if not temporary_path.is_file() or temporary_path.stat().st_size == 0:
|
|
188
|
+
raise SystemExit("pandoc did not produce a non-empty Markdown file")
|
|
189
|
+
temporary_path.replace(output_path)
|
|
190
|
+
finally:
|
|
191
|
+
temporary_path.unlink(missing_ok=True)
|
|
192
|
+
prepared_path.unlink(missing_ok=True)
|
|
193
|
+
|
|
194
|
+
return output_path
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def extract_title(html_path: Path, markdown_path: Path) -> str:
|
|
198
|
+
parser = _TitleParser()
|
|
199
|
+
parser.feed(html_path.read_text(encoding="utf-8", errors="replace"))
|
|
200
|
+
title = re.sub(r"^\[[^\]]+\]\s*", "", parser.title())
|
|
201
|
+
if title:
|
|
202
|
+
return title
|
|
203
|
+
|
|
204
|
+
markdown = markdown_path.read_text(encoding="utf-8")
|
|
205
|
+
heading = re.search(r"^#\s+(.+?)\s*$", markdown, re.MULTILINE)
|
|
206
|
+
if heading:
|
|
207
|
+
return heading.group(1)
|
|
208
|
+
raise SystemExit(f"could not determine the paper title from {html_path}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def inspect_markdown(markdown_path: Path) -> list[dict[str, int]]:
|
|
212
|
+
markdown = markdown_path.read_text(encoding="utf-8")
|
|
213
|
+
warnings: list[dict[str, int]] = []
|
|
214
|
+
|
|
215
|
+
citation_placeholders = len(re.findall(r"\*{1,3}\?\*{1,3}", markdown))
|
|
216
|
+
if citation_placeholders:
|
|
217
|
+
warnings.append(
|
|
218
|
+
{
|
|
219
|
+
"code": "unresolved_citation_placeholders",
|
|
220
|
+
"count": citation_placeholders,
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
internal_targets = set(re.findall(r"\]\(#([^)]+)\)", markdown))
|
|
225
|
+
explicit_anchors = set(re.findall(r'<a id="([^"]+)"></a>', markdown))
|
|
226
|
+
unresolved_targets = internal_targets - explicit_anchors
|
|
227
|
+
if unresolved_targets:
|
|
228
|
+
warnings.append(
|
|
229
|
+
{
|
|
230
|
+
"code": "unresolved_internal_links",
|
|
231
|
+
"count": len(unresolved_targets),
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
nested_math = len(re.findall(r"\$\$\s*\$", markdown))
|
|
236
|
+
if nested_math:
|
|
237
|
+
warnings.append(
|
|
238
|
+
{
|
|
239
|
+
"code": "nested_math_delimiters",
|
|
240
|
+
"count": nested_math,
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
image_targets = re.findall(r"!\[[^\]]*\]\(([^)]+)\)", markdown)
|
|
245
|
+
missing_images = {
|
|
246
|
+
target
|
|
247
|
+
for target in image_targets
|
|
248
|
+
if not re.match(r"^(?:https?://|data:)", target)
|
|
249
|
+
and not (markdown_path.parent / target).is_file()
|
|
250
|
+
}
|
|
251
|
+
if missing_images:
|
|
252
|
+
warnings.append(
|
|
253
|
+
{
|
|
254
|
+
"code": "missing_local_images",
|
|
255
|
+
"count": len(missing_images),
|
|
256
|
+
}
|
|
257
|
+
)
|
|
258
|
+
return warnings
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def report_warnings(warnings: list[dict[str, int]]) -> None:
|
|
262
|
+
messages = {
|
|
263
|
+
"unresolved_citation_placeholders": (
|
|
264
|
+
"unresolved citation placeholders; the source HTML likely "
|
|
265
|
+
"contains damaged citations"
|
|
266
|
+
),
|
|
267
|
+
"unresolved_internal_links": "unresolved internal link targets",
|
|
268
|
+
"nested_math_delimiters": "nested display-math delimiters",
|
|
269
|
+
"missing_local_images": "missing local image targets",
|
|
270
|
+
}
|
|
271
|
+
for warning in warnings:
|
|
272
|
+
print(
|
|
273
|
+
f"Warning: {warning['count']} {messages[warning['code']]}.",
|
|
274
|
+
file=sys.stderr,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def update_index(
|
|
279
|
+
output_dir: Path,
|
|
280
|
+
arxiv_id: str,
|
|
281
|
+
title: str,
|
|
282
|
+
html_path: Path,
|
|
283
|
+
markdown_path: Path,
|
|
284
|
+
warnings: list[dict[str, int]],
|
|
285
|
+
) -> Path:
|
|
286
|
+
index_path = output_dir / "index.jsonl"
|
|
287
|
+
records: dict[str, dict[str, object]] = {}
|
|
288
|
+
if index_path.exists():
|
|
289
|
+
for line_number, line in enumerate(
|
|
290
|
+
index_path.read_text(encoding="utf-8").splitlines(),
|
|
291
|
+
start=1,
|
|
292
|
+
):
|
|
293
|
+
if not line.strip():
|
|
294
|
+
continue
|
|
295
|
+
try:
|
|
296
|
+
record = json.loads(line)
|
|
297
|
+
except json.JSONDecodeError as error:
|
|
298
|
+
raise SystemExit(
|
|
299
|
+
f"invalid JSON in {index_path} at line {line_number}: {error.msg}"
|
|
300
|
+
) from error
|
|
301
|
+
if not isinstance(record, dict) or not isinstance(record.get("id"), str):
|
|
302
|
+
raise SystemExit(
|
|
303
|
+
f"invalid record in {index_path} at line {line_number}: "
|
|
304
|
+
'expected an object with a string "id"'
|
|
305
|
+
)
|
|
306
|
+
records[record["id"]] = record
|
|
307
|
+
|
|
308
|
+
relative_html = html_path.relative_to(output_dir)
|
|
309
|
+
relative_markdown = markdown_path.relative_to(output_dir)
|
|
310
|
+
host = relative_html.parts[0]
|
|
311
|
+
records[arxiv_id] = {
|
|
312
|
+
"id": arxiv_id,
|
|
313
|
+
"title": title,
|
|
314
|
+
"source": f"https://{host}/html/{arxiv_id}",
|
|
315
|
+
"html": relative_html.as_posix(),
|
|
316
|
+
"markdown": relative_markdown.as_posix(),
|
|
317
|
+
"warnings": warnings,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
temporary_path = index_path.with_suffix(".jsonl.tmp")
|
|
321
|
+
temporary_path.unlink(missing_ok=True)
|
|
322
|
+
try:
|
|
323
|
+
content = "".join(
|
|
324
|
+
json.dumps(records[record_id], ensure_ascii=False) + "\n"
|
|
325
|
+
for record_id in sorted(records)
|
|
326
|
+
)
|
|
327
|
+
temporary_path.write_text(content, encoding="utf-8")
|
|
328
|
+
temporary_path.replace(index_path)
|
|
329
|
+
finally:
|
|
330
|
+
temporary_path.unlink(missing_ok=True)
|
|
331
|
+
return index_path
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
335
|
+
parser = argparse.ArgumentParser(
|
|
336
|
+
prog="ar2md",
|
|
337
|
+
description="Download versioned arXiv HTML (with ar5iv fallback) and convert it to Markdown.",
|
|
338
|
+
)
|
|
339
|
+
parser.add_argument("paper", type=parse_arxiv_id, help="versioned arXiv ID or URL")
|
|
340
|
+
parser.add_argument(
|
|
341
|
+
"-o",
|
|
342
|
+
"--output-dir",
|
|
343
|
+
type=Path,
|
|
344
|
+
default=Path("ar2md"),
|
|
345
|
+
help="mirror root (default: ./ar2md)",
|
|
346
|
+
)
|
|
347
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {version('ar2md')}")
|
|
348
|
+
return parser
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
352
|
+
parser = build_parser()
|
|
353
|
+
args = parser.parse_args(argv)
|
|
354
|
+
|
|
355
|
+
wget = require_tool("wget")
|
|
356
|
+
pandoc = require_tool("pandoc")
|
|
357
|
+
output_dir = args.output_dir.expanduser().resolve()
|
|
358
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
html_path = fetch_html(args.paper, wget, output_dir)
|
|
362
|
+
markdown_path = convert_html(html_path, pandoc)
|
|
363
|
+
title = extract_title(html_path, markdown_path)
|
|
364
|
+
warnings = inspect_markdown(markdown_path)
|
|
365
|
+
report_warnings(warnings)
|
|
366
|
+
index_path = update_index(
|
|
367
|
+
output_dir,
|
|
368
|
+
args.paper,
|
|
369
|
+
title,
|
|
370
|
+
html_path,
|
|
371
|
+
markdown_path,
|
|
372
|
+
warnings,
|
|
373
|
+
)
|
|
374
|
+
except subprocess.CalledProcessError as error:
|
|
375
|
+
command = Path(error.cmd[0]).name
|
|
376
|
+
parser.exit(error.returncode or 1, f"ar2md: {command} failed\n")
|
|
377
|
+
|
|
378
|
+
print(f"HTML: {html_path}")
|
|
379
|
+
print(f"Markdown: {markdown_path}")
|
|
380
|
+
print(f"Index: {index_path}")
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ar2md
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Download arXiv HTML papers and convert them to LLM-ready Markdown
|
|
5
|
+
Project-URL: Homepage, https://github.com/cs-giung/ar2md
|
|
6
|
+
Project-URL: Repository, https://github.com/cs-giung/ar2md
|
|
7
|
+
Project-URL: Issues, https://github.com/cs-giung/ar2md/issues
|
|
8
|
+
Author: cs-giung
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: arxiv,llm,markdown,pandoc
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# ar2md
|
|
27
|
+
|
|
28
|
+
Download a versioned arXiv paper as HTML and convert it to LLM-ready Markdown with Pandoc. Native arXiv HTML is preferred; papers without it fall back to ar5iv.
|
|
29
|
+
|
|
30
|
+
## Requirements
|
|
31
|
+
|
|
32
|
+
- GNU Wget
|
|
33
|
+
- Pandoc 3.x
|
|
34
|
+
|
|
35
|
+
Set `WGET` or `PANDOC` to an executable path when either tool is not on `PATH`.
|
|
36
|
+
|
|
37
|
+
## Run without installing
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uvx ar2md 1706.03762v7
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Install as a uv tool
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv tool install ar2md
|
|
47
|
+
ar2md 1706.03762v7
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The PyPI distribution and installed executable are both named `ar2md`.
|
|
51
|
+
|
|
52
|
+
To run an unreleased commit directly from GitHub:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
uvx --from git+https://github.com/cs-giung/ar2md ar2md 1706.03762v7
|
|
56
|
+
uv tool install git+https://github.com/cs-giung/ar2md
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
A version is required so that the local reference is reproducible. For convenience, the common extra-dot spelling `1706.03762.v7` is accepted and normalized to `1706.03762v7`. Versioned arXiv `abs`, `html`, and `pdf` URLs are also accepted.
|
|
60
|
+
|
|
61
|
+
## Output
|
|
62
|
+
|
|
63
|
+
The default mirror root is `./ar2md`. Files retain their source hostname:
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
ar2md/
|
|
67
|
+
├── index.jsonl
|
|
68
|
+
├── arxiv.org/ # preferred source
|
|
69
|
+
│ ├── html/
|
|
70
|
+
│ │ ├── 1706.03762v7.html
|
|
71
|
+
│ │ ├── 1706.03762v7.md
|
|
72
|
+
│ │ └── 1706.03762v7/
|
|
73
|
+
│ └── static/
|
|
74
|
+
└── ar5iv.labs.arxiv.org/ # created only for fallback papers
|
|
75
|
+
├── html/
|
|
76
|
+
│ ├── 1503.02531v1.html
|
|
77
|
+
│ └── 1503.02531v1.md
|
|
78
|
+
└── assets/
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Choose another mirror root with `--output-dir`:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
ar2md 1706.03762v7 --output-dir papers
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
An existing HTML snapshot is reused, while Markdown is regenerated atomically on each invocation.
|
|
88
|
+
|
|
89
|
+
`index.jsonl` contains one JSON object per versioned paper ID. Records are sorted by ID and replaced, rather than duplicated, when a paper is regenerated. The index is written atomically after successful conversion:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{"id": "1207.0580v1", "title": "Improving neural networks by preventing co-adaptation of feature detectors", "source": "https://ar5iv.labs.arxiv.org/html/1207.0580v1", "html": "ar5iv.labs.arxiv.org/html/1207.0580v1.html", "markdown": "ar5iv.labs.arxiv.org/html/1207.0580v1.md", "warnings": [{"code": "unresolved_citation_placeholders", "count": 24}]}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The `html` and `markdown` paths are relative to the mirror root. The `source` field records whether the HTML came from arXiv or ar5iv. `warnings` is empty for a clean conversion.
|
|
96
|
+
|
|
97
|
+
After conversion, `ar2md` checks for unresolved citation placeholders, unresolved internal links, nested display-math delimiters, and missing local images. Findings are printed to stderr and stored as structured warning counts in the index. The tool does not fabricate content that is already missing from the source HTML.
|
|
98
|
+
|
|
99
|
+
## ar5iv fallback
|
|
100
|
+
|
|
101
|
+
The downloader first requests `https://arxiv.org/html/<versioned-id>`. An HTTP 404 triggers a request to `https://ar5iv.labs.arxiv.org/html/<versioned-id>`; network failures and other HTTP errors do not silently switch sources. For example:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
ar2md 1503.02531v1
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The resulting HTML and Markdown are stored under `ar2md/ar5iv.labs.arxiv.org/`, making the alternate provenance explicit. A cached fallback snapshot is reused on later runs. ar5iv is a LaTeXML conversion service rather than an arXiv archival endpoint, so retain the downloaded snapshot when exact reproducibility matters.
|
|
108
|
+
|
|
109
|
+
## Local development
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
uvx --from . ar2md 1706.03762v7
|
|
113
|
+
uv tool install .
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Conversion
|
|
117
|
+
|
|
118
|
+
The bundled Lua filter keeps the paper body while removing arXiv UI, preserves TeX math and figures, uses figure captions as descriptive image alt text, reconstructs algorithm code blocks, normalizes footnotes and equation blocks, and rewrites internal citations to valid local anchors.
|
|
119
|
+
|
|
120
|
+
## Publishing
|
|
121
|
+
|
|
122
|
+
Build and validate the distributions locally:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
uv lock --check
|
|
126
|
+
uv run python -m unittest discover -s tests -v
|
|
127
|
+
uv build
|
|
128
|
+
uvx twine check dist/*
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Publishing uses PyPI Trusted Publishing. In the PyPI project or pending-publisher settings, configure:
|
|
132
|
+
|
|
133
|
+
- PyPI project: `ar2md`
|
|
134
|
+
- GitHub owner: `cs-giung`
|
|
135
|
+
- GitHub repository: `ar2md`
|
|
136
|
+
- workflow: `publish.yml`
|
|
137
|
+
- environment: `pypi`
|
|
138
|
+
|
|
139
|
+
Then create a GitHub release from a tag matching `v<project.version>`, such as `v0.1.0`. The release workflow checks the tag against `pyproject.toml`, runs the test suite, validates both distributions, and publishes without a long-lived API token.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
ar2md/__init__.py,sha256=efZmQZqQLvUSTvkAKod-2zChpchSEOILMCkAYFsSp6g,55
|
|
2
|
+
ar2md/arxiv.lua,sha256=QR9zhbilUJ6AZpiHlf9X_nLsHVtKlBIb95o2UxrPF_4,6844
|
|
3
|
+
ar2md/cli.py,sha256=hns0amP7pDIbUF4Ytfa9lFiRqBcB-Xzmoi2TkCck_FU,12273
|
|
4
|
+
ar2md-0.1.0.dist-info/METADATA,sha256=Xtf5SQczIzoaoe5zH52QY50-RNVPO8Ue4GX9dBJ-E94,5427
|
|
5
|
+
ar2md-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
6
|
+
ar2md-0.1.0.dist-info/entry_points.txt,sha256=XEYdoLssJscmaljoga_jp6hJh3rFvHskNa-6qqKZQV8,41
|
|
7
|
+
ar2md-0.1.0.dist-info/licenses/LICENSE,sha256=WvkD_s0T9J_on1SO6LvImJe3ZwDYHZtoBop-feLusac,1065
|
|
8
|
+
ar2md-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cs-giung
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|