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 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)
@@ -0,0 +1,8 @@
1
+ import re
2
+
3
+
4
+ def replace_bullets(text):
5
+ # Replace bullet characters with a -
6
+ bullet_pattern = r"(^|[\n ])[•●○■▪▫–—]( )"
7
+ replaced_string = re.sub(bullet_pattern, r"\1-\2", text)
8
+ return replaced_string
@@ -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)]