citegraph 0.2.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.
- citegraph/__init__.py +44 -0
- citegraph/_progress.py +74 -0
- citegraph/annotations.py +183 -0
- citegraph/author_export.py +294 -0
- citegraph/author_overrides.py +143 -0
- citegraph/authors.py +1997 -0
- citegraph/cli.py +926 -0
- citegraph/cocitation_stats.py +370 -0
- citegraph/config.py +73 -0
- citegraph/cost_estimation.py +259 -0
- citegraph/dedup.py +498 -0
- citegraph/enrich.py +972 -0
- citegraph/extract_metadata.py +81 -0
- citegraph/extract_references.py +376 -0
- citegraph/graph.py +660 -0
- citegraph/html_report.py +1787 -0
- citegraph/identity.py +199 -0
- citegraph/ids.py +106 -0
- citegraph/io.py +566 -0
- citegraph/jel.py +167 -0
- citegraph/journals.py +182 -0
- citegraph/llm.py +186 -0
- citegraph/pdf_to_markdown.py +394 -0
- citegraph/pipeline.py +997 -0
- citegraph/plotting.py +1214 -0
- citegraph/reports.py +214 -0
- citegraph/schemas.py +89 -0
- citegraph/webui.py +624 -0
- citegraph-0.2.0.dist-info/METADATA +870 -0
- citegraph-0.2.0.dist-info/RECORD +33 -0
- citegraph-0.2.0.dist-info/WHEEL +4 -0
- citegraph-0.2.0.dist-info/entry_points.txt +2 -0
- citegraph-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""PDF -> markdown conversion using docling, with idempotent caching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from citegraph.io import OutLayout, cache_input_fingerprint, read_json, write_json
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# ``ocr`` flag accepted by :func:`convert_directory` and the ``Pipeline``:
|
|
17
|
+
# ``False`` = never OCR, ``True`` = force OCR everywhere, ``"auto"`` = try
|
|
18
|
+
# without OCR first and only re-run the image-only outputs with OCR.
|
|
19
|
+
OCRMode = bool | Literal["auto"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def list_pdfs(pdf_dir: Path | str, *, recursive: bool = False) -> list[Path]:
|
|
23
|
+
"""Return all ``*.pdf`` files (case-insensitive) under ``pdf_dir``.
|
|
24
|
+
|
|
25
|
+
With ``recursive=False`` (default), looks at ``pdf_dir`` only — the
|
|
26
|
+
historical behaviour. With ``recursive=True``, walks the whole tree
|
|
27
|
+
but skips dotted directories (``.git``, ``.venv``, etc.) so that
|
|
28
|
+
pointing at a project root doesn't drag in third-party PDFs.
|
|
29
|
+
"""
|
|
30
|
+
pdf_dir = Path(pdf_dir)
|
|
31
|
+
if not pdf_dir.is_dir():
|
|
32
|
+
raise NotADirectoryError(f"PDF directory not found: {pdf_dir}")
|
|
33
|
+
|
|
34
|
+
if not recursive:
|
|
35
|
+
return sorted(p for p in pdf_dir.iterdir() if p.suffix.lower() == ".pdf")
|
|
36
|
+
|
|
37
|
+
found: list[Path] = []
|
|
38
|
+
for path in pdf_dir.rglob("*"):
|
|
39
|
+
if path.suffix.lower() != ".pdf" or not path.is_file():
|
|
40
|
+
continue
|
|
41
|
+
rel_parts = path.relative_to(pdf_dir).parts
|
|
42
|
+
if any(part.startswith(".") for part in rel_parts[:-1]):
|
|
43
|
+
continue
|
|
44
|
+
found.append(path)
|
|
45
|
+
return sorted(found)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cache_stem_for(pdf_path: Path | str, pdf_dir: Path | str) -> str:
|
|
49
|
+
"""Cache filename stem for ``pdf_path`` relative to ``pdf_dir``.
|
|
50
|
+
|
|
51
|
+
Flat layouts produce the historical ``pdf.stem`` (so existing caches
|
|
52
|
+
stay valid). Nested layouts encode the relative path — e.g.
|
|
53
|
+
``journal_X/foo.pdf`` becomes ``journal_X__foo`` — to keep two PDFs
|
|
54
|
+
with the same filename in different subdirectories from clobbering
|
|
55
|
+
each other's cache files.
|
|
56
|
+
"""
|
|
57
|
+
pdf_path = Path(pdf_path)
|
|
58
|
+
pdf_dir = Path(pdf_dir)
|
|
59
|
+
try:
|
|
60
|
+
rel = pdf_path.relative_to(pdf_dir)
|
|
61
|
+
except ValueError:
|
|
62
|
+
return pdf_path.stem
|
|
63
|
+
parts = list(rel.with_suffix("").parts)
|
|
64
|
+
# Strip filesystem separators that survived (defensive — Path.parts
|
|
65
|
+
# already splits them) and join with a double underscore.
|
|
66
|
+
sanitized = ["".join(c if c not in "/\\:" else "_" for c in p) for p in parts]
|
|
67
|
+
return "__".join(sanitized)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _detect_stem_collisions(pdfs: list[Path], pdf_dir: Path) -> None:
|
|
71
|
+
"""Raise if ``cache_stem_for`` would collide for two distinct PDFs."""
|
|
72
|
+
stems = [cache_stem_for(p, pdf_dir) for p in pdfs]
|
|
73
|
+
dupes = sorted(s for s, n in Counter(stems).items() if n > 1)
|
|
74
|
+
if not dupes:
|
|
75
|
+
return
|
|
76
|
+
examples = []
|
|
77
|
+
for d in dupes:
|
|
78
|
+
offenders = [
|
|
79
|
+
str(p.relative_to(pdf_dir)) for p, s in zip(pdfs, stems, strict=True) if s == d
|
|
80
|
+
]
|
|
81
|
+
examples.append(f" {d!r} <- {offenders}")
|
|
82
|
+
raise ValueError(
|
|
83
|
+
"Cache-key collisions in recursive scan:\n"
|
|
84
|
+
+ "\n".join(examples)
|
|
85
|
+
+ "\nRename one of the conflicting PDFs to disambiguate."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_image_only(path: Path, min_text_chars: int = 200) -> bool:
|
|
90
|
+
"""Return True if the markdown appears to contain only images with no real text.
|
|
91
|
+
|
|
92
|
+
Scanned PDFs converted by docling without OCR produce files that are mostly
|
|
93
|
+
``<!-- image -->`` tags. This heuristic strips those tags and markdown
|
|
94
|
+
headers to check if any substantive text survives.
|
|
95
|
+
"""
|
|
96
|
+
text = path.read_text(encoding="utf-8")
|
|
97
|
+
stripped = text.replace("<!-- image -->", "")
|
|
98
|
+
lines = [ln for ln in stripped.splitlines() if not ln.lstrip().startswith("#")]
|
|
99
|
+
return len("".join(lines).strip()) < min_text_chars
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_GLYPH_PLACEHOLDER = "glyph<UNKNOWN>"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _conversion_entries(markdown_dir: Path) -> tuple[dict[str, dict], bool]:
|
|
106
|
+
path = OutLayout(markdown_dir.parent).conversion_cache_json
|
|
107
|
+
if not path.exists():
|
|
108
|
+
return {}, True
|
|
109
|
+
try:
|
|
110
|
+
data = read_json(path)
|
|
111
|
+
except (OSError, UnicodeError, ValueError):
|
|
112
|
+
return {}, False
|
|
113
|
+
if not isinstance(data, dict) or data.get("version") != 1:
|
|
114
|
+
return {}, False
|
|
115
|
+
entries = data.get("entries") if isinstance(data, dict) else None
|
|
116
|
+
return (entries, True) if isinstance(entries, dict) else ({}, False)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _write_conversion_entry(
|
|
120
|
+
markdown_dir: Path, pdf_path: Path, out_path: Path, *, ocr: bool
|
|
121
|
+
) -> None:
|
|
122
|
+
layout = OutLayout(markdown_dir.parent)
|
|
123
|
+
entries, _ = _conversion_entries(markdown_dir)
|
|
124
|
+
entries[out_path.name] = {
|
|
125
|
+
"input_sha256": cache_input_fingerprint(pdf_path),
|
|
126
|
+
"output_sha256": cache_input_fingerprint(out_path),
|
|
127
|
+
"ocr": ocr,
|
|
128
|
+
}
|
|
129
|
+
write_json(layout.conversion_cache_json, {"version": 1, "entries": entries})
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _valid_conversion(
|
|
133
|
+
entry: object, pdf_hash: str, markdown_path: Path, *, ocr: bool
|
|
134
|
+
) -> bool:
|
|
135
|
+
return (
|
|
136
|
+
isinstance(entry, dict)
|
|
137
|
+
and entry.get("input_sha256") == pdf_hash
|
|
138
|
+
and (not ocr or entry.get("ocr") is True)
|
|
139
|
+
and markdown_path.exists()
|
|
140
|
+
and entry.get("output_sha256") == cache_input_fingerprint(markdown_path)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _copy_atomic(source: Path, target: Path) -> None:
|
|
145
|
+
fd, temporary = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.")
|
|
146
|
+
try:
|
|
147
|
+
with os.fdopen(fd, "wb") as stream:
|
|
148
|
+
stream.write(source.read_bytes())
|
|
149
|
+
os.replace(temporary, target)
|
|
150
|
+
finally:
|
|
151
|
+
if os.path.exists(temporary):
|
|
152
|
+
os.unlink(temporary)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _ocr_already_attempted(pdf_path: Path, markdown_path: Path) -> bool:
|
|
156
|
+
entries, _ = _conversion_entries(markdown_path.parent)
|
|
157
|
+
entry = entries.get(markdown_path.name)
|
|
158
|
+
return _valid_conversion(
|
|
159
|
+
entry,
|
|
160
|
+
cache_input_fingerprint(pdf_path),
|
|
161
|
+
markdown_path,
|
|
162
|
+
ocr=True,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _has_unmappable_glyphs(
|
|
167
|
+
path: Path, min_glyphs: int = 20, min_rate: float = 0.01
|
|
168
|
+
) -> bool:
|
|
169
|
+
"""Return True if the conversion lost a meaningful share of its characters.
|
|
170
|
+
|
|
171
|
+
A PDF whose embedded font carries no ToUnicode map converts to one
|
|
172
|
+
``glyph<UNKNOWN>`` per unmappable character. The output is *long*, so
|
|
173
|
+
:func:`_is_image_only` — which looks for too few characters — never fires,
|
|
174
|
+
and the paper goes on to yield empty metadata with no warning anywhere.
|
|
175
|
+
|
|
176
|
+
Corruption is often concentrated (a title page in a different font can be
|
|
177
|
+
fully unmappable while the body reads fine), so the rate is deliberately
|
|
178
|
+
low. ``min_glyphs`` keeps a handful of stray math symbols from crying wolf.
|
|
179
|
+
"""
|
|
180
|
+
text = path.read_text(encoding="utf-8")
|
|
181
|
+
n_glyphs = text.count(_GLYPH_PLACEHOLDER)
|
|
182
|
+
if n_glyphs < min_glyphs:
|
|
183
|
+
return False
|
|
184
|
+
mapped = sum(ch.isalnum() for ch in text.replace(_GLYPH_PLACEHOLDER, ""))
|
|
185
|
+
return n_glyphs / (n_glyphs + mapped) >= min_rate
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def convert_pdf_to_markdown(
|
|
189
|
+
pdf_path: Path | str,
|
|
190
|
+
markdown_dir: Path | str,
|
|
191
|
+
*,
|
|
192
|
+
overwrite: bool = False,
|
|
193
|
+
cache_stem: str | None = None,
|
|
194
|
+
ocr: bool = False,
|
|
195
|
+
) -> Path:
|
|
196
|
+
"""Convert a single PDF to markdown and write it to ``markdown_dir``.
|
|
197
|
+
|
|
198
|
+
With ``overwrite=False``, a recorded conversion is reused only while its
|
|
199
|
+
PDF, markdown output, and OCR setting still match. A content-identical PDF
|
|
200
|
+
can also reuse a recorded sibling after a rename. Existing stem caches
|
|
201
|
+
without provenance retain the historical reuse behavior.
|
|
202
|
+
|
|
203
|
+
``cache_stem`` overrides the output filename stem; without it the PDF's
|
|
204
|
+
own ``stem`` is used. Pass an explicit stem (e.g. one produced by
|
|
205
|
+
:func:`cache_stem_for`) to disambiguate same-named PDFs in different
|
|
206
|
+
subdirectories.
|
|
207
|
+
|
|
208
|
+
With ``ocr=True``, configures docling to force full-page OCR via EasyOCR,
|
|
209
|
+
which is needed for scanned PDFs where each page is a bitmap image.
|
|
210
|
+
"""
|
|
211
|
+
pdf_path = Path(pdf_path)
|
|
212
|
+
markdown_dir = Path(markdown_dir)
|
|
213
|
+
markdown_dir.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
|
|
215
|
+
stem = cache_stem if cache_stem is not None else pdf_path.stem
|
|
216
|
+
out_path = markdown_dir / f"{stem}.md"
|
|
217
|
+
if not overwrite:
|
|
218
|
+
entries, allow_legacy = _conversion_entries(markdown_dir)
|
|
219
|
+
pdf_hash = cache_input_fingerprint(pdf_path)
|
|
220
|
+
entry = entries.get(out_path.name)
|
|
221
|
+
if _valid_conversion(entry, pdf_hash, out_path, ocr=ocr):
|
|
222
|
+
logger.debug("Skipping %s (cached at %s)", pdf_path.name, out_path)
|
|
223
|
+
return out_path
|
|
224
|
+
if out_path.exists() and entry is None and allow_legacy and not ocr:
|
|
225
|
+
_write_conversion_entry(markdown_dir, pdf_path, out_path, ocr=ocr)
|
|
226
|
+
logger.debug("Skipping %s (legacy cache at %s)", pdf_path.name, out_path)
|
|
227
|
+
return out_path
|
|
228
|
+
for cached_name, cached_entry in sorted(entries.items()):
|
|
229
|
+
cached_path = markdown_dir / cached_name
|
|
230
|
+
if (
|
|
231
|
+
cached_path.name == cached_name
|
|
232
|
+
and cached_path.suffix == ".md"
|
|
233
|
+
and _valid_conversion(cached_entry, pdf_hash, cached_path, ocr=ocr)
|
|
234
|
+
):
|
|
235
|
+
_copy_atomic(cached_path, out_path)
|
|
236
|
+
_write_conversion_entry(
|
|
237
|
+
markdown_dir,
|
|
238
|
+
pdf_path,
|
|
239
|
+
out_path,
|
|
240
|
+
ocr=bool(cached_entry.get("ocr")),
|
|
241
|
+
)
|
|
242
|
+
logger.debug("Reusing %s by content at %s", pdf_path.name, out_path)
|
|
243
|
+
return out_path
|
|
244
|
+
|
|
245
|
+
logger.info("Converting %s -> markdown%s", pdf_path.name, " (OCR)" if ocr else "")
|
|
246
|
+
# Imported lazily so ``import citegraph`` stays cheap and so docling is
|
|
247
|
+
# only required when this function is actually called.
|
|
248
|
+
from docling.document_converter import DocumentConverter
|
|
249
|
+
|
|
250
|
+
if ocr:
|
|
251
|
+
from docling.datamodel.base_models import InputFormat
|
|
252
|
+
from docling.datamodel.pipeline_options import EasyOcrOptions, PdfPipelineOptions
|
|
253
|
+
from docling.document_converter import PdfFormatOption
|
|
254
|
+
|
|
255
|
+
pipeline_options = PdfPipelineOptions(
|
|
256
|
+
do_ocr=True,
|
|
257
|
+
ocr_options=EasyOcrOptions(force_full_page_ocr=True),
|
|
258
|
+
)
|
|
259
|
+
converter = DocumentConverter(
|
|
260
|
+
format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options)}
|
|
261
|
+
)
|
|
262
|
+
else:
|
|
263
|
+
converter = DocumentConverter()
|
|
264
|
+
|
|
265
|
+
result = converter.convert(str(pdf_path))
|
|
266
|
+
fd, temporary = tempfile.mkstemp(dir=markdown_dir, prefix=f".{out_path.name}.")
|
|
267
|
+
try:
|
|
268
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
269
|
+
stream.write(result.document.export_to_markdown())
|
|
270
|
+
os.replace(temporary, out_path)
|
|
271
|
+
finally:
|
|
272
|
+
if os.path.exists(temporary):
|
|
273
|
+
os.unlink(temporary)
|
|
274
|
+
_write_conversion_entry(markdown_dir, pdf_path, out_path, ocr=ocr)
|
|
275
|
+
return out_path
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _convert_loop(
|
|
279
|
+
pdfs: list[Path],
|
|
280
|
+
pdf_dir: Path,
|
|
281
|
+
markdown_dir: Path | str,
|
|
282
|
+
*,
|
|
283
|
+
overwrite: bool,
|
|
284
|
+
recursive: bool,
|
|
285
|
+
show_progress: bool,
|
|
286
|
+
ocr: bool,
|
|
287
|
+
description: str = "Converting PDFs",
|
|
288
|
+
) -> list[Path]:
|
|
289
|
+
"""Inner per-PDF loop shared by the single-pass and two-pass paths."""
|
|
290
|
+
from citegraph._progress import iter_with_progress
|
|
291
|
+
|
|
292
|
+
out_paths: list[Path] = []
|
|
293
|
+
for pdf in iter_with_progress(
|
|
294
|
+
pdfs,
|
|
295
|
+
show_progress=show_progress,
|
|
296
|
+
description=description,
|
|
297
|
+
item_label=lambda p: p.name,
|
|
298
|
+
):
|
|
299
|
+
stem = cache_stem_for(pdf, pdf_dir) if recursive else None
|
|
300
|
+
out_paths.append(
|
|
301
|
+
convert_pdf_to_markdown(
|
|
302
|
+
pdf, markdown_dir, overwrite=overwrite, cache_stem=stem, ocr=ocr
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
return out_paths
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def convert_directory(
|
|
309
|
+
pdf_dir: Path | str,
|
|
310
|
+
markdown_dir: Path | str,
|
|
311
|
+
*,
|
|
312
|
+
overwrite: bool = False,
|
|
313
|
+
recursive: bool = False,
|
|
314
|
+
show_progress: bool = True,
|
|
315
|
+
ocr: OCRMode = False,
|
|
316
|
+
) -> list[Path]:
|
|
317
|
+
"""Convert every PDF under ``pdf_dir`` to markdown.
|
|
318
|
+
|
|
319
|
+
With ``recursive=True``, walks subdirectories and uses path-aware cache
|
|
320
|
+
stems via :func:`cache_stem_for`. Raises ``ValueError`` upfront if two
|
|
321
|
+
distinct PDFs would map to the same cache stem — better to fail loudly
|
|
322
|
+
than to silently overwrite each other's caches mid-run.
|
|
323
|
+
|
|
324
|
+
With ``show_progress=True`` (default) a rich progress bar is rendered
|
|
325
|
+
while the per-PDF loop runs. Pass ``False`` for headless runs / tests
|
|
326
|
+
where the extra stderr output is unwanted.
|
|
327
|
+
|
|
328
|
+
``ocr`` controls how OCR is applied (see :data:`OCRMode`):
|
|
329
|
+
|
|
330
|
+
* ``False`` — never OCR.
|
|
331
|
+
* ``True`` — force full-page OCR via EasyOCR for every PDF.
|
|
332
|
+
* ``"auto"`` — two-pass: convert everything without OCR first, run the
|
|
333
|
+
:func:`_is_image_only` and :func:`_has_unmappable_glyphs` heuristics
|
|
334
|
+
on each output, then re-run only the flagged PDFs with OCR (their
|
|
335
|
+
stub markdown is atomically replaced only after a successful retry).
|
|
336
|
+
OCR is the remedy for both failure modes:
|
|
337
|
+
a scanned page with no text layer, and a page whose font carries no
|
|
338
|
+
ToUnicode map.
|
|
339
|
+
|
|
340
|
+
Returns the list of written markdown files in stable (sorted) order.
|
|
341
|
+
"""
|
|
342
|
+
pdf_dir = Path(pdf_dir)
|
|
343
|
+
markdown_dir = Path(markdown_dir)
|
|
344
|
+
pdfs = list_pdfs(pdf_dir, recursive=recursive)
|
|
345
|
+
logger.info(
|
|
346
|
+
"Found %d PDFs under %s (recursive=%s, ocr=%r)", len(pdfs), pdf_dir, recursive, ocr
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if recursive:
|
|
350
|
+
_detect_stem_collisions(pdfs, pdf_dir)
|
|
351
|
+
|
|
352
|
+
if ocr == "auto":
|
|
353
|
+
# Pass 1: try without OCR — cheap on text PDFs, fast on cached ones.
|
|
354
|
+
out_paths = _convert_loop(
|
|
355
|
+
pdfs,
|
|
356
|
+
pdf_dir,
|
|
357
|
+
markdown_dir,
|
|
358
|
+
overwrite=overwrite,
|
|
359
|
+
recursive=recursive,
|
|
360
|
+
show_progress=show_progress,
|
|
361
|
+
ocr=False,
|
|
362
|
+
)
|
|
363
|
+
retry_pairs = [
|
|
364
|
+
(pdf, md)
|
|
365
|
+
for pdf, md in zip(pdfs, out_paths, strict=True)
|
|
366
|
+
if (_is_image_only(md) or _has_unmappable_glyphs(md))
|
|
367
|
+
and not _ocr_already_attempted(pdf, md)
|
|
368
|
+
]
|
|
369
|
+
if retry_pairs:
|
|
370
|
+
logger.info(
|
|
371
|
+
"Re-running %d poorly-converted PDF(s) with OCR (auto fallback)",
|
|
372
|
+
len(retry_pairs),
|
|
373
|
+
)
|
|
374
|
+
_convert_loop(
|
|
375
|
+
[pdf for pdf, _ in retry_pairs],
|
|
376
|
+
pdf_dir,
|
|
377
|
+
markdown_dir,
|
|
378
|
+
overwrite=True,
|
|
379
|
+
recursive=recursive,
|
|
380
|
+
show_progress=show_progress,
|
|
381
|
+
ocr=True,
|
|
382
|
+
description=f"Re-converting {len(retry_pairs)} with OCR",
|
|
383
|
+
)
|
|
384
|
+
return out_paths
|
|
385
|
+
|
|
386
|
+
return _convert_loop(
|
|
387
|
+
pdfs,
|
|
388
|
+
pdf_dir,
|
|
389
|
+
markdown_dir,
|
|
390
|
+
overwrite=overwrite,
|
|
391
|
+
recursive=recursive,
|
|
392
|
+
show_progress=show_progress,
|
|
393
|
+
ocr=bool(ocr),
|
|
394
|
+
)
|