Skip to content

Commit b531afa

Browse files
committed
feature(extract-core): output page byte ranges
1 parent 409d9f5 commit b531afa

12 files changed

Lines changed: 178 additions & 90 deletions

File tree

‎extract-core/extract_core/__init__.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
InputDoc,
1313
MarkdownDoc,
1414
OutputFormat,
15-
PageIndexes,
15+
Pages,
16+
Ranges,
1617
Result,
1718
Status,
1819
SupportedExt,
@@ -58,7 +59,8 @@
5859
"MinerUConfig",
5960
"MinerUPipelineConfig",
6061
"OutputFormat",
61-
"PageIndexes",
62+
"Ranges",
63+
"Pages",
6264
"Pipeline",
6365
"PipelineType",
6466
"Result",

‎extract-core/extract_core/objects.py‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import traceback
44
import uuid
55
from abc import ABC
6+
from collections.abc import Sequence
67
from enum import StrEnum
78
from functools import cache
89
from io import BytesIO
@@ -16,7 +17,7 @@
1617
no_enum_values_config,
1718
safe_copy,
1819
)
19-
from pydantic import AfterValidator, RootModel, TypeAdapter
20+
from pydantic import AfterValidator, Field, TypeAdapter
2021
from pydantic import BaseModel as _BaseModel
2122

2223
logger = logging.getLogger(__name__)
@@ -203,18 +204,25 @@ def without_content(self) -> Self:
203204
return safe_copy(self, update={"content": None})
204205

205206

206-
class PageIndexes(RootModel[list[tuple[int, int]]]):
207-
# Stores page end index
207+
Ranges = list[tuple[int, int]]
208+
209+
210+
class Pages(BaseModel):
211+
total: int = 0
212+
byte_ranges: Ranges = []
213+
208214
@classmethod
209-
def from_page_end_indices(cls, lengths: list[int]) -> Self:
210-
return [
211-
((lengths[p - 1] if p > 0 else 0), lengths[p]) for p in range(len(lengths))
212-
]
215+
def from_pages_bytes_sizes(cls, sizes: Sequence[int]) -> Self:
216+
bytes_ranges = []
217+
for p, end in enumerate(sizes):
218+
start = 0 if p == 0 else bytes_ranges[-1][1]
219+
bytes_ranges.append((start, start + end))
220+
return cls(total=len(sizes), byte_ranges=bytes_ranges)
213221

214222

215223
class ConversionOutput(BaseModel):
216224
path: Path
217-
pages: PageIndexes = []
225+
pages: Pages = Field(default_factory=Pages)
218226

219227

220228
class MarkdownDoc(ConversionOutput):

‎extract-python/benches/compare.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import markdown2
55
import pypdfium2
6-
from extract_core import BaseModel, OutputFormat, PageIndexes
6+
from extract_core import BaseModel, OutputFormat, PageRanges
77
from extract_python.utils import chdir
88
from html2image import Html2Image
99
from PIL import Image, ImageDraw
@@ -140,7 +140,7 @@ def _scan_pages(
140140
root: Path, comparison: ComparisonItem
141141
) -> list[dict[str, tuple[int, int]]]:
142142
all_pages = [
143-
PageIndexes.model_validate_json(
143+
PageRanges.model_validate_json(
144144
(root / compared / "artifacts" / "pages.json").read_text()
145145
).root
146146
for compared in comparison.compared
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
ARTIFACTS = "artifacts"
2-
DEFAULT_MD_PAGE_SEP = '<div style="page-break-after: always;"></div>'
2+
DEFAULT_MD_PAGE_SEP = '\n<div style="page-break-after: always;"></div>\n'

‎extract-python/extract_python/docling_.py‎

Lines changed: 27 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
InputDoc,
2424
MarkdownDoc,
2525
OutputFormat,
26-
PageIndexes,
2726
Pipeline,
2827
PipelineType,
2928
Result,
@@ -34,7 +33,7 @@
3433
from pydantic_core.core_schema import SerializerFunctionWrapHandler
3534

3635
from .constants import ARTIFACTS, DEFAULT_MD_PAGE_SEP
37-
from .utils import chdir, map_and_preserve, path_to_artifacts_dirname
36+
from .utils import chdir, map_and_preserve, path_to_artifacts_dirname, write_pages
3837

3938
logger = logging.getLogger(__name__)
4039

@@ -115,39 +114,38 @@ def _to_markdown_doc(
115114
raise FileExistsError(f"directory {md_dir} already exists")
116115
# Let's avoid issue of duplicated input file names flattened top level
117116
md_filename = md_dir_name + OutputFormat.MARKDOWN
118-
total_length = 0
119-
n_pages = len(res.pages)
120-
121117
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td:
122118
tmp_dir = Path(td)
123-
page_path = Path("page.md")
124-
# We do a chdir to bypass a Docling bug which only allows to maintain relative
125-
# image ref when saving the markdown to a relative path
126-
with (tmp_dir / md_filename).open("w") as f, chdir(tmp_dir):
127-
end_indices = []
128-
for page_i in range(n_pages):
129-
res.document.save_as_markdown(
130-
page_path,
131-
page_no=page_i + 1,
132-
image_mode=ImageRefMode.REFERENCED,
133-
artifacts_dir=Path(ARTIFACTS),
134-
**kwargs,
135-
)
136-
content = page_path.read_text()
137-
if page_i > 0:
138-
content += "\n"
139-
if page_i < n_pages - 1:
140-
content += page_sep
141-
total_length += len(content)
142-
end_indices.append(total_length)
143-
f.write(content)
144-
f.flush()
145-
page_path.unlink()
119+
md_path = tmp_dir / md_filename
120+
current_page_path = tmp_dir / "page.md"
121+
with chdir(tmp_dir):
122+
# We do a chdir to bypass a Docling bug which only allows to maintain
123+
# relative image ref when saving the markdown to a relative path
124+
pages = _docling_pages_it(res, current_page_path, **kwargs)
125+
with md_path.open("wb") as f:
126+
pages = write_pages(pages, page_sep, f)
127+
# Clean up the tmp page file before move everything to the end destination
128+
current_page_path.unlink()
146129
shutil.move(tmp_dir, md_dir)
147-
pages = PageIndexes.from_page_end_indices(end_indices)
148130
return MarkdownDoc(path=Path(md_dir_name), pages=pages)
149131

150132

133+
def _docling_pages_it(
134+
res: ConversionResult, output_path: Path, **kwargs
135+
) -> Iterable[str]:
136+
n_pages = len(res.pages)
137+
for page_i in range(n_pages):
138+
res.document.save_as_markdown(
139+
output_path,
140+
page_no=page_i + 1,
141+
image_mode=ImageRefMode.REFERENCED,
142+
artifacts_dir=Path(ARTIFACTS),
143+
**kwargs,
144+
)
145+
content = output_path.read_text()
146+
yield content
147+
148+
151149
class SerializableFormatOptions(DoclingFormatOption):
152150
# Utility class to serialize Python format options into a JSON which can be
153151
# correctly deserialized into a docling FormatOption

‎extract-python/extract_python/marker_.py‎

Lines changed: 16 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,14 @@
99
InputDoc,
1010
MarkdownDoc,
1111
OutputFormat,
12-
PageIndexes,
1312
Pipeline,
1413
PipelineType,
1514
Result,
1615
Status,
1716
)
1817

19-
from .constants import ARTIFACTS
20-
from .utils import path_to_artifacts_dirname, report_recoverable_errors
18+
from .constants import ARTIFACTS, DEFAULT_MD_PAGE_SEP
19+
from .utils import path_to_artifacts_dirname, report_recoverable_errors, write_pages
2120

2221
if TYPE_CHECKING:
2322
from marker.converters.pdf import PdfConverter
@@ -63,15 +62,22 @@ async def _process_doc(
6362
content, _, images = text_from_rendered(rendered)
6463
match output_format:
6564
case OutputFormat.MARKDOWN:
66-
output = _to_markdown_doc(doc, content, images, output_path)
65+
output = _to_markdown_doc(
66+
doc, content, images, output_path, page_sep=DEFAULT_MD_PAGE_SEP
67+
)
6768
case _:
6869
raise NotImplementedError(f"unsupported output format {output_format}")
6970
input_doc = doc.without_content()
7071
return Result(input=input_doc, status=Status.SUCCESS, output=output)
7172

7273

7374
def _to_markdown_doc(
74-
input_doc: InputDoc, content: str, images: dict[str, "Image"], output_path: Path
75+
input_doc: InputDoc,
76+
content: str,
77+
images: dict[str, "Image"],
78+
output_path: Path,
79+
*,
80+
page_sep: str = DEFAULT_MD_PAGE_SEP,
7581
) -> MarkdownDoc:
7682
from marker.renderers.markdown import MarkdownRenderer # noqa: PLC0415
7783

@@ -85,24 +91,9 @@ def _to_markdown_doc(
8591
im.save(artifacts_dir / im_name)
8692
del images
8793
gc.collect()
88-
page_sep = MarkdownRenderer.page_separator
89-
content = content.split(page_sep)
90-
n_pages = len(content)
91-
md_path = (output_path / md_dir_name / md_dir_name).with_suffix(
92-
OutputFormat.MARKDOWN.value
93-
)
94-
total_length = 0
95-
end_indices = []
96-
with md_path.open("w", encoding="utf-8") as f:
97-
for page_i, page_content in enumerate(content):
98-
content = page_content
99-
if page_i > 0:
100-
content += "\n"
101-
if page_i < n_pages - 1:
102-
content += page_sep
103-
total_length += len(content)
104-
end_indices.append(total_length)
105-
f.write(content)
106-
f.flush()
107-
pages = PageIndexes.from_page_end_indices(end_indices)
94+
pages = content.split(MarkdownRenderer.page_separator)
95+
md_path = output_path / md_dir_name / md_dir_name
96+
md_path = md_path.with_suffix(OutputFormat.MARKDOWN.value)
97+
with md_path.open("wb") as f:
98+
pages = write_pages(pages, page_sep, f)
10899
return MarkdownDoc(path=Path(md_dir_name), pages=pages)

‎extract-python/extract_python/miner_u.py‎

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,14 @@
1212
MinerUBackend,
1313
MinerUPipelineConfig,
1414
OutputFormat,
15-
PageIndexes,
1615
Pipeline,
1716
PipelineType,
1817
Result,
1918
Status,
2019
)
2120

2221
from .constants import ARTIFACTS, DEFAULT_MD_PAGE_SEP
23-
from .utils import path_to_artifacts_dirname, reset_env
22+
from .utils import path_to_artifacts_dirname, reset_env, write_pages
2423

2524
_MINER_U_CONVERSION_ERRORS = tuple()
2625
MDMakeFunction = Callable[[list, str, str], str | None]
@@ -148,21 +147,9 @@ def _dump_md_content(
148147

149148
if md_make_mode is None:
150149
md_make_mode = MakeMode.MM_MD
151-
total_length = 0
152-
end_indices = []
153-
with md_path.open("w") as f:
154-
n_pages = len(pdf_info)
155-
for page_i, page in enumerate(pdf_info):
156-
content = md_make_fn([page], md_make_mode, str(im_dir))
157-
if page_i > 0:
158-
content += "\n"
159-
if page_i < n_pages - 1:
160-
content += page_sep
161-
total_length += len(content)
162-
end_indices.append(total_length)
163-
f.write(content)
164-
f.flush()
165-
end_indices = PageIndexes.from_page_end_indices(end_indices)
150+
pages = (md_make_fn([p], md_make_mode, str(im_dir)) for p in pdf_info)
151+
with md_path.open("wb") as f:
152+
pages = write_pages(pages, page_sep, f)
166153
output_path = md_path.parent.relative_to(output_path)
167-
output = ConversionOutput(path=output_path, pages=end_indices)
154+
output = ConversionOutput(path=output_path, pages=pages)
168155
return output

‎extract-python/extract_python/utils.py‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
from functools import wraps
66
from itertools import tee
77
from pathlib import Path, PurePath
8-
from typing import Protocol, TypeVar
8+
from typing import BinaryIO, Protocol, TypeVar
99

10-
from extract_core import Error, InputDoc, Result, Status
10+
from extract_core import Error, InputDoc, Pages, Result, Status
1111

1212
R = TypeVar("R")
1313
In = TypeVar("In")
@@ -73,3 +73,20 @@ def reset_env() -> Generator[None, None, None]:
7373
finally:
7474
os.environ.clear()
7575
os.environ.update(old_env)
76+
77+
78+
def write_pages(pages: Iterable[str], page_sep: str, out: BinaryIO) -> Pages:
79+
pages = iter(pages)
80+
next_page = None
81+
pages_byte_sizes = []
82+
sentinel = object()
83+
while True:
84+
content = next(pages, sentinel) if next_page is None else next_page
85+
if content is sentinel:
86+
break
87+
next_page = next(pages, sentinel)
88+
if next_page is not sentinel:
89+
content += page_sep
90+
content = content.encode()
91+
pages_byte_sizes.append(out.write(content))
92+
return Pages.from_pages_bytes_sizes(pages_byte_sizes)

‎extract-python/tests/test_docling.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ async def test_docling_pdf_to_markdown(
5151
assert (output_path / p).is_dir()
5252
assert (output_path / p / p.name).with_suffix(".md").exists()
5353
assert any((output_path / p).glob("artifacts/*.png"))
54-
assert all(r.output.pages for r in res)
54+
assert all(r.output.pages.byte_ranges for r in res)
5555
assert not any(r.errors for r in res)
5656
input_path = [r.input.path for r in res]
5757
expected_input_path = [

‎extract-python/tests/test_marker.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ async def test_marker_pdf_to_markdown(
4545
assert (output_path / p).is_dir()
4646
assert (output_path / p / p.name).with_suffix(".md").exists()
4747
assert any((output_path / p).glob("artifacts/*.jpeg"))
48-
assert all(r.output.pages for r in res)
48+
assert all(r.output.pages.byte_ranges for r in res)
4949
assert not any(r.errors for r in res)
5050
input_path = [r.input.path for r in res]
5151
expected_path = [

0 commit comments

Comments
 (0)