From 9518fffcad1673cae81134914f35324acb2d4f40 Mon Sep 17 00:00:00 2001 From: Rafael Teixeira de Lima Date: Mon, 13 Oct 2025 15:02:34 +0200 Subject: [PATCH 1/8] Export of DrawingML figures into docling document --- docling/backend/docx/drawingml/utils.py | 99 +++++++ docling/backend/msword_backend.py | 85 +++++- tests/data/docx/drawingml.docx | Bin 0 -> 41256 bytes .../docling_v2/drawingml.docx.itxt | 13 + .../docling_v2/drawingml.docx.json | 250 ++++++++++++++++++ .../groundtruth/docling_v2/drawingml.docx.md | 7 + 6 files changed, 443 insertions(+), 11 deletions(-) create mode 100644 docling/backend/docx/drawingml/utils.py create mode 100644 tests/data/docx/drawingml.docx create mode 100644 tests/data/groundtruth/docling_v2/drawingml.docx.itxt create mode 100644 tests/data/groundtruth/docling_v2/drawingml.docx.json create mode 100644 tests/data/groundtruth/docling_v2/drawingml.docx.md diff --git a/docling/backend/docx/drawingml/utils.py b/docling/backend/docx/drawingml/utils.py new file mode 100644 index 000000000..50aac2d17 --- /dev/null +++ b/docling/backend/docx/drawingml/utils.py @@ -0,0 +1,99 @@ +import os +import shutil +import subprocess +from pathlib import Path +from tempfile import mkdtemp +from typing import Callable, Optional + +import pypdfium2 +from docx.document import Document +from PIL import Image, ImageChops + + +def get_docx_to_pdf_converter() -> Optional[Callable]: + """ + Detects the best available DOCX to PDF tool and returns a conversion function. + The returned function accepts (input_path, output_path). + Returns None if no tool is available. + """ + + # Try LibreOffice + libreoffice_cmd = shutil.which("libreoffice") or shutil.which("soffice") + if libreoffice_cmd: + + def convert_with_libreoffice(input_path, output_path): + subprocess.run( + [ + libreoffice_cmd, + "--headless", + "--convert-to", + "pdf", + "--outdir", + os.path.dirname(output_path), + input_path, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + + expected_output = os.path.join( + os.path.dirname(output_path), + os.path.splitext(os.path.basename(input_path))[0] + ".pdf", + ) + if expected_output != output_path: + os.rename(expected_output, output_path) + + return convert_with_libreoffice + + ## Space for other DOCX to PDF converters if available + + # No tools found + return None + + +def crop_whitespace(image: Image.Image, bg_color=None, padding=0) -> Image.Image: + if bg_color is None: + bg_color = image.getpixel((0, 0)) + + bg = Image.new(image.mode, image.size, bg_color) + diff = ImageChops.difference(image, bg) + bbox = diff.getbbox() + + if bbox: + left, upper, right, lower = bbox + left = max(0, left - padding) + upper = max(0, upper - padding) + right = min(image.width, right + padding) + lower = min(image.height, lower + padding) + return image.crop((left, upper, right, lower)) + else: + return image + + +def get_pil_from_dml_docx( + docx: Document, converter: Optional[Callable] +) -> Optional[Image.Image]: + if converter is None: + return None + + temp_dir = Path(mkdtemp()) + temp_docx = Path(temp_dir / "drawing_only.docx") + temp_pdf = Path(temp_dir / "drawing_only.pdf") + + # 1) Save docx temporarily + docx.save(str(temp_docx)) + + # 2) Export to PDF + converter(temp_docx, temp_pdf) + + # 3) Load PDF as PNG + pdf = pypdfium2.PdfDocument(temp_pdf) + page = pdf[0] + image = crop_whitespace(page.render(scale=2).to_pil()) + page.close() + pdf.close() + + shutil.rmtree(temp_dir, ignore_errors=True) + + return image diff --git a/docling/backend/msword_backend.py b/docling/backend/msword_backend.py index 6bc9b906b..3ab8388fd 100644 --- a/docling/backend/msword_backend.py +++ b/docling/backend/msword_backend.py @@ -1,5 +1,6 @@ import logging import re +from copy import deepcopy from io import BytesIO from pathlib import Path from typing import Any, List, Optional, Union @@ -33,6 +34,10 @@ from typing_extensions import override from docling.backend.abstract_backend import DeclarativeDocumentBackend +from docling.backend.docx.drawingml.utils import ( + get_docx_to_pdf_converter, + get_pil_from_dml_docx, +) from docling.backend.docx.latex.omml import oMath2Latex from docling.datamodel.base_models import InputFormat from docling.datamodel.document import InputDocument @@ -64,6 +69,8 @@ def __init__( self.equation_bookends: str = "{EQ}" # Track processed textbox elements to avoid duplication self.processed_textbox_elements: List[int] = [] + # Get docx 2 pdf converter if available + self.docx_to_pdf_converter = get_docx_to_pdf_converter() for i in range(-1, self.max_levels): self.parents[i] = None @@ -80,18 +87,11 @@ def __init__( "indents": [None], } - self.docx_obj = None - try: - if isinstance(self.path_or_stream, BytesIO): - self.docx_obj = Document(self.path_or_stream) - elif isinstance(self.path_or_stream, Path): - self.docx_obj = Document(str(self.path_or_stream)) - + self.docx_obj = self.load_msword_file( + path_or_stream=self.path_or_stream, document_hash=self.document_hash + ) + if self.docx_obj: self.valid = True - except Exception as e: - raise RuntimeError( - f"MsWordDocumentBackend could not load document with hash {self.document_hash}" - ) from e @override def is_valid(self) -> bool: @@ -139,6 +139,22 @@ def convert(self) -> DoclingDocument: f"Cannot convert doc with {self.document_hash} because the backend failed to init." ) + @staticmethod + def load_msword_file( + path_or_stream: Union[BytesIO, Path], document_hash: str + ) -> DocxDocument: + try: + if isinstance(path_or_stream, BytesIO): + return Document(path_or_stream) + elif isinstance(path_or_stream, Path): + return Document(str(path_or_stream)) + else: + return None + except Exception as e: + raise RuntimeError( + f"MsWordDocumentBackend could not load document with hash {document_hash}" + ) from e + def _update_history( self, name: str, @@ -195,6 +211,7 @@ def _walk_linear( } xpath_expr = etree.XPath(".//a:blip", namespaces=namespaces) drawing_blip = xpath_expr(element) + drawingml_els = element.findall(".//w:drawing", namespaces=namespaces) # Check for textbox content - check multiple textbox formats # Only process if the element hasn't been processed before @@ -274,6 +291,16 @@ def _walk_linear( ): te1 = self._handle_text_elements(element, docx_obj, doc) added_elements.extend(te1) + # Check for DrawingML elements + elif drawingml_els: + if self.docx_to_pdf_converter is None: + _log.warning( + "Found DrawingML elements in document, but no DOCX to PDF converters. " + "If you want these exported, make sure you have " + "LibreOffice binary in PATH. " + ) + else: + self._handle_drawingml(doc=doc, drawingml_els=drawingml_els) # Check for the sdt containers, like table of contents elif tag_name in ["sdt"]: sdt_content = element.find(".//w:sdtContent", namespaces=namespaces) @@ -1381,3 +1408,39 @@ def get_docx_image(drawing_blip: Any) -> Optional[bytes]: ) elem_ref.append(p3.get_ref()) return elem_ref + + def _handle_drawingml(self, doc: DoclingDocument, drawingml_els: Any): + # 1) Make an empty copy of the original document + dml_doc = self.load_msword_file(self.path_or_stream, self.document_hash) + body = dml_doc._element.body + for child in list(body): + body.remove(child) + + # 2) Add DrawingML to empty document + new_para = dml_doc.add_paragraph() + new_r = new_para.add_run() + for dml in drawingml_els: + new_r._r.append(deepcopy(dml)) + + # 3) Export DOCX->PDF->PNG and save it in DoclingDocument + level = self._get_level() + try: + pil_image = get_pil_from_dml_docx( + dml_doc, converter=self.docx_to_pdf_converter + ) + if pil_image is None: + raise UnidentifiedImageError + + doc.add_picture( + parent=self.parents[level - 1], + image=ImageRef.from_pil(image=pil_image, dpi=72), + caption=None, + ) + except (UnidentifiedImageError, OSError): + _log.warning("Warning: DrawingML image cannot be loaded by Pillow") + doc.add_picture( + parent=self.parents[level - 1], + caption=None, + ) + + return diff --git a/tests/data/docx/drawingml.docx b/tests/data/docx/drawingml.docx new file mode 100644 index 0000000000000000000000000000000000000000..9ac2526467597827ec56f9153a1cf01dc782f6d6 GIT binary patch literal 41256 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
Pn@&*rJ%% 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

{cT=a{%U-Z*X;rM9_amJ0eE4`c!w{x_i!HB)u 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 W4Ec~8gwBcq&ocvHtu + + \ No newline at end of file From 5092cf8e4c7b0498cb3ca8c05d6eef2620f77e3f Mon Sep 17 00:00:00 2001 From: Rafael Teixeira de Lima Date: Mon, 13 Oct 2025 16:00:11 +0200 Subject: [PATCH 2/8] Adding libreoffice env var and libreoffice to checks image Signed-off-by: Rafael Teixeira de Lima --- .github/workflows/checks.yml | 6 +++--- docling/backend/docx/drawingml/utils.py | 12 +++++++++++- docling/backend/msword_backend.py | 2 +- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index b403da25b..993d49bef 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -83,7 +83,7 @@ jobs: if [[ "${{ steps.apt-cache.outputs.cache-hit }}" != "true" ]]; then sudo apt-get -qq update fi - sudo apt-get -qq install -y ffmpeg tesseract-ocr tesseract-ocr-eng tesseract-ocr-fra tesseract-ocr-deu tesseract-ocr-spa tesseract-ocr-script-latn libleptonica-dev libtesseract-dev pkg-config + sudo apt-get -qq install -y ffmpeg tesseract-ocr tesseract-ocr-eng tesseract-ocr-fra tesseract-ocr-deu tesseract-ocr-spa tesseract-ocr-script-latn libleptonica-dev libtesseract-dev libreoffice pkg-config - name: Set TESSDATA_PREFIX run: echo "TESSDATA_PREFIX=$(dpkg -L tesseract-ocr-eng | grep tessdata$)" >> "$GITHUB_ENV" @@ -152,7 +152,7 @@ jobs: if [[ "${{ steps.apt-cache.outputs.cache-hit }}" != "true" ]]; then sudo apt-get -qq update fi - sudo apt-get -qq install -y ffmpeg tesseract-ocr tesseract-ocr-eng tesseract-ocr-fra tesseract-ocr-deu tesseract-ocr-spa tesseract-ocr-script-latn libleptonica-dev libtesseract-dev pkg-config + sudo apt-get -qq install -y ffmpeg tesseract-ocr tesseract-ocr-eng tesseract-ocr-fra tesseract-ocr-deu tesseract-ocr-spa tesseract-ocr-script-latn libleptonica-dev libtesseract-dev libreoffice pkg-config - name: Set TESSDATA_PREFIX run: echo "TESSDATA_PREFIX=$(dpkg -L tesseract-ocr-eng | grep tessdata$)" >> "$GITHUB_ENV" @@ -226,7 +226,7 @@ jobs: if [[ "${{ steps.apt-cache.outputs.cache-hit }}" != "true" ]]; then sudo apt-get -qq update fi - sudo apt-get -qq install -y ffmpeg tesseract-ocr tesseract-ocr-eng tesseract-ocr-fra tesseract-ocr-deu tesseract-ocr-spa tesseract-ocr-script-latn libleptonica-dev libtesseract-dev pkg-config + sudo apt-get -qq install -y ffmpeg tesseract-ocr tesseract-ocr-eng tesseract-ocr-fra tesseract-ocr-deu tesseract-ocr-spa tesseract-ocr-script-latn libleptonica-dev libtesseract-dev libreoffice pkg-config - name: Set TESSDATA_PREFIX run: echo "TESSDATA_PREFIX=$(dpkg -L tesseract-ocr-eng | grep tessdata$)" >> "$GITHUB_ENV" diff --git a/docling/backend/docx/drawingml/utils.py b/docling/backend/docx/drawingml/utils.py index 50aac2d17..8acab6642 100644 --- a/docling/backend/docx/drawingml/utils.py +++ b/docling/backend/docx/drawingml/utils.py @@ -18,7 +18,17 @@ def get_docx_to_pdf_converter() -> Optional[Callable]: """ # Try LibreOffice - libreoffice_cmd = shutil.which("libreoffice") or shutil.which("soffice") + libreoffice_cmd = ( + os.getenv("DOCLING_LIBREOFFICE_CMD", None) + or shutil.which("libreoffice") + or shutil.which("soffice") + or ( + "/Applications/LibreOffice.app/Contents/MacOS/soffice" + if os.path.isfile("/Applications/LibreOffice.app/Contents/MacOS/soffice") + else None + ) + ) + if libreoffice_cmd: def convert_with_libreoffice(input_path, output_path): diff --git a/docling/backend/msword_backend.py b/docling/backend/msword_backend.py index 3ab8388fd..a6e88db5a 100644 --- a/docling/backend/msword_backend.py +++ b/docling/backend/msword_backend.py @@ -297,7 +297,7 @@ def _walk_linear( _log.warning( "Found DrawingML elements in document, but no DOCX to PDF converters. " "If you want these exported, make sure you have " - "LibreOffice binary in PATH. " + "LibreOffice binary in PATH or specify its path with DOCLING_LIBREOFFICE_CMD. " ) else: self._handle_drawingml(doc=doc, drawingml_els=drawingml_els) From fc49e807cd212df9d3fa09b2b062583cf29eb230 Mon Sep 17 00:00:00 2001 From: Rafael Teixeira de Lima Date: Mon, 13 Oct 2025 16:01:29 +0200 Subject: [PATCH 3/8] DCO Remediation Commit for Rafael Teixeira de Lima I, Rafael Teixeira de Lima , hereby add my Signed-off-by to this commit: 9518fffcad1673cae81134914f35324acb2d4f40 Signed-off-by: Rafael Teixeira de Lima From 72ad04f0224d77dba95b382dc4a53b139443a5c6 Mon Sep 17 00:00:00 2001 From: Rafael Teixeira de Lima Date: Mon, 13 Oct 2025 17:21:35 +0200 Subject: [PATCH 4/8] Enforcing apt get update Signed-off-by: Rafael Teixeira de Lima --- .github/workflows/checks.yml | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 993d49bef..ed30e22ba 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -80,9 +80,7 @@ jobs: - name: Install System Dependencies run: | - if [[ "${{ steps.apt-cache.outputs.cache-hit }}" != "true" ]]; then - sudo apt-get -qq update - fi + sudo apt-get -qq update sudo apt-get -qq install -y ffmpeg tesseract-ocr tesseract-ocr-eng tesseract-ocr-fra tesseract-ocr-deu tesseract-ocr-spa tesseract-ocr-script-latn libleptonica-dev libtesseract-dev libreoffice pkg-config - name: Set TESSDATA_PREFIX @@ -149,9 +147,7 @@ jobs: - name: Install System Dependencies run: | - if [[ "${{ steps.apt-cache.outputs.cache-hit }}" != "true" ]]; then - sudo apt-get -qq update - fi + sudo apt-get -qq update sudo apt-get -qq install -y ffmpeg tesseract-ocr tesseract-ocr-eng tesseract-ocr-fra tesseract-ocr-deu tesseract-ocr-spa tesseract-ocr-script-latn libleptonica-dev libtesseract-dev libreoffice pkg-config - name: Set TESSDATA_PREFIX @@ -223,9 +219,7 @@ jobs: - name: Install System Dependencies run: | - if [[ "${{ steps.apt-cache.outputs.cache-hit }}" != "true" ]]; then - sudo apt-get -qq update - fi + sudo apt-get -qq update sudo apt-get -qq install -y ffmpeg tesseract-ocr tesseract-ocr-eng tesseract-ocr-fra tesseract-ocr-deu tesseract-ocr-spa tesseract-ocr-script-latn libleptonica-dev libtesseract-dev libreoffice pkg-config - name: Set TESSDATA_PREFIX From 767e2525fbb30c502885269f0aee3332da5b2686 Mon Sep 17 00:00:00 2001 From: Rafael Teixeira de Lima Date: Tue, 14 Oct 2025 09:29:55 +0200 Subject: [PATCH 5/8] Only display drawingml warning once per document Signed-off-by: Rafael Teixeira de Lima --- docling/backend/msword_backend.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docling/backend/msword_backend.py b/docling/backend/msword_backend.py index a6e88db5a..0e344e227 100644 --- a/docling/backend/msword_backend.py +++ b/docling/backend/msword_backend.py @@ -71,6 +71,7 @@ def __init__( self.processed_textbox_elements: List[int] = [] # Get docx 2 pdf converter if available self.docx_to_pdf_converter = get_docx_to_pdf_converter() + self.display_drawingml_warning = True for i in range(-1, self.max_levels): self.parents[i] = None @@ -294,11 +295,13 @@ def _walk_linear( # Check for DrawingML elements elif drawingml_els: if self.docx_to_pdf_converter is None: - _log.warning( - "Found DrawingML elements in document, but no DOCX to PDF converters. " - "If you want these exported, make sure you have " - "LibreOffice binary in PATH or specify its path with DOCLING_LIBREOFFICE_CMD. " - ) + if self.display_drawingml_warning: + _log.warning( + "Found DrawingML elements in document, but no DOCX to PDF converters. " + "If you want these exported, make sure you have " + "LibreOffice binary in PATH or specify its path with DOCLING_LIBREOFFICE_CMD. " + ) + self.display_drawingml_warning = False else: self._handle_drawingml(doc=doc, drawingml_els=drawingml_els) # Check for the sdt containers, like table of contents From d90ed7866ab6f8d2ca320e38969d0e29254159d8 Mon Sep 17 00:00:00 2001 From: Michele Dolfi Date: Tue, 14 Oct 2025 14:45:52 +0200 Subject: [PATCH 6/8] add util to test libreoffice and exclude files from test when not found Signed-off-by: Michele Dolfi --- docling/backend/docx/drawingml/utils.py | 40 +++++++++++++++++++------ tests/test_backend_msword.py | 19 ++++++++++++ 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/docling/backend/docx/drawingml/utils.py b/docling/backend/docx/drawingml/utils.py index 8acab6642..73b28576b 100644 --- a/docling/backend/docx/drawingml/utils.py +++ b/docling/backend/docx/drawingml/utils.py @@ -10,17 +10,11 @@ from PIL import Image, ImageChops -def get_docx_to_pdf_converter() -> Optional[Callable]: - """ - Detects the best available DOCX to PDF tool and returns a conversion function. - The returned function accepts (input_path, output_path). - Returns None if no tool is available. - """ +def get_libreoffice_cmd(raise_if_unavailable: bool = False) -> Optional[str]: + """Return the libreoffice cmd and optionally test it.""" - # Try LibreOffice libreoffice_cmd = ( - os.getenv("DOCLING_LIBREOFFICE_CMD", None) - or shutil.which("libreoffice") + shutil.which("libreoffice") or shutil.which("soffice") or ( "/Applications/LibreOffice.app/Contents/MacOS/soffice" @@ -29,6 +23,34 @@ def get_docx_to_pdf_converter() -> Optional[Callable]: ) ) + if raise_if_unavailable: + if libreoffice_cmd is None: + raise RuntimeError("Libreoffice not found") + + # The following test will raise if the libreoffice_cmd cannot be used + subprocess.run( + [ + libreoffice_cmd, + "-h", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + + return libreoffice_cmd + + +def get_docx_to_pdf_converter() -> Optional[Callable]: + """ + Detects the best available DOCX to PDF tool and returns a conversion function. + The returned function accepts (input_path, output_path). + Returns None if no tool is available. + """ + + # Try LibreOffice + libreoffice_cmd = get_libreoffice_cmd() + if libreoffice_cmd: def convert_with_libreoffice(input_path, output_path): diff --git a/tests/test_backend_msword.py b/tests/test_backend_msword.py index 9da0ea253..716bf349c 100644 --- a/tests/test_backend_msword.py +++ b/tests/test_backend_msword.py @@ -1,7 +1,9 @@ +import os from pathlib import Path import pytest +from docling.backend.docx.drawingml.utils import get_libreoffice_cmd from docling.backend.msword_backend import MsWordDocumentBackend from docling.datamodel.base_models import InputFormat from docling.datamodel.document import ( @@ -17,6 +19,7 @@ from .verify_utils import verify_document, verify_export GENERATE = GEN_TEST_DATA +IS_CI = bool(os.getenv("CI")) @pytest.mark.xfail(strict=False) @@ -87,6 +90,22 @@ def _test_e2e_docx_conversions_impl(docx_paths: list[Path]): for docx_path in docx_paths: # print(f"converting {docx_path}") + has_libreoffice = False + try: + cmd = get_libreoffice_cmd() + if cmd is not None: + has_libreoffice = True + except Exception: + pass + + if ( + not IS_CI + and not has_libreoffice + and str(docx_path) in ("tests/data/docx/drawingml.docx",) + ): + print(f"Skipping {docx_path} because no Libreoffice is installed.") + continue + gt_path = ( docx_path.parent.parent / "groundtruth" / "docling_v2" / docx_path.name ) From 049b161daa0bcae927018af64a01de38a87dfd23 Mon Sep 17 00:00:00 2001 From: Michele Dolfi Date: Tue, 14 Oct 2025 14:49:36 +0200 Subject: [PATCH 7/8] check libreoffice only once Signed-off-by: Michele Dolfi --- tests/test_backend_msword.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/tests/test_backend_msword.py b/tests/test_backend_msword.py index 716bf349c..8959f8f94 100644 --- a/tests/test_backend_msword.py +++ b/tests/test_backend_msword.py @@ -87,17 +87,15 @@ def get_converter(): def _test_e2e_docx_conversions_impl(docx_paths: list[Path]): converter = get_converter() - for docx_path in docx_paths: - # print(f"converting {docx_path}") - - has_libreoffice = False - try: - cmd = get_libreoffice_cmd() - if cmd is not None: - has_libreoffice = True - except Exception: - pass + has_libreoffice = False + try: + cmd = get_libreoffice_cmd(raise_if_unavailable=True) + if cmd is not None: + has_libreoffice = True + except Exception: + pass + for docx_path in docx_paths: if ( not IS_CI and not has_libreoffice From ad2f1861e906ceac6381be1bddbf93bf541e72e6 Mon Sep 17 00:00:00 2001 From: Rafael Teixeira de Lima Date: Tue, 14 Oct 2025 16:04:44 +0200 Subject: [PATCH 8/8] Only initialise converter if needed Signed-off-by: Rafael Teixeira de Lima --- docling/backend/msword_backend.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/docling/backend/msword_backend.py b/docling/backend/msword_backend.py index 0e344e227..105ceb763 100644 --- a/docling/backend/msword_backend.py +++ b/docling/backend/msword_backend.py @@ -3,7 +3,7 @@ from copy import deepcopy from io import BytesIO from pathlib import Path -from typing import Any, List, Optional, Union +from typing import Any, Callable, List, Optional, Union from docling_core.types.doc import ( DocItemLabel, @@ -36,6 +36,7 @@ from docling.backend.abstract_backend import DeclarativeDocumentBackend from docling.backend.docx.drawingml.utils import ( get_docx_to_pdf_converter, + get_libreoffice_cmd, get_pil_from_dml_docx, ) from docling.backend.docx.latex.omml import oMath2Latex @@ -69,8 +70,8 @@ def __init__( self.equation_bookends: str = "{EQ}" # Track processed textbox elements to avoid duplication self.processed_textbox_elements: List[int] = [] - # Get docx 2 pdf converter if available - self.docx_to_pdf_converter = get_docx_to_pdf_converter() + self.docx_to_pdf_converter: Optional[Callable] = None + self.docx_to_pdf_converter_init = False self.display_drawingml_warning = True for i in range(-1, self.max_levels): @@ -294,14 +295,22 @@ def _walk_linear( added_elements.extend(te1) # Check for DrawingML elements elif drawingml_els: + if ( + self.docx_to_pdf_converter is None + and self.docx_to_pdf_converter_init is False + ): + self.docx_to_pdf_converter = get_docx_to_pdf_converter() + self.docx_to_pdf_converter_init = True + if self.docx_to_pdf_converter is None: if self.display_drawingml_warning: - _log.warning( - "Found DrawingML elements in document, but no DOCX to PDF converters. " - "If you want these exported, make sure you have " - "LibreOffice binary in PATH or specify its path with DOCLING_LIBREOFFICE_CMD. " - ) - self.display_drawingml_warning = False + if self.docx_to_pdf_converter is None: + _log.warning( + "Found DrawingML elements in document, but no DOCX to PDF converters. " + "If you want these exported, make sure you have " + "LibreOffice binary in PATH or specify its path with DOCLING_LIBREOFFICE_CMD." + ) + self.display_drawingml_warning = False else: self._handle_drawingml(doc=doc, drawingml_els=drawingml_els) # Check for the sdt containers, like table of contents