Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docling/.agents/skills/docling/references/python-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,28 @@ converter = DocumentConverter(
format_options={InputFormat.HTML: HTMLFormatOption(backend_options=html_options)}
)
```
## DOCX, XLSX and EPUB size limits

These formats are ZIP archives. Docling checks the decompressed sizes before it
parses them and fails the file with `ArchiveLimitError` (a `DocumentLoadError`)
when one is too large. Defaults: DOCX/XLSX 128 MiB per member, 512 MiB in total,
10,000 members; EPUB content documents 10 MB each, 100 MB in total. Raise them
for large trusted files with `max_file_bytes`, `max_total_bytes` and
`max_member_count` on `MsWordBackendOptions`, `MsExcelBackendOptions` or
`EpubBackendOptions`:

```python
from docling.datamodel.backend_options import MsWordBackendOptions
from docling.datamodel.base_models import InputFormat
from docling.document_converter import DocumentConverter, WordFormatOption

converter = DocumentConverter(format_options={
InputFormat.DOCX: WordFormatOption(
backend_options=MsWordBackendOptions(max_file_bytes=512 * 1024 * 1024)
),
})
```

## Offline / air-gapped models

Pre-download model artifacts, then point conversions at them so no network is
Expand Down
52 changes: 45 additions & 7 deletions docling/backend/epub_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@

from docling.backend.abstract_backend import DeclarativeDocumentBackend
from docling.backend.html_backend import HTMLDocumentBackend
from docling.backend.utils.archive_limits import (
ArchiveLimitError,
ArchiveLimits,
check_archive_limits,
read_member,
)
from docling.datamodel.backend_options import EpubBackendOptions, HTMLBackendOptions
from docling.datamodel.base_models import InputFormat
from docling.datamodel.document import InputDocument
Expand All @@ -35,10 +41,11 @@ class EpubDocumentBackend(DeclarativeDocumentBackend):
for processing the XHTML structure.

Note:
This module enforces limits when extracting EPUB archives for image processing.
These limits prevent malicious EPUBs from exhausting disk space or memory
through decompression bombs. Limits can be configured via
EpubBackendOptions.
This module limits the decompressed size of the archive members it reads
(container, package document and content documents) and of the
members it extracts for image processing. These limits keep the
memory and disk use proportional to the configured values. Limits
can be configured via EpubBackendOptions.

Known Limitations:
Internal anchor links (e.g., footnote references) are converted but
Expand Down Expand Up @@ -81,6 +88,9 @@ def __init__(
self.valid = True

_log.debug(f"Found {len(self.content_files)} content files in EPUB")
except ArchiveLimitError as e:
_log.error(f"EPUB archive exceeds the configured limits: {e}")
raise
except Exception as e:
_log.error(f"Failed to initialize EPUB backend: {e}")
raise DocumentLoadError(
Expand All @@ -94,7 +104,10 @@ def _parse_epub_structure(self):

# Read container.xml to find the content.opf file
try:
container_data = self.epub_zip.read("META-INF/container.xml")
max_file_bytes = self.options.max_file_bytes
container_data = read_member(
self.epub_zip, "META-INF/container.xml", max_file_bytes
)
container_root = ET.fromstring(container_data)

# Find the content.opf path
Expand All @@ -109,7 +122,7 @@ def _parse_epub_structure(self):
raise ValueError("Could not find full-path in rootfile")

# Parse content.opf to get the reading order
opf_data = self.epub_zip.read(opf_path)
opf_data = read_member(self.epub_zip, opf_path, max_file_bytes)
opf_root = ET.fromstring(opf_data)

# Extract metadata
Expand Down Expand Up @@ -146,6 +159,7 @@ def _parse_epub_structure(self):
self.content_files.append(full_path)

_log.debug(f"Content files in reading order: {self.content_files}")
self._check_content_sizes()

except DefusedXmlException as e:
_log.error(f"Security issue detected while parsing EPUB XML: {e}")
Expand All @@ -156,6 +170,26 @@ def _parse_epub_structure(self):
_log.error(f"Error parsing EPUB structure: {e}")
raise

def _check_content_sizes(self) -> None:
"""Check the declared sizes of the content documents before they are read.

Raises:
ArchiveLimitError: If a content document is larger than
``max_file_bytes``, or all of them together are larger than
``max_total_bytes``, or there are more than ``max_member_count``.
"""
if not self.epub_zip:
return
names = set(self.epub_zip.namelist())
members = [
self.epub_zip.getinfo(name)
for name in dict.fromkeys(self.content_files)
if name in names
]
check_archive_limits(
self.epub_zip, ArchiveLimits.from_options(self.options), members
)

def _safe_extract_epub(self, target_dir: Path) -> bool:
"""Safely extract EPUB contents with zip-bomb protection.

Expand Down Expand Up @@ -404,7 +438,9 @@ def convert(self) -> DoclingDocument:
_log.debug(f"Reading content file: {content_file}")

# Read the XHTML content
xhtml_data = self.epub_zip.read(content_file)
xhtml_data = read_member(
self.epub_zip, content_file, self.options.max_file_bytes
)
xhtml_text = self._decode_content_file(xhtml_data)

# Extract the body content from the XHTML
Expand All @@ -429,6 +465,8 @@ def convert(self) -> DoclingDocument:

combined_html_parts.append(body_content)

except ArchiveLimitError:
raise
except Exception as e:
_log.warning(f"Failed to read content file {content_file}: {e}")
continue
Expand Down
52 changes: 46 additions & 6 deletions docling/backend/msexcel_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@
crop_whitespace,
get_docx_to_pdf_converter,
)
from docling.backend.utils.archive_limits import (
ArchiveLimitError,
ArchiveLimits,
check_archive_limits,
read_member,
)
from docling.backend.utils.charts import (
MAX_CHART_TABLE_CELLS,
build_chart_table,
Expand Down Expand Up @@ -570,8 +576,8 @@ class MsExcelDocumentBackend(DeclarativeDocumentBackend, PaginatedDocumentBacken
parsing, so threaded comments are not available for that format.
"""

# Maximum uncompressed byte sizes accepted when reading members from the XLSX zip.
# These caps guard against decompression-bomb payloads in drawing XML / image files.
# Maximum decompressed byte sizes accepted when this backend reads drawing XML
# and image members from the XLSX zip itself (openpyxl reads the rest).
_MAX_DRAWING_BYTES: Final[int] = 10 * 1024 * 1024 # 10 MB
_MAX_IMAGE_BYTES: Final[int] = 50 * 1024 * 1024 # 50 MB

Expand Down Expand Up @@ -599,6 +605,7 @@ def __init__(
if options is None:
options = MsExcelBackendOptions()
super().__init__(in_doc, path_or_stream, options)
self._archive_limits = ArchiveLimits.from_options(options)

self.page_range = in_doc.limits.page_range

Expand All @@ -614,6 +621,7 @@ def __init__(

self.workbook = None
try:
self._check_archive_limits()
# Suppress the openpyxl warning for WMF/EMF images being dropped:
# those formats are handled separately via LibreOffice conversion.
with warnings.catch_warnings():
Expand All @@ -635,13 +643,30 @@ def __init__(
self.workbook = _load_workbook(str(self.path_or_stream))

self.valid = self.workbook is not None
except ArchiveLimitError:
self.valid = False
raise
except Exception as e:
self.valid = False

raise DocumentLoadError(
f"MsExcelDocumentBackend could not load document with hash {self.document_hash}"
) from e

def _check_archive_limits(self) -> None:
"""Check the decompressed sizes the XLSX archive declares.

openpyxl decompresses the workbook members while it loads them, so the
check runs before ``load_workbook``.

Raises:
ArchiveLimitError: If the archive exceeds the configured limits.
"""
with ZipFile(self.path_or_stream, "r") as zip_file:
check_archive_limits(zip_file, self._archive_limits)
if isinstance(self.path_or_stream, BytesIO):
self.path_or_stream.seek(0)

def _parse_threaded_comments(
self, sheet_name: str
) -> dict[str, list[tuple[str, str, datetime | None]]]:
Expand Down Expand Up @@ -680,7 +705,11 @@ def _parse_threaded_comments(

person_map: dict[str, str] = {}
try:
person_xml = zip_file.read("xl/persons/person.xml")
person_xml = read_member(
zip_file,
"xl/persons/person.xml",
self._archive_limits.max_file_bytes,
)
person_tree = etree.fromstring(person_xml, parser=_SAFE_XML_PARSER)
person_map = {
person.get("id"): person.get("displayName")
Expand All @@ -703,7 +732,9 @@ def _parse_threaded_comments(

threaded_file = f"xl/threadedComments/threadedComment{sheet_num}.xml"
try:
threaded_xml = zip_file.read(threaded_file)
threaded_xml = read_member(
zip_file, threaded_file, self._archive_limits.max_file_bytes
)
threaded_tree = etree.fromstring(
threaded_xml, parser=_SAFE_XML_PARSER
)
Expand Down Expand Up @@ -1584,7 +1615,12 @@ def _process_drawing_for_unsupported_images(
if drawing_path not in zf.namelist():
return doc

tree = etree.fromstring(zf.read(drawing_path), parser=_SAFE_XML_PARSER)
try:
drawing_xml = read_member(zf, drawing_path, self._MAX_DRAWING_BYTES)
except ArchiveLimitError as exc:
_log.warning("Skipping EMF/WMF scan of %s: %s", drawing_path, exc)
return doc
tree = etree.fromstring(drawing_xml, parser=_SAFE_XML_PARSER)
try:
drawing = SpreadsheetDrawing.from_tree(tree)
except TypeError:
Expand All @@ -1600,7 +1636,11 @@ def _process_drawing_for_unsupported_images(
if dep.Type != IMAGE_NS or dep.target not in zf.namelist():
continue

image_bytes = zf.read(dep.target)
try:
image_bytes = read_member(zf, dep.target, self._MAX_IMAGE_BYTES)
except ArchiveLimitError as exc:
_log.warning("Skipping image %s: %s", dep.target, exc)
continue

# Skip images PIL can already handle — openpyxl already added those.
try:
Expand Down
Loading
Loading