marker-pdf 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.
- benchmark.py +128 -0
- chunk_convert.sh +47 -0
- convert.py +124 -0
- convert_single.py +29 -0
- marker/bbox.py +81 -0
- marker/benchmark/scoring.py +51 -0
- marker/cleaners/bullets.py +8 -0
- marker/cleaners/code.py +129 -0
- marker/cleaners/equations.py +316 -0
- marker/cleaners/headers.py +109 -0
- marker/cleaners/table.py +97 -0
- marker/convert.py +176 -0
- marker/debug/data.py +79 -0
- marker/extract_text.py +152 -0
- marker/logger.py +14 -0
- marker/markdown.py +174 -0
- marker/models.py +13 -0
- marker/ocr/page.py +78 -0
- marker/ocr/utils.py +72 -0
- marker/ordering.py +120 -0
- marker/postprocessors/editor.py +110 -0
- marker/postprocessors/t5.py +141 -0
- marker/schema.py +213 -0
- marker/segmentation.py +262 -0
- marker/settings.py +129 -0
- marker_pdf-0.1.0.dist-info/LICENSE +674 -0
- marker_pdf-0.1.0.dist-info/METADATA +261 -0
- marker_pdf-0.1.0.dist-info/RECORD +29 -0
- marker_pdf-0.1.0.dist-info/WHEEL +4 -0
benchmark.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import tempfile
|
|
3
|
+
import time
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
|
|
6
|
+
from tqdm import tqdm
|
|
7
|
+
|
|
8
|
+
from marker.convert import convert_single_pdf
|
|
9
|
+
from marker.logger import configure_logging
|
|
10
|
+
from marker.models import load_all_models
|
|
11
|
+
from marker.ordering import load_ordering_model
|
|
12
|
+
from marker.segmentation import load_layout_model
|
|
13
|
+
from marker.cleaners.equations import load_nougat_model
|
|
14
|
+
from marker.benchmark.scoring import score_text
|
|
15
|
+
from marker.extract_text import naive_get_text
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import subprocess
|
|
19
|
+
import shutil
|
|
20
|
+
import fitz as pymupdf
|
|
21
|
+
from marker.settings import settings
|
|
22
|
+
from tabulate import tabulate
|
|
23
|
+
|
|
24
|
+
configure_logging()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def nougat_prediction(pdf_filename, batch_size=1):
|
|
28
|
+
out_dir = tempfile.mkdtemp()
|
|
29
|
+
subprocess.run(["nougat", pdf_filename, "-o", out_dir, "--no-skipping", "--recompute", "--batchsize", str(batch_size)], check=True)
|
|
30
|
+
md_file = os.listdir(out_dir)[0]
|
|
31
|
+
with open(os.path.join(out_dir, md_file), "r") as f:
|
|
32
|
+
data = f.read()
|
|
33
|
+
shutil.rmtree(out_dir)
|
|
34
|
+
return data
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
parser = argparse.ArgumentParser(description="Benchmark PDF to MD conversion. Needs source pdfs, and a refernece folder with the correct markdown.")
|
|
39
|
+
parser.add_argument("in_folder", help="Input PDF files")
|
|
40
|
+
parser.add_argument("reference_folder", help="Reference folder with reference markdown files")
|
|
41
|
+
parser.add_argument("out_file", help="Output filename")
|
|
42
|
+
parser.add_argument("--nougat", action="store_true", help="Run nougat and compare", default=False)
|
|
43
|
+
# Nougat batch size 1 uses about as much VRAM as default marker settings
|
|
44
|
+
parser.add_argument("--nougat_batch_size", type=int, default=1, help="Batch size to use for nougat when making predictions.")
|
|
45
|
+
parser.add_argument("--marker_parallel_factor", type=int, default=1, help="How much to multiply default parallel OCR workers and model batch sizes by.")
|
|
46
|
+
parser.add_argument("--md_out_path", type=str, default=None, help="Output path for generated markdown files")
|
|
47
|
+
args = parser.parse_args()
|
|
48
|
+
|
|
49
|
+
methods = ["naive", "marker"]
|
|
50
|
+
if args.nougat:
|
|
51
|
+
methods.append("nougat")
|
|
52
|
+
|
|
53
|
+
model_lst = load_all_models()
|
|
54
|
+
|
|
55
|
+
scores = defaultdict(dict)
|
|
56
|
+
benchmark_files = os.listdir(args.in_folder)
|
|
57
|
+
benchmark_files = [b for b in benchmark_files if b.endswith(".pdf")]
|
|
58
|
+
times = defaultdict(dict)
|
|
59
|
+
pages = defaultdict(int)
|
|
60
|
+
|
|
61
|
+
for fname in tqdm(benchmark_files):
|
|
62
|
+
md_filename = fname.rsplit(".", 1)[0] + ".md"
|
|
63
|
+
|
|
64
|
+
reference_filename = os.path.join(args.reference_folder, md_filename)
|
|
65
|
+
with open(reference_filename, "r") as f:
|
|
66
|
+
reference = f.read()
|
|
67
|
+
|
|
68
|
+
pdf_filename = os.path.join(args.in_folder, fname)
|
|
69
|
+
doc = pymupdf.open(pdf_filename)
|
|
70
|
+
pages[fname] = len(doc)
|
|
71
|
+
|
|
72
|
+
for method in methods:
|
|
73
|
+
start = time.time()
|
|
74
|
+
if method == "marker":
|
|
75
|
+
full_text, out_meta = convert_single_pdf(pdf_filename, model_lst, parallel_factor=args.marker_parallel_factor)
|
|
76
|
+
elif method == "nougat":
|
|
77
|
+
full_text = nougat_prediction(pdf_filename, batch_size=args.nougat_batch_size)
|
|
78
|
+
elif method == "naive":
|
|
79
|
+
full_text = naive_get_text(doc)
|
|
80
|
+
else:
|
|
81
|
+
raise ValueError(f"Unknown method {method}")
|
|
82
|
+
|
|
83
|
+
times[method][fname] = time.time() - start
|
|
84
|
+
|
|
85
|
+
score = score_text(full_text, reference)
|
|
86
|
+
scores[method][fname] = score
|
|
87
|
+
|
|
88
|
+
if args.md_out_path:
|
|
89
|
+
md_out_filename = f"{method}_{md_filename}"
|
|
90
|
+
with open(os.path.join(args.md_out_path, md_out_filename), "w+") as f:
|
|
91
|
+
f.write(full_text)
|
|
92
|
+
|
|
93
|
+
total_pages = sum(pages.values())
|
|
94
|
+
with open(args.out_file, "w+") as f:
|
|
95
|
+
write_data = defaultdict(dict)
|
|
96
|
+
for method in methods:
|
|
97
|
+
total_time = sum(times[method].values())
|
|
98
|
+
file_stats = {
|
|
99
|
+
fname:
|
|
100
|
+
{
|
|
101
|
+
"time": times[method][fname],
|
|
102
|
+
"score": scores[method][fname],
|
|
103
|
+
"pages": pages[fname]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for fname in benchmark_files
|
|
107
|
+
}
|
|
108
|
+
write_data[method] = {
|
|
109
|
+
"files": file_stats,
|
|
110
|
+
"avg_score": sum(scores[method].values()) / len(scores[method]),
|
|
111
|
+
"time_per_page": total_time / total_pages,
|
|
112
|
+
"time_per_doc": total_time / len(scores[method])
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
json.dump(write_data, f, indent=4)
|
|
116
|
+
|
|
117
|
+
summary_table = []
|
|
118
|
+
score_table = []
|
|
119
|
+
score_headers = benchmark_files
|
|
120
|
+
for method in methods:
|
|
121
|
+
summary_table.append([method, write_data[method]["avg_score"], write_data[method]["time_per_page"], write_data[method]["time_per_doc"]])
|
|
122
|
+
score_table.append([method, *[write_data[method]["files"][h]["score"] for h in score_headers]])
|
|
123
|
+
|
|
124
|
+
print(tabulate(summary_table, headers=["Method", "Average Score", "Time per page", "Time per document"]))
|
|
125
|
+
print("")
|
|
126
|
+
print("Scores by file")
|
|
127
|
+
print(tabulate(score_table, headers=["Method", *score_headers]))
|
|
128
|
+
|
chunk_convert.sh
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
trap 'pkill -P $$' SIGINT
|
|
4
|
+
|
|
5
|
+
# Check if NUM_DEVICES is set
|
|
6
|
+
if [[ -z "$NUM_DEVICES" ]]; then
|
|
7
|
+
echo "Please set the NUM_DEVICES environment variable."
|
|
8
|
+
exit 1
|
|
9
|
+
fi
|
|
10
|
+
|
|
11
|
+
if [[ -z "$NUM_WORKERS" ]]; then
|
|
12
|
+
echo "Please set the NUM_WORKERS environment variable."
|
|
13
|
+
exit 1
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Get input folder and output folder from args
|
|
18
|
+
if [[ -z "$1" ]]; then
|
|
19
|
+
echo "Please provide an input folder."
|
|
20
|
+
exit 1
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
if [[ -z "$2" ]]; then
|
|
24
|
+
echo "Please provide an output folder."
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
INPUT_FOLDER=$1
|
|
29
|
+
OUTPUT_FOLDER=$2
|
|
30
|
+
|
|
31
|
+
# Loop from 0 to NUM_DEVICES and run the Python script in parallel
|
|
32
|
+
for (( i=0; i<$NUM_DEVICES; i++ )); do
|
|
33
|
+
DEVICE_NUM=$i
|
|
34
|
+
export DEVICE_NUM
|
|
35
|
+
export NUM_DEVICES
|
|
36
|
+
export NUM_WORKERS
|
|
37
|
+
echo "Running convert.py on GPU $DEVICE_NUM"
|
|
38
|
+
cmd="CUDA_VISIBLE_DEVICES=$DEVICE_NUM python convert.py $INPUT_FOLDER $OUTPUT_FOLDER --num_chunks $NUM_DEVICES --chunk_idx $DEVICE_NUM --workers $NUM_WORKERS"
|
|
39
|
+
[[ -n "$METADATA_FILE" ]] && cmd="$cmd --metadata_file $METADATA_FILE"
|
|
40
|
+
[[ -n "$MIN_LENGTH" ]] && cmd="$cmd --min_length $MIN_LENGTH"
|
|
41
|
+
eval $cmd &
|
|
42
|
+
|
|
43
|
+
sleep 5
|
|
44
|
+
done
|
|
45
|
+
|
|
46
|
+
# Wait for all background processes to finish
|
|
47
|
+
wait
|
convert.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
|
|
5
|
+
import ray
|
|
6
|
+
from tqdm import tqdm
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
from marker.convert import convert_single_pdf, get_length_of_text
|
|
10
|
+
from marker.models import load_all_models
|
|
11
|
+
from marker.settings import settings
|
|
12
|
+
from marker.logger import configure_logging
|
|
13
|
+
import traceback
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
configure_logging()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@ray.remote(num_cpus=settings.RAY_CORES_PER_WORKER, num_gpus=.05 if settings.CUDA else 0)
|
|
20
|
+
def process_single_pdf(fname: str, out_folder: str, model_refs, metadata: Optional[Dict] = None, min_length: Optional[int] = None):
|
|
21
|
+
out_filename = fname.rsplit(".", 1)[0] + ".md"
|
|
22
|
+
out_filename = os.path.join(out_folder, os.path.basename(out_filename))
|
|
23
|
+
out_meta_filename = out_filename.rsplit(".", 1)[0] + "_meta.json"
|
|
24
|
+
if os.path.exists(out_filename):
|
|
25
|
+
return
|
|
26
|
+
try:
|
|
27
|
+
# Skip trying to convert files that don't have a lot of embedded text
|
|
28
|
+
# This can indicate that they were scanned, and not OCRed properly
|
|
29
|
+
# Usually these files are not recent/high-quality
|
|
30
|
+
if min_length:
|
|
31
|
+
length = get_length_of_text(fname)
|
|
32
|
+
if length < min_length:
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
full_text, out_metadata = convert_single_pdf(fname, model_refs, metadata=metadata)
|
|
36
|
+
if len(full_text.strip()) > 0:
|
|
37
|
+
with open(out_filename, "w+", encoding='utf-8') as f:
|
|
38
|
+
f.write(full_text)
|
|
39
|
+
with open(out_meta_filename, "w+") as f:
|
|
40
|
+
f.write(json.dumps(out_metadata, indent=4))
|
|
41
|
+
else:
|
|
42
|
+
print(f"Empty file: {fname}. Could not convert.")
|
|
43
|
+
except Exception as e:
|
|
44
|
+
print(f"Error converting {fname}: {e}")
|
|
45
|
+
print(traceback.format_exc())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
parser = argparse.ArgumentParser(description="Convert multiple pdfs to markdown.")
|
|
50
|
+
parser.add_argument("in_folder", help="Input folder with pdfs.")
|
|
51
|
+
parser.add_argument("out_folder", help="Output folder")
|
|
52
|
+
parser.add_argument("--chunk_idx", type=int, default=0, help="Chunk index to convert")
|
|
53
|
+
parser.add_argument("--num_chunks", type=int, default=1, help="Number of chunks being processed in parallel")
|
|
54
|
+
parser.add_argument("--max", type=int, default=None, help="Maximum number of pdfs to convert")
|
|
55
|
+
parser.add_argument("--workers", type=int, default=5, help="Number of worker processes to use")
|
|
56
|
+
parser.add_argument("--metadata_file", type=str, default=None, help="Metadata json file to use for filtering")
|
|
57
|
+
parser.add_argument("--min_length", type=int, default=None, help="Minimum length of pdf to convert")
|
|
58
|
+
|
|
59
|
+
args = parser.parse_args()
|
|
60
|
+
|
|
61
|
+
in_folder = os.path.abspath(args.in_folder)
|
|
62
|
+
out_folder = os.path.abspath(args.out_folder)
|
|
63
|
+
files = [os.path.join(in_folder, f) for f in os.listdir(in_folder)]
|
|
64
|
+
os.makedirs(out_folder, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
# Handle chunks if we're processing in parallel
|
|
67
|
+
# Ensure we get all files into a chunk
|
|
68
|
+
chunk_size = math.ceil(len(files) / args.num_chunks)
|
|
69
|
+
start_idx = args.chunk_idx * chunk_size
|
|
70
|
+
end_idx = start_idx + chunk_size
|
|
71
|
+
files_to_convert = files[start_idx:end_idx]
|
|
72
|
+
|
|
73
|
+
# Limit files converted if needed
|
|
74
|
+
if args.max:
|
|
75
|
+
files_to_convert = files_to_convert[:args.max]
|
|
76
|
+
|
|
77
|
+
metadata = {}
|
|
78
|
+
if args.metadata_file:
|
|
79
|
+
metadata_file = os.path.abspath(args.metadata_file)
|
|
80
|
+
with open(metadata_file, "r") as f:
|
|
81
|
+
metadata = json.load(f)
|
|
82
|
+
|
|
83
|
+
total_processes = min(len(files_to_convert), args.workers)
|
|
84
|
+
|
|
85
|
+
ray.init(
|
|
86
|
+
num_cpus=total_processes,
|
|
87
|
+
num_gpus=1 if settings.CUDA else 0,
|
|
88
|
+
storage=settings.RAY_CACHE_PATH,
|
|
89
|
+
_temp_dir=settings.RAY_CACHE_PATH,
|
|
90
|
+
dashboard_host=settings.RAY_DASHBOARD_HOST,
|
|
91
|
+
log_to_driver=settings.DEBUG
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
model_lst = load_all_models()
|
|
95
|
+
model_refs = ray.put(model_lst)
|
|
96
|
+
|
|
97
|
+
# Dynamically set GPU allocation per task based on GPU ram
|
|
98
|
+
gpu_frac = settings.VRAM_PER_TASK / settings.INFERENCE_RAM if settings.CUDA else 0
|
|
99
|
+
|
|
100
|
+
print(f"Converting {len(files_to_convert)} pdfs in chunk {args.chunk_idx + 1}/{args.num_chunks} with {total_processes} processes, and storing in {out_folder}")
|
|
101
|
+
futures = [
|
|
102
|
+
process_single_pdf.options(num_gpus=gpu_frac).remote(
|
|
103
|
+
filename,
|
|
104
|
+
out_folder,
|
|
105
|
+
model_refs,
|
|
106
|
+
metadata=metadata.get(os.path.basename(filename)),
|
|
107
|
+
min_length=args.min_length
|
|
108
|
+
) for filename in files_to_convert
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
# Run all ray conversion tasks
|
|
112
|
+
progress_bar = tqdm(total=len(futures))
|
|
113
|
+
while len(futures) > 0:
|
|
114
|
+
finished, futures = ray.wait(
|
|
115
|
+
futures, timeout=7.0
|
|
116
|
+
)
|
|
117
|
+
finished_lst = ray.get(finished)
|
|
118
|
+
if isinstance(finished_lst, list):
|
|
119
|
+
progress_bar.update(len(finished_lst))
|
|
120
|
+
else:
|
|
121
|
+
progress_bar.update(1)
|
|
122
|
+
|
|
123
|
+
# Shutdown ray to free resources
|
|
124
|
+
ray.shutdown()
|
convert_single.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
from marker.convert import convert_single_pdf
|
|
4
|
+
from marker.logger import configure_logging
|
|
5
|
+
from marker.models import load_all_models
|
|
6
|
+
from marker.settings import settings
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
configure_logging()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
if __name__ == "__main__":
|
|
13
|
+
parser = argparse.ArgumentParser()
|
|
14
|
+
parser.add_argument("filename", help="PDF file to parse")
|
|
15
|
+
parser.add_argument("output", help="Output file name")
|
|
16
|
+
parser.add_argument("--max_pages", type=int, default=None, help="Maximum number of pages to parse")
|
|
17
|
+
parser.add_argument("--parallel_factor", type=int, default=1, help="How much to multiply default parallel OCR workers and model batch sizes by.")
|
|
18
|
+
args = parser.parse_args()
|
|
19
|
+
|
|
20
|
+
fname = args.filename
|
|
21
|
+
model_lst = load_all_models()
|
|
22
|
+
full_text, out_meta = convert_single_pdf(fname, model_lst, max_pages=args.max_pages, parallel_factor=args.parallel_factor)
|
|
23
|
+
|
|
24
|
+
with open(args.output, "w+", encoding='utf-8') as f:
|
|
25
|
+
f.write(full_text)
|
|
26
|
+
|
|
27
|
+
out_meta_filename = args.output.rsplit(".", 1)[0] + "_meta.json"
|
|
28
|
+
with open(out_meta_filename, "w+") as f:
|
|
29
|
+
f.write(json.dumps(out_meta, indent=4))
|
marker/bbox.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fitz as pymupdf
|
|
2
|
+
|
|
3
|
+
def should_merge_blocks(box1, box2, tol=5):
|
|
4
|
+
# Within tol y px, and to the right within tol px
|
|
5
|
+
merge = [
|
|
6
|
+
box2[0] > box1[0], # After in the x coordinate
|
|
7
|
+
abs(box2[1] - box1[1]) < tol, # Within tol y px
|
|
8
|
+
abs(box2[3] - box1[3]) < tol, # Within tol y px
|
|
9
|
+
abs(box2[0] - box1[2]) < tol, # Within tol x px
|
|
10
|
+
]
|
|
11
|
+
return all(merge)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def merge_boxes(box1, box2):
|
|
15
|
+
return (min(box1[0], box2[0]), min(box1[1], box2[1]), max(box2[2], box1[2]), max(box1[3], box2[3]))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def boxes_intersect(box1, box2):
|
|
19
|
+
# Box1 intersects box2
|
|
20
|
+
return box1[0] < box2[2] and box1[2] > box2[0] and box1[1] < box2[3] and box1[3] > box2[1]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def boxes_intersect_pct(box1, box2, pct=.9):
|
|
24
|
+
# determine the coordinates of the intersection rectangle
|
|
25
|
+
x_left = max(box1[0], box2[0])
|
|
26
|
+
y_top = max(box1[1], box2[1])
|
|
27
|
+
x_right = min(box1[2], box2[2])
|
|
28
|
+
y_bottom = min(box1[3], box2[3])
|
|
29
|
+
|
|
30
|
+
if x_right < x_left or y_bottom < y_top:
|
|
31
|
+
return 0.0
|
|
32
|
+
|
|
33
|
+
# The intersection of two axis-aligned bounding boxes is always an
|
|
34
|
+
# axis-aligned bounding box
|
|
35
|
+
intersection_area = (x_right - x_left) * (y_bottom - y_top)
|
|
36
|
+
|
|
37
|
+
# compute the area of both AABBs
|
|
38
|
+
bb1_area = (box1[2] - box1[0]) * (box1[3] - box1[1])
|
|
39
|
+
bb2_area = (box2[2] - box2[0]) * (box2[3] - box2[1])
|
|
40
|
+
|
|
41
|
+
iou = intersection_area / float(bb1_area + bb2_area - intersection_area)
|
|
42
|
+
return iou > pct
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def multiple_boxes_intersect(box1, boxes):
|
|
46
|
+
for box2 in boxes:
|
|
47
|
+
if boxes_intersect(box1, box2):
|
|
48
|
+
return True
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def box_contained(box1, box2):
|
|
53
|
+
# Box1 inside box2
|
|
54
|
+
return box1[0] > box2[0] and box1[1] > box2[1] and box1[2] < box2[2] and box1[3] < box2[3]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def unnormalize_box(bbox, width, height):
|
|
58
|
+
return [
|
|
59
|
+
width * (bbox[0] / 1000),
|
|
60
|
+
height * (bbox[1] / 1000),
|
|
61
|
+
width * (bbox[2] / 1000),
|
|
62
|
+
height * (bbox[3] / 1000),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def correct_rotation(bbox, page):
|
|
67
|
+
#bbox base is (x0, y0, x1, y1)
|
|
68
|
+
rotation = page.rotation
|
|
69
|
+
if rotation == 0:
|
|
70
|
+
return bbox
|
|
71
|
+
|
|
72
|
+
tl = pymupdf.Point(bbox[0], bbox[1]) * page.rotation_matrix
|
|
73
|
+
br = pymupdf.Point(bbox[2], bbox[3]) * page.rotation_matrix
|
|
74
|
+
if rotation == 90:
|
|
75
|
+
bbox = [br[0], tl[1], tl[0], br[1]]
|
|
76
|
+
elif rotation == 180:
|
|
77
|
+
bbox = [br[0], br[1], tl[0], tl[1]]
|
|
78
|
+
elif rotation == 270:
|
|
79
|
+
bbox = [tl[0], br[1], br[0], tl[1]]
|
|
80
|
+
|
|
81
|
+
return bbox
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
from rapidfuzz import fuzz, distance
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
CHUNK_MIN_CHARS = 25
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def tokenize(text):
|
|
10
|
+
# Combined pattern
|
|
11
|
+
pattern = r'([^\w\s\d\'])|([\w\']+)|(\d+)|(\n+)|( +)'
|
|
12
|
+
result = re.findall(pattern, text)
|
|
13
|
+
# Flatten the result and filter out empty strings
|
|
14
|
+
flattened_result = [item for sublist in result for item in sublist if item]
|
|
15
|
+
return flattened_result
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def chunk_text(text):
|
|
19
|
+
chunks = text.split("\n")
|
|
20
|
+
chunks = [c for c in chunks if c.strip() and len(c) > CHUNK_MIN_CHARS]
|
|
21
|
+
return chunks
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def overlap_score(hypothesis_chunks, reference_chunks):
|
|
25
|
+
length_modifier = len(hypothesis_chunks) / len(reference_chunks)
|
|
26
|
+
search_distance = max(len(reference_chunks) // 5, 10)
|
|
27
|
+
chunk_scores = []
|
|
28
|
+
chunk_weights = []
|
|
29
|
+
for i, hyp_chunk in enumerate(hypothesis_chunks):
|
|
30
|
+
max_score = 0
|
|
31
|
+
chunk_weight = 1
|
|
32
|
+
i_offset = int(i * length_modifier)
|
|
33
|
+
chunk_range = range(max(0, i_offset-search_distance), min(len(reference_chunks), i_offset+search_distance))
|
|
34
|
+
for j in chunk_range:
|
|
35
|
+
ref_chunk = reference_chunks[j]
|
|
36
|
+
score = fuzz.ratio(hyp_chunk, ref_chunk, score_cutoff=30) / 100
|
|
37
|
+
if score > max_score:
|
|
38
|
+
max_score = score
|
|
39
|
+
chunk_weight = math.sqrt(len(ref_chunk))
|
|
40
|
+
chunk_scores.append(max_score)
|
|
41
|
+
chunk_weights.append(chunk_weight)
|
|
42
|
+
chunk_scores = [chunk_scores[i] * chunk_weights[i] for i in range(len(chunk_scores))]
|
|
43
|
+
return chunk_scores, chunk_weights
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def score_text(hypothesis, reference):
|
|
47
|
+
# Returns a 0-1 alignment score
|
|
48
|
+
hypothesis_chunks = chunk_text(hypothesis)
|
|
49
|
+
reference_chunks = chunk_text(reference)
|
|
50
|
+
chunk_scores, chunk_weights = overlap_score(hypothesis_chunks, reference_chunks)
|
|
51
|
+
return sum(chunk_scores) / sum(chunk_weights)
|
marker/cleaners/code.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from marker.schema import Span, Line, Page
|
|
2
|
+
import re
|
|
3
|
+
from typing import List
|
|
4
|
+
import fitz as pymupdf
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def is_code_linelen(lines, thresh=60):
|
|
8
|
+
# Decide based on chars per newline threshold
|
|
9
|
+
total_alnum_chars = sum(len(re.findall(r'\w', line.prelim_text)) for line in lines)
|
|
10
|
+
total_newlines = max(len(lines) - 1, 1)
|
|
11
|
+
|
|
12
|
+
if total_alnum_chars == 0:
|
|
13
|
+
return False
|
|
14
|
+
|
|
15
|
+
ratio = total_alnum_chars / total_newlines
|
|
16
|
+
return ratio < thresh
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def comment_count(lines):
|
|
20
|
+
pattern = re.compile(r"^(//|#|'|--|/\*|'''|\"\"\"|--\[\[|<!--|%|%{|\(\*)")
|
|
21
|
+
return sum([1 for line in lines if pattern.match(line)])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def identify_code_blocks(blocks: List[Page]):
|
|
25
|
+
code_block_count = 0
|
|
26
|
+
font_info = None
|
|
27
|
+
for p in blocks:
|
|
28
|
+
stats = p.get_font_stats()
|
|
29
|
+
if font_info is None:
|
|
30
|
+
font_info = stats
|
|
31
|
+
else:
|
|
32
|
+
font_info += stats
|
|
33
|
+
try:
|
|
34
|
+
most_common_font = font_info.most_common(1)[0][0]
|
|
35
|
+
except IndexError:
|
|
36
|
+
print(f"Could not find most common font")
|
|
37
|
+
most_common_font = None
|
|
38
|
+
|
|
39
|
+
last_block = None
|
|
40
|
+
for page in blocks:
|
|
41
|
+
try:
|
|
42
|
+
min_start = page.get_min_line_start()
|
|
43
|
+
except IndexError:
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
for block in page.blocks:
|
|
47
|
+
if block.most_common_block_type() != "Text":
|
|
48
|
+
last_block = block
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
is_indent = []
|
|
52
|
+
line_fonts = []
|
|
53
|
+
for line in block.lines:
|
|
54
|
+
fonts = [span.font for span in line.spans]
|
|
55
|
+
line_fonts += fonts
|
|
56
|
+
line_start = line.bbox[0]
|
|
57
|
+
if line_start > min_start:
|
|
58
|
+
is_indent.append(True)
|
|
59
|
+
else:
|
|
60
|
+
is_indent.append(False)
|
|
61
|
+
comment_lines = comment_count([line.prelim_text for line in block.lines])
|
|
62
|
+
is_code = [
|
|
63
|
+
len(block.lines) > 3,
|
|
64
|
+
sum([f != most_common_font for f in line_fonts]) > len(line_fonts) * .8, # At least 80% of the fonts are not the most common, since code usually uses a different font from the main body text
|
|
65
|
+
is_code_linelen(block.lines),
|
|
66
|
+
(
|
|
67
|
+
sum(is_indent) > len(block.lines) * .2
|
|
68
|
+
or
|
|
69
|
+
comment_lines > len(block.lines) * .2
|
|
70
|
+
), # 20% lines indented or 20% of the lines are comments
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
# Check if previous block is code, and this block is indented
|
|
74
|
+
is_code_prev = [
|
|
75
|
+
last_block and last_block.most_common_block_type() == "Code",
|
|
76
|
+
sum(is_indent) >= len(block.lines) * .8 # At least 80% indented
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
if all(is_code) or all(is_code_prev):
|
|
80
|
+
code_block_count += 1
|
|
81
|
+
block.set_block_type("Code")
|
|
82
|
+
|
|
83
|
+
last_block = block
|
|
84
|
+
return code_block_count
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def indent_blocks(blocks: List[Page]):
|
|
88
|
+
span_counter = 0
|
|
89
|
+
for page in blocks:
|
|
90
|
+
for block in page.blocks:
|
|
91
|
+
block_types = [span.block_type for line in block.lines for span in line.spans]
|
|
92
|
+
if "Code" not in block_types:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
lines = []
|
|
96
|
+
min_left = 1000 # will contain x- coord of column 0
|
|
97
|
+
col_width = 0 # width of 1 char
|
|
98
|
+
for line in block.lines:
|
|
99
|
+
text = ""
|
|
100
|
+
min_left = min(line.bbox[0], min_left)
|
|
101
|
+
for span in line.spans:
|
|
102
|
+
if col_width == 0 and len(span.text) > 0:
|
|
103
|
+
col_width = (span.bbox[2] - span.bbox[0]) / len(span.text)
|
|
104
|
+
text += span.text
|
|
105
|
+
lines.append((pymupdf.Rect(line.bbox), text))
|
|
106
|
+
|
|
107
|
+
block_text = ""
|
|
108
|
+
blank_line = False
|
|
109
|
+
for line in lines:
|
|
110
|
+
text = line[1]
|
|
111
|
+
prefix = " " * int((line[0].x0 - min_left) / col_width)
|
|
112
|
+
current_line_blank = len(text.strip()) == 0
|
|
113
|
+
if blank_line and current_line_blank:
|
|
114
|
+
# Don't put multiple blank lines in a row
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
block_text += prefix + text + "\n"
|
|
118
|
+
blank_line = current_line_blank
|
|
119
|
+
|
|
120
|
+
new_span = Span(
|
|
121
|
+
text=block_text,
|
|
122
|
+
bbox=block.bbox,
|
|
123
|
+
color=block.lines[0].spans[0].color,
|
|
124
|
+
span_id=f"{span_counter}_fix_code",
|
|
125
|
+
font=block.lines[0].spans[0].font,
|
|
126
|
+
block_type="Code"
|
|
127
|
+
)
|
|
128
|
+
span_counter += 1
|
|
129
|
+
block.lines = [Line(spans=[new_span], bbox=block.bbox)]
|