Skip to content

Optional wolfxl reader for .xlsx files in ExcelExtractor #44848

Open

Description

@wolfiesch

Self Checks

  • I have read the Contributing Guide and Language Policy.
  • I have searched for existing issues search for existing issues, including closed ones.
  • I confirm that I am using English to submit this report, otherwise it will be closed.
  • Please do not modify this template :) and fill in all the required fields.

1. Is this request related to a challenge you're experiencing? Tell me about your story.

ExcelExtractor eagerly reads .xlsx files with openpyxl. For large workbooks, time spent in that read and row-extraction loop can be substantial. Would an optional reader dependency group be acceptable for installations that prioritize this workload?

I have prepared a small change against Dify main adb27d0d016f372fb643fb53c38de91c014be034: a non-default xlsx-wolfxl = ["wolfxl>=2.0.17,<2.1"] dependency group and a guarded reader import. Without wolfxl installed, openpyxl remains the reader. Installing the group selects wolfxl. Missing dependencies inside an installed wolfxl package and workbook parsing errors propagate; they are not retried with openpyxl. The .xls path is unchanged. Official Docker images would remain on openpyxl because they install the default groups.

I maintain wolfxl and would author the change. I am opening this issue to discuss the packaging choice and compatibility tradeoffs. The change is up as a draft PR for reference and will stay in draft until the packaging question is settled here.

2. Additional context or comments

Historical measurements used published wolfxl 2.0.17 and openpyxl 3.1.5, with Pillow 13.0.0. The current prepared lock resolves wolfxl 2.0.18; it has focused test/type-check coverage, but has not been benchmarked or corpus-tested here.

Copied extractor loop, synthetic input openpyxl median seconds wolfxl median seconds openpyxl / wolfxl median peak RSS, MiB
Linux x86_64, CPython 3.13.15, 300,000 rows x 7 15.331 6.392 946.1 / 737.3
macOS arm64, CPython 3.13.9, 300,000 rows x 7 12.085 6.207 1029.1 / 1271.3
Linux, 300 rows x 7 0.012 0.012 29.1 / 30.2
macOS, 300 rows x 7 0.013 0.010 33.9 / 34.6

These are loop-only observations from a standalone copy of the extractor, based on Dify 12ab116fffd4706c20eb618d706cc71e094b519e. They exclude process startup, imports, Document construction, database and storage. The inputs contain no images. Each arm ran five times in alternating openpyxl-then-wolfxl order, with fresh processes, warm page cache, no discarded warmup, 180-second child timeouts and an 1800-second outer timeout. Hosts were shared and unpinned. Cold-cache behavior was not measured. There is no demonstrated small-file gain; rounded millisecond timings overlap. macOS peak RSS is worse with wolfxl for both inputs, as is Linux small-input RSS. This is not an end-to-end Dify speed or memory-reduction claim.

On 353 Apache POI .xlsx fixtures at 4f3c25563919e7efa4a75feccf6cef1ac3d05a04, the 2.0.17 comparison on Linux and macOS found 312 identical document hash/count and image-count results, 5 differing document results, 30 shared exceptions, 1 file read only by openpyxl, 4 read only by wolfxl and 1 hostile file killed with both readers. The 1 wolfxl-only rejection is duplicate-filename-case-insensitive.xlsx, deliberately rejected for duplicate ZIP names. The differing and lenient reads have not been independently certified. This is not full parser parity or a bounded-memory claim. The Linux corpus used CPython 3.12.14; macOS used 3.13.9. Wheel bytes were verified against PyPI; source-commit-to-wheel provenance is unverified.

The evidence archive dify-xlsx-evidence-2.0.17.zip (SHA256 4ef9ad279313ab2bc6c72cfd42b212bdcf1323475be0dc7c4f1e4548af671901) contains exact synthetic inputs, unmodified drivers, raw benchmark and corpus observations, results and SHA256SUMS. To reproduce the large-input measurement from its extracted directory on Linux (POSIX shell):

sha256sum -c SHA256SUMS.txt
UV_NO_BUILD_PACKAGE=wolfxl uv run --no-project --python 3.13.15 --with wolfxl==2.0.17 --with openpyxl==3.1.5 --with Pillow==13.0.0 python run_rss_safe.py bench big300k.xlsx out-linux-big

For the small input, use small300.xlsx and a new output directory. On native macOS arm64, use CPython 3.13.9 and run.py instead; verify hashes with shasum -a 256 -c SHA256SUMS.txt. Keep the input names and use fresh output directories, since raw logs append. The drivers run five iterations per reader with 180-second child timeouts and need PyPI network access. Expected output is 300,000 documents, zero images, text hash prefix 33bcefba97527c1a for the large input, and 300 documents with ff18ac2b8b8ea16c for the small input. Absolute timing and RSS depend on the host.

On the prepared change, uv lock --check, changed-file ruff check/format, make type-check-core, and all 10 tests in test_excel_extractor.py passed. Type checks and tests ran with the optional dependency both absent and installed; actual reader identity was asserted in both environments. These scoped checks do not replace upstream CI.

3. Can you help us with this feature?

  • I am interested in contributing to this feature.

Activity

  1. xz567001-afk commented on Oct 30, 2026

    @xz567001-afk

    One concrete compatibility gate before deciding the packaging policy: exercise an image-only row through the real installed reader, including locked 2.0.18.

    The draft's real-workbook test covers scalar cells; its image tests replace load_workbook with _FakeWorkbook. They don't verify the optional reader's drawing objects. The extractor treats a missing _images attribute as no images, so an otherwise blank image row could produce no document without raising.

    A small .xlsx fixture with an embedded PNG, real reader selection, and test storage/database could assert the markdown image, one upload, and retry reuse in both dependency environments. This would complement the disclosed 2.0.17 corpus results; I haven't run either backend or observed image loss.

    def test_installed_xlsx_reader(self, tmp_path):
    import openpyxl
    if importlib.util.find_spec("wolfxl") is None:
    assert excel_module.load_workbook is openpyxl.load_workbook
    else:
    assert excel_module.load_workbook is importlib.import_module("wolfxl").load_workbook
    path = tmp_path / "reader.xlsx"
    workbook = openpyxl.Workbook()
    sheet = workbook.active
    assert sheet is not None
    sheet.append(["Name", "Count"])
    sheet.append(["Alice", 3])
    workbook.save(path)
    workbook.close()
    documents = ExcelExtractor(str(path)).extract()
    assert [document.page_content for document in documents] == ['"Name":"Alice";"Count":"3"']
    def test_extract_xlsx_with_hyperlinks_and_sheet_skip(self, monkeypatch: pytest.MonkeyPatch):
    sheet_with_data = _FakeSheet(
    header_rows=[("Name", "Link")],
    data_rows=[
    (_FakeCell("Alice"), _FakeCell("Doc", hyperlink=SimpleNamespace(target="https://example.com/doc"))),
    (_FakeCell(None), _FakeCell(123)),
    (_FakeCell(None), _FakeCell(None)),
    ],
    )
    empty_sheet = _FakeSheet(header_rows=[(None, None)], data_rows=[])
    workbook = _FakeWorkbook({"Data": sheet_with_data, "Empty": empty_sheet})
    monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbook)
    extractor = ExcelExtractor("/tmp/sample.xlsx")
    docs = extractor.extract()
    assert workbook.closed is True
    assert len(docs) == 2
    assert docs[0].page_content == '"Name":"Alice";"Link":"[Doc](https://example.com/doc)"'
    assert docs[1].page_content == '"Name":"";"Link":"123"'
    assert all(doc.metadata["source"] == "/tmp/sample.xlsx" for doc in docs)
    def test_extract_xlsx_turns_embedded_images_into_markdown_links(
    self,
    monkeypatch: pytest.MonkeyPatch,
    database_session_maker: sessionmaker[Session],
    ):
    image_bytes = b"\x89PNG\r\n\x1a\nexcel-image"
    sheet = _FakeSheet(
    header_rows=[("Question", "Answer", "Image")],
    data_rows=[
    (_FakeCell("Q1"), _FakeCell("A1"), _FakeCell(None)),
    (_FakeCell("Q2"), _FakeCell("A2"), _FakeCell(None)),
    ],
    images=[
    _FakeImage(image_bytes, row=1, col=2),
    _FakeImage(image_bytes, row=1, col=2),
    ],
    )
    workbook = _FakeWorkbook({"Data": sheet})
    monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbook)
    saves = _patch_image_persistence(monkeypatch)
    extractor = ExcelExtractor(
    "/tmp/sample.xlsx",
    tenant_id="tenant-1",
    user_id="user-1",
    source_file_id="source-file-1",
    )
    docs = extractor.extract()
    upload_files = _get_upload_files(database_session_maker)
    assert workbook.closed is True
    assert len(docs) == 2
    assert len(upload_files) == 1
    assert docs[0].page_content == (
    '"Question":"Q1";"Answer":"A1";'
    f'"Image":"![image](http://files.local/files/{upload_files[0].id}/file-preview) '
    f'![image](http://files.local/files/{upload_files[0].id}/file-preview)"'
    )
    assert docs[1].page_content == '"Question":"Q2";"Answer":"A2";"Image":""'
    assert len(saves) == 1
    assert saves[0][0].startswith("image_files/tenant-1/source-file-1/")
    assert saves[0][0].endswith(".png")
    assert saves[0][1] == image_bytes
    assert upload_files[0].tenant_id == "tenant-1"
    assert upload_files[0].key == saves[0][0]
    assert upload_files[0].used is True
    def test_extract_xlsx_keeps_rows_with_only_embedded_images(
    self,
    monkeypatch: pytest.MonkeyPatch,
    database_session_maker: sessionmaker[Session],
    ):
    image_bytes = b"\x89PNG\r\n\x1a\nimage-only-row"
    sheet = _FakeSheet(
    header_rows=[("Question", "Answer", "Image")],
    data_rows=[
    (_FakeCell(None), _FakeCell(None), _FakeCell(None)),
    (_FakeCell(None), _FakeCell(None), _FakeCell(None)),
    ],
    images=[_FakeImage(image_bytes, row=1, col=2)],
    )
    workbook = _FakeWorkbook({"Data": sheet})
    monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbook)
    saves = _patch_image_persistence(monkeypatch)
    extractor = ExcelExtractor(
    "/tmp/sample.xlsx",
    tenant_id="tenant-1",
    user_id="user-1",
    source_file_id="source-file-1",
    )
    docs = extractor.extract()
    upload_files = _get_upload_files(database_session_maker)
    assert workbook.closed is True
    assert len(docs) == 1
    assert len(upload_files) == 1
    assert docs[0].page_content == (
    f'"Question":"";"Answer":"";"Image":"![image](http://files.local/files/{upload_files[0].id}/file-preview)"'
    )
    assert len(saves) == 1
    def test_extract_xlsx_reuses_existing_embedded_image_uploads_on_retry(
    self,
    monkeypatch: pytest.MonkeyPatch,
    database_session_maker: sessionmaker[Session],
    ):
    image_bytes = b"\x89PNG\r\n\x1a\nretry-safe-image"
    workbooks = [
    _FakeWorkbook(
    {
    "Data": _FakeSheet(
    header_rows=[("Question", "Answer", "Image")],
    data_rows=[(_FakeCell("Q1"), _FakeCell("A1"), _FakeCell(None))],
    images=[_FakeImage(image_bytes, row=1, col=2)],
    )
    }
    ),
    _FakeWorkbook(
    {
    "Data": _FakeSheet(
    header_rows=[("Question", "Answer", "Image")],
    data_rows=[(_FakeCell("Q1"), _FakeCell("A1"), _FakeCell(None))],
    images=[_FakeImage(image_bytes, row=1, col=2)],
    )
    }
    ),
    ]
    monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbooks.pop(0))
    saves = _patch_image_persistence(monkeypatch)
    extractor = ExcelExtractor(
    "/tmp/sample.xlsx",
    tenant_id="tenant-1",
    user_id="user-1",
    source_file_id="source-file-1",
    )
    first_docs = extractor.extract()
    second_docs = extractor.extract()
    upload_files = _get_upload_files(database_session_maker)
    assert len(upload_files) == 1
    expected_page_content = (
    '"Question":"Q1";"Answer":"A1";'
    f'"Image":"![image](http://files.local/files/{upload_files[0].id}/file-preview)"'
    )
    assert first_docs[0].page_content == expected_page_content
    assert second_docs[0].page_content == expected_page_content
    assert len(saves) == 1
    def test_extract_xls_path(self, monkeypatch: pytest.MonkeyPatch):
    class FakeExcelFile:
    sheet_names = ["Sheet1"]
    def parse(self, sheet_name):

    if row_has_content and page_content:
    documents.append(
    Document(page_content=";".join(page_content), metadata={"source": self._file_path})
    )
    finally:
    wb.close()
    elif file_extension == ".xls":
    excel_file = pd.ExcelFile(self._file_path, engine="xlrd")
    for excel_sheet_name in excel_file.sheet_names:
    df = excel_file.parse(sheet_name=excel_sheet_name)
    df.dropna(how="all", inplace=True)
    for _, series_row in df.iterrows():
    page_content = []
    for k, v in series_row.items():
    if pd.notna(v):
    # Escape embedded double quotes like the .xlsx branch
    # does, so quoted cell values do not corrupt the row.
    value = str(v).replace('"', '\\"')
    page_content.append(f'"{k}":"{value}"')
    documents.append(
    Document(page_content=";".join(page_content), metadata={"source": self._file_path})
    )
    else:
    raise ValueError(f"Unsupported file extension: {file_extension}")
    return documents
    def _extract_images_from_sheet(
    self, sheet_name: str, sheet, valid_columns: set[int], min_row: int
    ) -> dict[tuple[int, int], list[str]]:
    """
    Extract embedded worksheet images and map them to their anchor cell.
    Images are stored with deterministic keys derived from the source upload file,
    sheet, anchor cell, and content hash so retried tasks can reuse the same
    UploadFile rows and storage objects.
    """
    if not self._tenant_id or not self._user_id or not self._source_file_id:
    return {}
    images = getattr(sheet, "_images", None) or []
    image_candidates: list[SheetImageCandidate] = []
    for image in images:
    marker = getattr(getattr(image, "anchor", None), "_from", None)
    row_idx = getattr(marker, "row", None)

  2. wolfiesch commented on Oct 31, 2026

    @wolfiesch
    Author

    Thanks, the test you described found a real gap. The cause is row iteration, not image loading.

    Both readers return the embedded image with the same anchor, format and bytes. ExcelExtractor only iterated rows up to sheet.max_row, and a row that holds only an image has no cells, so it was skipped:

    • openpyxl: the 10-row header scan (iter_rows(max_row=10)) creates empty cells and pads max_row to 10, so image-only rows after row 10 are dropped on current main.
    • wolfxl 2.0.18: the scan does not pad max_row, so an image-only row directly below the data was dropped as well.

    #44849 now runs the row loop to the last image anchor (sheets without images iterate as before) and adds a real-workbook test: embedded PNGs anchored at C3 and C12, the installed reader, test storage and database. It asserts the markdown links, one upload per image, and upload reuse on a second extract(). It fails without the fix in both environments (openpyxl misses row 12; wolfxl misses rows 3 and 12) and passes with it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    1.18.0Bugs from 1.18.0

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions