From 5d4c39232825a2a0db45f3c3f2ddada24a727dae Mon Sep 17 00:00:00 2001 From: Linxiushen <1787979356@qq.com> Date: Mon, 21 Sep 2026 11:35:53 +0800 Subject: [PATCH] fix: close the pypdfium2 document in render_pdf_to_png_parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit render_pdf_to_png_parts() (the pdf_to_images before_model_callback, run on every request that carries an application/pdf part) creates pdfium.PdfDocument(pdf_bytes) and returns without closing it. PdfDocument wraps native PDFium memory, has no __del__, and its parent/kids bookkeeping forms a reference cycle, so the handle is only released when the cyclic GC happens to run a collection that reaches it — not when the function returns. Measured with gc disabled: 50 calls leave 50 unclosed native documents; the first gc.collect() still leaves 50, the second frees them. Under load the callback can therefore pile up native PDFium memory between collections. Use the document as a context manager. Closing it also closes the pages and bitmaps created from it, so nothing else changes: the existing 3 tests pass, and the rendered PNG bytes are identical before/after for 5 (pages, max_pages, scale) shapes. With gc disabled the same 50 calls now leave 0. --- veadk/utils/pdf_to_images.py | 44 ++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/veadk/utils/pdf_to_images.py b/veadk/utils/pdf_to_images.py index 6069fe57a..05e13f724 100644 --- a/veadk/utils/pdf_to_images.py +++ b/veadk/utils/pdf_to_images.py @@ -53,26 +53,32 @@ def render_pdf_to_png_parts( """Render up to ``max_pages`` pages of a PDF into ``image/png`` parts.""" import pypdfium2 as pdfium - pdf = pdfium.PdfDocument(pdf_bytes) - page_count = len(pdf) - rendered = min(page_count, max_pages) - if page_count > max_pages: - logger.warning( - f"PDF has {page_count} pages; rendering only the first {max_pages}." - ) - - parts: list[types.Part] = [] - for i in range(rendered): - # pypdfium2 leaves `scale` untyped (default 1), so it is inferred as - # int; floats are valid at runtime (e.g. 1.5x). Cast away the warning. - image = pdf[i].render(scale=scale).to_pil() # type: ignore[arg-type] - buffer = io.BytesIO() - image.save(buffer, format="PNG") - parts.append( - types.Part( - inline_data=types.Blob(mime_type="image/png", data=buffer.getvalue()) + # PdfDocument wraps native PDFium memory. Closing it (the context manager) + # also closes the pages and bitmaps created from it; without that the + # handles only go away once the cyclic GC happens to run, so a busy + # callback can pile up unreleased documents. + with pdfium.PdfDocument(pdf_bytes) as pdf: + page_count = len(pdf) + rendered = min(page_count, max_pages) + if page_count > max_pages: + logger.warning( + f"PDF has {page_count} pages; rendering only the first {max_pages}." + ) + + parts: list[types.Part] = [] + for i in range(rendered): + # pypdfium2 leaves `scale` untyped (default 1), so it is inferred as + # int; floats are valid at runtime (e.g. 1.5x). Cast away the warning. + image = pdf[i].render(scale=scale).to_pil() # type: ignore[arg-type] + buffer = io.BytesIO() + image.save(buffer, format="PNG") + parts.append( + types.Part( + inline_data=types.Blob( + mime_type="image/png", data=buffer.getvalue() + ) + ) ) - ) return parts