From 209b9a72f366f213ab66a03c5fb94913b35a3706 Mon Sep 17 00:00:00 2001 From: Rossi-Luciano Date: Thu, 20 Aug 2026 21:46:11 -0300 Subject: [PATCH] =?UTF-8?q?fix:=20passa=20largura=20expl=C3=ADcita=20ao=20?= =?UTF-8?q?inserir=20figura,=20evitando=20mismatch=20de=20DPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _try_insert_picture deixava o python-docx inferir a largura da imagem por conta própria (padrão 72 DPI quando ausente metadado), independente da _infer_image_dpi já usada para decidir a largura da figura (padrão 96 DPI) - os dois podiam divergir em ~33% para a mesma imagem. Passa a largura já decidida (capada ao teto disponível, nunca ampliada) explicitamente para add_picture(width=...). Refs #1278. --- .../sps/formats/pdf/renderer/docx/figure.py | 38 +++++++- tests/sps/formats/pdf/renderer/__init__.py | 0 .../sps/formats/pdf/renderer/docx/__init__.py | 0 .../formats/pdf/renderer/docx/test_figure.py | 89 +++++++++++++++++++ 4 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 tests/sps/formats/pdf/renderer/__init__.py create mode 100644 tests/sps/formats/pdf/renderer/docx/__init__.py create mode 100644 tests/sps/formats/pdf/renderer/docx/test_figure.py diff --git a/packtools/sps/formats/pdf/renderer/docx/figure.py b/packtools/sps/formats/pdf/renderer/docx/figure.py index 4139d5cc4..1ef6eced3 100644 --- a/packtools/sps/formats/pdf/renderer/docx/figure.py +++ b/packtools/sps/formats/pdf/renderer/docx/figure.py @@ -22,11 +22,12 @@ def add_figure(docx, figure_data, header_style_name='SCL Table Heading', page_at single_col_width = _compute_single_column_width(page_attributes) - target_width = content_width if layout == pdf_enum.SINGLE_COLUMN_PAGE_LABEL else single_col_width + ceiling_width = content_width if layout == pdf_enum.SINGLE_COLUMN_PAGE_LABEL else single_col_width context = _get_docx_context(docx) href, alt = _extract_figure_meta(figure_data) img_path = _resolve_image_path(href, context) + target_width = _natural_width_capped(img_path, ceiling_width) picture_added = _try_insert_picture(docx, img_path, target_width, page_attributes) if not picture_added: _add_alt_paragraph(docx, alt) @@ -191,15 +192,44 @@ def _resolve_image_path(href, context): return img_path +def _natural_width_capped(img_path, ceiling_width): + """ + Compute the image's natural width from its DPI metadata, capped at + ceiling_width - only ever shrunk to fit, never enlarged. + """ + if not (img_path and os.path.exists(img_path)): + return ceiling_width + try: + from PIL import Image + + with Image.open(img_path) as im: + px_w = im.width + dpi = _infer_image_dpi(im) + natural_width = Cm((px_w / max(1.0, dpi)) * 2.54) + return natural_width if natural_width < ceiling_width else ceiling_width + except Exception: + return ceiling_width + def _try_insert_picture(docx, img_path, content_width, page_attributes): - """Try to insert a picture into the document, scaling it to fit content width. Returns True if successful.""" + """ + Insert a picture at content_width explicitly, then apply + _scale_picture_to_fit as a height safety net. Returns True if successful. + + content_width is passed to add_picture(width=...) instead of left for + python-docx to infer: python-docx reads DPI metadata independently of + _infer_image_dpi (used above to compute content_width in the first + place), and the two can disagree - e.g. a PNG without DPI metadata makes + python-docx default to 72 DPI while _infer_image_dpi defaults to 96 DPI, + a ~33% size difference. Passing the width explicitly makes content_width + the actual inserted size. + """ if not (img_path and os.path.exists(img_path)): return False try: pic_para = docx.add_paragraph() run = pic_para.add_run() - picture = run.add_picture(img_path) - + picture = run.add_picture(img_path, width=content_width) + _scale_picture_to_fit(picture, content_width, page_attributes) pic_para.alignment = WD_ALIGN_PARAGRAPH.CENTER diff --git a/tests/sps/formats/pdf/renderer/__init__.py b/tests/sps/formats/pdf/renderer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/sps/formats/pdf/renderer/docx/__init__.py b/tests/sps/formats/pdf/renderer/docx/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/sps/formats/pdf/renderer/docx/test_figure.py b/tests/sps/formats/pdf/renderer/docx/test_figure.py new file mode 100644 index 000000000..092fc3c31 --- /dev/null +++ b/tests/sps/formats/pdf/renderer/docx/test_figure.py @@ -0,0 +1,89 @@ +import os +import tempfile +import unittest + +from docx import Document +from docx.shared import Cm +from PIL import Image + +from packtools.sps.formats.pdf.renderer.docx.figure import ( + _infer_image_dpi, + _natural_width_capped, + add_figure, +) + + +class TestNaturalWidthCapped(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(self.tmpdir.cleanup) + + def _make_png(self, px_width=200, px_height=100, dpi=None): + path = os.path.join(self.tmpdir.name, "img.png") + im = Image.new("RGB", (px_width, px_height), color="white") + if dpi: + im.save(path, format="PNG", dpi=(dpi, dpi)) + else: + im.save(path, format="PNG") + return path + + def test_natural_width_shrinks_to_ceiling_when_larger(self): + img_path = self._make_png(px_width=4000, dpi=96) + ceiling = Cm(10) + result = _natural_width_capped(img_path, ceiling) + self.assertEqual(result, ceiling) + + def test_natural_width_kept_when_smaller_than_ceiling(self): + img_path = self._make_png(px_width=200, dpi=96) + ceiling = Cm(10) + result = _natural_width_capped(img_path, ceiling) + expected = Cm((200 / 96) * 2.54) + # PNG's pHYs chunk stores pixels-per-meter (integer), so the DPI read + # back after a save/load round-trip is a close approximation, not + # bit-exact - allow a small tolerance instead of exact EMU equality. + self.assertAlmostEqual(int(result), int(expected), delta=5000) + self.assertLess(result, ceiling) + + def test_missing_file_falls_back_to_ceiling(self): + ceiling = Cm(10) + result = _natural_width_capped("/no/such/file.png", ceiling) + self.assertEqual(result, ceiling) + + +class TestAddFigureInsertedWidth(unittest.TestCase): + """ + Regression test for the DPI mismatch between _infer_image_dpi (used to + decide the figure's width) and python-docx's own DPI reading (used when + add_picture() is called without an explicit width, defaulting to 72 DPI + vs _infer_image_dpi's 96 DPI fallback - a ~33% size difference). + """ + + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(self.tmpdir.cleanup) + + def test_inserted_picture_width_matches_infer_image_dpi_not_python_docx_default(self): + img_path = os.path.join(self.tmpdir.name, "no_dpi.png") + Image.new("RGB", (200, 100), color="white").save(img_path, format="PNG") + + docx = Document() + figure_data = {"href": img_path, "label": "Figure 1", "caption": "test"} + add_figure(docx, figure_data) + + self.assertEqual(len(docx.inline_shapes), 1) + inserted_width = docx.inline_shapes[0].width + + with Image.open(img_path) as im: + dpi = _infer_image_dpi(im) + expected_width = int(Cm((200 / dpi) * 2.54)) + + self.assertEqual(inserted_width, expected_width) + + # python-docx's own (unfixed) default would have produced 96/72 = 1.333x + # this width instead - assert we are NOT that value. + python_docx_default_width = int(Cm((200 / 72) * 2.54)) + self.assertNotEqual(inserted_width, python_docx_default_width) + + +if __name__ == "__main__": + unittest.main()