From 822ba8a0e991e90e94359ee2fbae566973b34b79 Mon Sep 17 00:00:00 2001 From: Michele Dolfi Date: Thu, 22 Oct 2026 18:04:22 +0200 Subject: [PATCH 1/2] fix(xlsx): clip merged-cell ranges while loading workbooks openpyxl creates one MergedCell object for each covered cell of a merged range while it loads a worksheet. A single short element such as A1:XFD1048576 can make it create billions of objects, and the table detection then widens to the same range. Load workbooks through openpyxl's ExcelReader with a per-workbook budget of covered merged cells. Ranges within the budget load unchanged. A range that does not fit is clipped to the cells with values in its worksheet, and to its top-left cell if it still does not fit. The hook is set on the workbook and worksheet instances of this load only. Signed-off-by: Michele Dolfi --- docling/backend/msexcel_backend.py | 133 +++++++++++++++++++++++++++-- tests/test_backend_msexcel.py | 85 ++++++++++++++++++ 2 files changed, 211 insertions(+), 7 deletions(-) diff --git a/docling/backend/msexcel_backend.py b/docling/backend/msexcel_backend.py index 74838a5972..f292dbf6cb 100644 --- a/docling/backend/msexcel_backend.py +++ b/docling/backend/msexcel_backend.py @@ -79,7 +79,7 @@ _OPENPYXL_AVAILABLE: bool = False _OPENPYXL_IMPORT_ERROR: ImportError | None = None try: # pragma: no cover - import-time guard - from openpyxl import Workbook, load_workbook + from openpyxl import Workbook from openpyxl.cell.cell import Cell, MergedCell from openpyxl.chart import DoughnutChart, PieChart, PieChart3D, ProjectedPieChart from openpyxl.chart.data_source import AxDataSource, NumDataSource @@ -93,8 +93,10 @@ TwoCellAnchor, ) from openpyxl.packaging.relationship import get_dependents, get_rels_path + from openpyxl.reader.excel import ExcelReader from openpyxl.styles import PatternFill from openpyxl.utils.cell import range_boundaries + from openpyxl.worksheet.merge import MergedCellRange from openpyxl.worksheet.worksheet import Worksheet from openpyxl.xml.constants import IMAGE_NS @@ -275,6 +277,127 @@ def span_at(self, row: int, col: int) -> tuple[int, int]: return self._anchor_spans.get((row, col), (1, 1)) +# openpyxl creates one ``MergedCell`` object for each covered cell of a merged +# range while it loads a worksheet. This limits the covered cells that one +# workbook can make it create. +_MAX_MERGED_CELLS: Final[int] = 100_000 + + +class _MergedRangeClipper: + """Clip merged ranges so that openpyxl creates a bounded number of cells. + + Merged ranges are kept unchanged while the workbook stays within the cell + budget. A range that does not fit is clipped to the cells with values in its + worksheet, and to its top-left cell if it still does not fit. + """ + + def __init__(self, budget: int) -> None: + self._remaining = budget + self.clipped = 0 + + def bind(self, sheet: Worksheet) -> Callable[[MergedCellRange], None]: + """Return a replacement for ``sheet._clean_merge_range``.""" + clean_merge_range = sheet._clean_merge_range # type: ignore[attr-defined] + value_bounds: list[DataRegion | None] = [] + + def clip_and_clean(merged_range: MergedCellRange) -> None: + # openpyxl binds all cells of a sheet before its merged ranges, so the + # first call sees all values of the sheet. + if not value_bounds: + value_bounds.append(_value_bounds(sheet)) + self._fit(merged_range, value_bounds[0]) + clean_merge_range(merged_range) + + return clip_and_clean + + def _fit(self, merged_range: MergedCellRange, bounds: DataRegion | None) -> None: + covered = _covered_cells(merged_range) + if covered > self._remaining: + self.clipped += 1 + if bounds is not None: + merged_range.max_row = max( + merged_range.min_row, min(merged_range.max_row, bounds.max_row) + ) + merged_range.max_col = max( + merged_range.min_col, min(merged_range.max_col, bounds.max_col) + ) + covered = _covered_cells(merged_range) + if covered > self._remaining: + merged_range.max_row = merged_range.min_row + merged_range.max_col = merged_range.min_col + covered = 0 + self._remaining -= covered + + +def _covered_cells(merged_range: MergedCellRange) -> int: + """Return the number of cells of a merged range after its top-left cell.""" + rows = merged_range.max_row - merged_range.min_row + 1 + cols = merged_range.max_col - merged_range.min_col + 1 + return rows * cols - 1 + + +def _value_bounds(sheet: Worksheet) -> DataRegion | None: + """Return the smallest region that holds all cells with a value.""" + coords = [coord for coord, cell in sheet._cells.items() if cell.value is not None] + if not coords: + return None + rows = [row for row, _ in coords] + cols = [col for _, col in coords] + return DataRegion(min(rows), max(rows), min(cols), max(cols)) + + +def _read_worksheets_with_clipped_merges( + workbook: Workbook, read_worksheets: Callable[[], None] +) -> None: + """Run ``read_worksheets`` with merged ranges clipped on each new worksheet.""" + # openpyxl expands each merged range in ``Worksheet._clean_merge_range`` and + # has no option to limit it. Wrap that method on the worksheets of this + # workbook only: ``create_sheet`` is shadowed on this workbook instance while + # its sheets load. No class or module attribute of openpyxl changes, so + # concurrent loads are not affected. + clipper = _MergedRangeClipper(_MAX_MERGED_CELLS) + create_sheet = workbook.create_sheet + sheets: list[Worksheet] = [] + + def create_bounded_sheet( + title: str | None = None, index: int | None = None + ) -> Worksheet: + sheet = create_sheet(title, index) + sheet._clean_merge_range = clipper.bind(sheet) + sheets.append(sheet) + return sheet + + workbook.create_sheet = create_bounded_sheet # type: ignore[method-assign] + try: + read_worksheets() + finally: + del workbook.create_sheet + for sheet in sheets: + del sheet._clean_merge_range # type: ignore[attr-defined] + if clipper.clipped: + _log.warning( + "Clipped %d merged cell ranges that cover more than %d cells", + clipper.clipped, + _MAX_MERGED_CELLS, + ) + + +def _load_workbook(filename: str | BytesIO) -> Workbook: + """Load a workbook like ``load_workbook(filename, data_only=True)``. + + Merged cell ranges are clipped so that they cover a bounded number of cells. + """ + reader = ExcelReader(filename, data_only=True) + read_worksheets = reader.read_worksheets + # ``reader.wb`` exists once ``read`` has read the workbook part, which openpyxl + # does before it reads the worksheets. + reader.read_worksheets = lambda: _read_worksheets_with_clipped_merges( # type: ignore[method-assign] + reader.wb, read_worksheets + ) + reader.read() + return reader.wb + + class ExcelCell(BaseModel): """Represents an Excel cell. @@ -398,13 +521,9 @@ def __init__( module=r"openpyxl\.reader\.drawings", ) if isinstance(self.path_or_stream, BytesIO): - self.workbook = load_workbook( - filename=self.path_or_stream, data_only=True - ) + self.workbook = _load_workbook(self.path_or_stream) elif isinstance(self.path_or_stream, Path): - self.workbook = load_workbook( - filename=str(self.path_or_stream), data_only=True - ) + self.workbook = _load_workbook(str(self.path_or_stream)) self.valid = self.workbook is not None except Exception as e: diff --git a/tests/test_backend_msexcel.py b/tests/test_backend_msexcel.py index 19d12271be..d8e1ab5a27 100644 --- a/tests/test_backend_msexcel.py +++ b/tests/test_backend_msexcel.py @@ -2,6 +2,8 @@ # SPDX-License-Identifier: MIT import logging +import tracemalloc +import zipfile from collections.abc import Iterator from datetime import datetime from io import BytesIO @@ -738,6 +740,89 @@ def test_merged_cells_are_indexed_once_and_preserve_semantics(tmp_path: Path) -> assert comment_map[(3, 3)] == [("Codex", "Synthetic note", None)] +def _replace_merged_range(path: Path, old_ref: str, new_ref: str) -> None: + """Rewrite a merged range of the first sheet, as openpyxl cannot save it.""" + with zipfile.ZipFile(path) as source: + parts = {info.filename: source.read(info) for info in source.infolist()} + sheet_part = "xl/worksheets/sheet1.xml" + old_attr = f''.encode() + assert old_attr in parts[sheet_part] + parts[sheet_part] = parts[sheet_part].replace( + old_attr, f''.encode() + ) + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as target: + for name, data in parts.items(): + target.writestr(name, data) + + +def test_whole_sheet_merged_range_is_clipped_to_data(tmp_path: Path) -> None: + workbook = Workbook() + sheet = workbook.active + sheet["A1"] = "Quarterly report" + sheet["B3"] = "end" + sheet.merge_cells("A1:B2") + file_path = tmp_path / "whole-sheet-merge.xlsx" + workbook.save(file_path) + _replace_merged_range(file_path, "A1:B2", "A1:XFD1048576") + + converter = DocumentConverter(allowed_formats=[InputFormat.XLSX]) + tracemalloc.start() + try: + doc = converter.convert(file_path).document + _, peak = tracemalloc.get_traced_memory() + finally: + tracemalloc.stop() + + assert peak < 50 * 1024 * 1024 + assert len(doc.tables) == 1 + table = doc.tables[0].data + assert (table.num_rows, table.num_cols) == (3, 2) + [anchor] = [cell for cell in table.table_cells if cell.text == "Quarterly report"] + assert (anchor.row_span, anchor.col_span) == (3, 2) + + +def test_merged_range_beyond_data_is_clipped(tmp_path: Path) -> None: + workbook = Workbook() + sheet = workbook.active + sheet.append(["Inventory", None, None]) + sheet.append(["Item", "Count", "Price"]) + sheet.append(["Fruit", 3, 1.5]) + sheet.append([None, 4, 2.5]) + sheet.append(["Total", None, None]) + sheet.merge_cells("A1:C1") + sheet.merge_cells("A3:A4") + sheet.merge_cells("A5:B5") + file_path = tmp_path / "partial-merge.xlsx" + workbook.save(file_path) + _replace_merged_range(file_path, "A5:B5", "A5:XFD1000") + + in_doc = InputDocument( + path_or_stream=file_path, + format=InputFormat.XLSX, + filename=file_path.stem, + backend=MsExcelDocumentBackend, + ) + backend = MsExcelDocumentBackend(in_doc=in_doc, path_or_stream=file_path) + loaded_sheet = backend.workbook.active + assert {str(merged) for merged in loaded_sheet.merged_cells.ranges} == { + "A1:C1", + "A3:A4", + "A5:C5", + } + + tables, _ = backend._find_data_tables(loaded_sheet) + + assert len(tables) == 1 + table = tables[0] + assert (table.anchor, table.num_rows, table.num_cols) == ((0, 0), 5, 3) + spans = { + cell.text: (cell.row_span, cell.col_span) + for cell in table.data + if cell.text in {"Inventory", "Fruit", "Total"} + } + assert spans == {"Inventory": (1, 3), "Fruit": (2, 1), "Total": (1, 3)} + + def test_split_leading_section_label_helper() -> None: backend = object.__new__(MsExcelDocumentBackend) From 9f4f4863987df5c28564638ccccf6e9a1bc8d383 Mon Sep 17 00:00:00 2001 From: Michele Dolfi Date: Thu, 22 Oct 2026 18:16:56 +0200 Subject: [PATCH 2/2] fix(xlsx): clip hyperlink ranges while loading workbooks openpyxl binds a ranged by looking up the range on the worksheet. This creates a cell and a Hyperlink copy for every cell of the range, and sets the link target as the value of each empty cell, so the cells also become table data. A short element such as A1:J100000 makes openpyxl and the table detection do work for one million cells. Clip the range lookups that openpyxl does while a worksheet loads with the same approach as merged ranges, and a separate budget of 10,000 hyperlink cells per workbook. Ranges within the budget load unchanged. The worksheet methods are shadowed on the instances of this load only. Signed-off-by: Michele Dolfi --- docling/backend/msexcel_backend.py | 231 +++++++++++++++++++++-------- tests/test_backend_msexcel.py | 76 ++++++++-- 2 files changed, 236 insertions(+), 71 deletions(-) diff --git a/docling/backend/msexcel_backend.py b/docling/backend/msexcel_backend.py index f292dbf6cb..766f3355c2 100644 --- a/docling/backend/msexcel_backend.py +++ b/docling/backend/msexcel_backend.py @@ -277,63 +277,166 @@ def span_at(self, row: int, col: int) -> tuple[int, int]: return self._anchor_spans.get((row, col), (1, 1)) -# openpyxl creates one ``MergedCell`` object for each covered cell of a merged -# range while it loads a worksheet. This limits the covered cells that one -# workbook can make it create. +# While openpyxl loads a worksheet, it creates one ``MergedCell`` object for +# each covered cell of a merged range, and one cell with its own ``Hyperlink`` +# copy for each cell of a hyperlink range. These limit the cells that the ranges +# of one workbook can make it create. A hyperlink also sets its target as the +# value of each empty cell, so these cells become table data: per cell, they +# cost about five times more than merged cells, and their limit is smaller. _MAX_MERGED_CELLS: Final[int] = 100_000 +_MAX_HYPERLINK_CELLS: Final[int] = 10_000 -class _MergedRangeClipper: - """Clip merged ranges so that openpyxl creates a bounded number of cells. +class _RangeBudget: + """Fit cell ranges of one workbook into a budget of created cells. - Merged ranges are kept unchanged while the workbook stays within the cell - budget. A range that does not fit is clipped to the cells with values in its + Ranges are kept unchanged while the workbook stays within the budget. A + range that does not fit is clipped to the cells with values in its worksheet, and to its top-left cell if it still does not fit. """ - def __init__(self, budget: int) -> None: - self._remaining = budget + def __init__(self, cells: int) -> None: + self._remaining = cells self.clipped = 0 - def bind(self, sheet: Worksheet) -> Callable[[MergedCellRange], None]: - """Return a replacement for ``sheet._clean_merge_range``.""" - clean_merge_range = sheet._clean_merge_range # type: ignore[attr-defined] - value_bounds: list[DataRegion | None] = [] + def fit(self, region: DataRegion, bounds: DataRegion | None) -> DataRegion: + """Return the part of a range that fits into the remaining budget.""" + covered = _covered_cells(region) + if covered <= self._remaining: + self._remaining -= covered + return region + self.clipped += 1 + if bounds is not None: + region = DataRegion( + region.min_row, + max(region.min_row, min(region.max_row, bounds.max_row)), + region.min_col, + max(region.min_col, min(region.max_col, bounds.max_col)), + ) + covered = _covered_cells(region) + if covered > self._remaining: + region = DataRegion( + region.min_row, region.min_row, region.min_col, region.min_col + ) + covered = 0 + self._remaining -= covered + return region - def clip_and_clean(merged_range: MergedCellRange) -> None: - # openpyxl binds all cells of a sheet before its merged ranges, so the - # first call sees all values of the sheet. - if not value_bounds: - value_bounds.append(_value_bounds(sheet)) - self._fit(merged_range, value_bounds[0]) - clean_merge_range(merged_range) - return clip_and_clean +def _covered_cells(region: DataRegion) -> int: + """Return the number of cells of a range after its top-left cell.""" + # An empty range, such as rows past the end of a sheet, covers no cells. + if region.width() <= 0 or region.height() <= 0: + return 0 + return region.width() * region.height() - 1 - def _fit(self, merged_range: MergedCellRange, bounds: DataRegion | None) -> None: - covered = _covered_cells(merged_range) - if covered > self._remaining: - self.clipped += 1 - if bounds is not None: - merged_range.max_row = max( - merged_range.min_row, min(merged_range.max_row, bounds.max_row) - ) - merged_range.max_col = max( - merged_range.min_col, min(merged_range.max_col, bounds.max_col) - ) - covered = _covered_cells(merged_range) - if covered > self._remaining: - merged_range.max_row = merged_range.min_row - merged_range.max_col = merged_range.min_col - covered = 0 - self._remaining -= covered +class _SheetRangeClipper: + """Clip the merged and hyperlink ranges of one worksheet while it loads. + + openpyxl has no option to limit how it expands these ranges. The clipper + shadows three methods on the worksheet instance and removes them after the + load; no class or module attribute of openpyxl changes, so concurrent loads + are not affected. + + - ``_clean_merge_range`` creates the ``MergedCell`` objects of a merged range. + - ``iter_rows`` and ``iter_cols`` create the cells of a range lookup such as + ``ws["A1:J10"]``. While a worksheet loads, openpyxl looks up ranges only to + bind hyperlinks; comments use single-cell lookups. + """ + + def __init__( + self, + sheet: Worksheet, + merged_budget: _RangeBudget, + hyperlink_budget: _RangeBudget, + ) -> None: + self._sheet = sheet + self._merged_budget = merged_budget + self._hyperlink_budget = hyperlink_budget + self._value_bounds: list[DataRegion | None] = [] + self._clean_merge_range = sheet._clean_merge_range # type: ignore[attr-defined] + self._iter_rows = sheet.iter_rows + self._iter_cols = sheet.iter_cols + + def install(self) -> None: + self._sheet._clean_merge_range = self.clean_merge_range # type: ignore[attr-defined] + self._sheet.iter_rows = self.iter_rows # type: ignore[method-assign] + self._sheet.iter_cols = self.iter_cols # type: ignore[method-assign] + + def uninstall(self) -> None: + del self._sheet._clean_merge_range # type: ignore[attr-defined] + del self._sheet.iter_rows + del self._sheet.iter_cols + + def _bounds(self) -> DataRegion | None: + # openpyxl binds all cells of a sheet before its ranges, so the first + # call sees all values of the sheet. + if not self._value_bounds: + self._value_bounds.append(_value_bounds(self._sheet)) + return self._value_bounds[0] + + def clean_merge_range(self, merged_range: MergedCellRange) -> None: + region = self._merged_budget.fit( + DataRegion( + merged_range.min_row, + merged_range.max_row, + merged_range.min_col, + merged_range.max_col, + ), + self._bounds(), + ) + merged_range.max_row = region.max_row + merged_range.max_col = region.max_col + self._clean_merge_range(merged_range) + + def _fit( + self, + min_row: int | None, + max_row: int | None, + min_col: int | None, + max_col: int | None, + ) -> DataRegion: + # Same defaults as ``Worksheet.iter_rows`` and ``Worksheet.iter_cols``. + return self._hyperlink_budget.fit( + DataRegion( + min_row or 1, + max_row or self._sheet.max_row, + min_col or 1, + max_col or self._sheet.max_column, + ), + self._bounds(), + ) -def _covered_cells(merged_range: MergedCellRange) -> int: - """Return the number of cells of a merged range after its top-left cell.""" - rows = merged_range.max_row - merged_range.min_row + 1 - cols = merged_range.max_col - merged_range.min_col + 1 - return rows * cols - 1 + def iter_rows( + self, + min_row: int | None = None, + max_row: int | None = None, + min_col: int | None = None, + max_col: int | None = None, + values_only: bool = False, + ) -> Any: + if not any((min_row, max_row, min_col, max_col)): + return self._iter_rows(values_only=values_only) + region = self._fit(min_row, max_row, min_col, max_col) + return self._iter_rows( + region.min_row, region.max_row, region.min_col, region.max_col, values_only + ) + + def iter_cols( + self, + min_col: int | None = None, + max_col: int | None = None, + min_row: int | None = None, + max_row: int | None = None, + values_only: bool = False, + ) -> Any: + if not any((min_row, max_row, min_col, max_col)): + return self._iter_cols(values_only=values_only) + region = self._fit(min_row, max_row, min_col, max_col) + return self._iter_cols( + region.min_col, region.max_col, region.min_row, region.max_row, values_only + ) def _value_bounds(sheet: Worksheet) -> DataRegion | None: @@ -346,25 +449,24 @@ def _value_bounds(sheet: Worksheet) -> DataRegion | None: return DataRegion(min(rows), max(rows), min(cols), max(cols)) -def _read_worksheets_with_clipped_merges( +def _read_worksheets_with_clipped_ranges( workbook: Workbook, read_worksheets: Callable[[], None] ) -> None: - """Run ``read_worksheets`` with merged ranges clipped on each new worksheet.""" - # openpyxl expands each merged range in ``Worksheet._clean_merge_range`` and - # has no option to limit it. Wrap that method on the worksheets of this - # workbook only: ``create_sheet`` is shadowed on this workbook instance while - # its sheets load. No class or module attribute of openpyxl changes, so - # concurrent loads are not affected. - clipper = _MergedRangeClipper(_MAX_MERGED_CELLS) + """Run ``read_worksheets`` with ranges clipped on each new worksheet.""" + # The worksheet methods can only be shadowed once a worksheet exists, so + # ``create_sheet`` is shadowed on this workbook instance while its sheets load. + merged_budget = _RangeBudget(_MAX_MERGED_CELLS) + hyperlink_budget = _RangeBudget(_MAX_HYPERLINK_CELLS) create_sheet = workbook.create_sheet - sheets: list[Worksheet] = [] + clippers: list[_SheetRangeClipper] = [] def create_bounded_sheet( title: str | None = None, index: int | None = None ) -> Worksheet: sheet = create_sheet(title, index) - sheet._clean_merge_range = clipper.bind(sheet) - sheets.append(sheet) + clipper = _SheetRangeClipper(sheet, merged_budget, hyperlink_budget) + clipper.install() + clippers.append(clipper) return sheet workbook.create_sheet = create_bounded_sheet # type: ignore[method-assign] @@ -372,26 +474,33 @@ def create_bounded_sheet( read_worksheets() finally: del workbook.create_sheet - for sheet in sheets: - del sheet._clean_merge_range # type: ignore[attr-defined] - if clipper.clipped: + for clipper in clippers: + clipper.uninstall() + if merged_budget.clipped: _log.warning( - "Clipped %d merged cell ranges that cover more than %d cells", - clipper.clipped, + "Clipped %d merged cell ranges: together they cover more than %d cells", + merged_budget.clipped, _MAX_MERGED_CELLS, ) + if hyperlink_budget.clipped: + _log.warning( + "Clipped %d hyperlink ranges: together they cover more than %d cells", + hyperlink_budget.clipped, + _MAX_HYPERLINK_CELLS, + ) def _load_workbook(filename: str | BytesIO) -> Workbook: """Load a workbook like ``load_workbook(filename, data_only=True)``. - Merged cell ranges are clipped so that they cover a bounded number of cells. + Merged cell and hyperlink ranges are clipped so that they cover a bounded + number of cells. """ reader = ExcelReader(filename, data_only=True) read_worksheets = reader.read_worksheets # ``reader.wb`` exists once ``read`` has read the workbook part, which openpyxl # does before it reads the worksheets. - reader.read_worksheets = lambda: _read_worksheets_with_clipped_merges( # type: ignore[method-assign] + reader.read_worksheets = lambda: _read_worksheets_with_clipped_ranges( # type: ignore[method-assign] reader.wb, read_worksheets ) reader.read() diff --git a/tests/test_backend_msexcel.py b/tests/test_backend_msexcel.py index d8e1ab5a27..f35085da07 100644 --- a/tests/test_backend_msexcel.py +++ b/tests/test_backend_msexcel.py @@ -740,16 +740,14 @@ def test_merged_cells_are_indexed_once_and_preserve_semantics(tmp_path: Path) -> assert comment_map[(3, 3)] == [("Codex", "Synthetic note", None)] -def _replace_merged_range(path: Path, old_ref: str, new_ref: str) -> None: - """Rewrite a merged range of the first sheet, as openpyxl cannot save it.""" +def _rewrite_sheet_xml( + path: Path, old: str, new: str, sheet_part: str = "xl/worksheets/sheet1.xml" +) -> None: + """Rewrite a range in a sheet part, as openpyxl cannot save such ranges.""" with zipfile.ZipFile(path) as source: parts = {info.filename: source.read(info) for info in source.infolist()} - sheet_part = "xl/worksheets/sheet1.xml" - old_attr = f''.encode() - assert old_attr in parts[sheet_part] - parts[sheet_part] = parts[sheet_part].replace( - old_attr, f''.encode() - ) + assert old.encode() in parts[sheet_part] + parts[sheet_part] = parts[sheet_part].replace(old.encode(), new.encode()) with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as target: for name, data in parts.items(): target.writestr(name, data) @@ -763,7 +761,9 @@ def test_whole_sheet_merged_range_is_clipped_to_data(tmp_path: Path) -> None: sheet.merge_cells("A1:B2") file_path = tmp_path / "whole-sheet-merge.xlsx" workbook.save(file_path) - _replace_merged_range(file_path, "A1:B2", "A1:XFD1048576") + _rewrite_sheet_xml( + file_path, '', '' + ) converter = DocumentConverter(allowed_formats=[InputFormat.XLSX]) tracemalloc.start() @@ -794,7 +794,9 @@ def test_merged_range_beyond_data_is_clipped(tmp_path: Path) -> None: sheet.merge_cells("A5:B5") file_path = tmp_path / "partial-merge.xlsx" workbook.save(file_path) - _replace_merged_range(file_path, "A5:B5", "A5:XFD1000") + _rewrite_sheet_xml( + file_path, '', '' + ) in_doc = InputDocument( path_or_stream=file_path, @@ -823,6 +825,60 @@ def test_merged_range_beyond_data_is_clipped(tmp_path: Path) -> None: assert spans == {"Inventory": (1, 3), "Fruit": (2, 1), "Total": (1, 3)} +def test_hyperlink_range_is_clipped_to_data(tmp_path: Path) -> None: + workbook = Workbook() + sheet = workbook.active + sheet["A1"] = "Docs" + sheet["A1"].hyperlink = "https://example.com/docs" + sheet["B3"] = "end" + small = workbook.create_sheet("Small") + small["A1"] = "Home" + small["A1"].hyperlink = "https://example.com/" + small["C3"] = "end" + file_path = tmp_path / "hyperlink-range.xlsx" + workbook.save(file_path) + _rewrite_sheet_xml(file_path, 'ref="A1" r:id', 'ref="A1:J100000" r:id') + _rewrite_sheet_xml( + file_path, + 'ref="A1" r:id', + 'ref="A1:B2" r:id', + sheet_part="xl/worksheets/sheet2.xml", + ) + + in_doc = InputDocument( + path_or_stream=file_path, + format=InputFormat.XLSX, + filename=file_path.stem, + backend=MsExcelDocumentBackend, + ) + tracemalloc.start() + try: + backend = MsExcelDocumentBackend(in_doc=in_doc, path_or_stream=file_path) + doc = backend.convert() + _, peak = tracemalloc.get_traced_memory() + finally: + tracemalloc.stop() + + assert peak < 50 * 1024 * 1024 + loaded_sheet = backend.workbook["Sheet"] + assert loaded_sheet["A1"].hyperlink.target == "https://example.com/docs" + assert (loaded_sheet.max_row, loaded_sheet.max_column) == (3, 2) + loaded_small = backend.workbook["Small"] + linked = { + cell.coordinate + for row in loaded_small.iter_rows() + for cell in row + if cell.hyperlink is not None + } + assert linked == {"A1", "A2", "B1", "B2"} + [docs_table] = [ + table.data + for table in doc.tables + if any(cell.text == "Docs" for cell in table.data.table_cells) + ] + assert (docs_table.num_rows, docs_table.num_cols) == (3, 2) + + def test_split_leading_section_label_helper() -> None: backend = object.__new__(MsExcelDocumentBackend)