diff --git a/packtools/sps/formats/pdf/renderer/docx/figure.py b/packtools/sps/formats/pdf/renderer/docx/figure.py index 93e92edca..ba961d547 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) @@ -214,15 +215,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/docx/test_figure.py b/tests/sps/formats/pdf/renderer/docx/test_figure.py index c8a00c07d..636cb4f25 100644 --- a/tests/sps/formats/pdf/renderer/docx/test_figure.py +++ b/tests/sps/formats/pdf/renderer/docx/test_figure.py @@ -3,9 +3,15 @@ import unittest from docx import Document +from docx.shared import Cm from PIL import Image -from packtools.sps.formats.pdf.renderer.docx.figure import decide_figure_layout +from packtools.sps.formats.pdf.renderer.docx.figure import ( + _infer_image_dpi, + _natural_width_capped, + add_figure, + decide_figure_layout, +) from packtools.sps.formats.pdf import enum as pdf_enum @@ -73,5 +79,77 @@ def test_layout_dpi_override_replaces_embedded_dpi(self): ) +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()