From 3e75c111b7de6e6814a4b42dc63e9e148be0309d Mon Sep 17 00:00:00 2001 From: Rossi-Luciano Date: Mon, 24 Aug 2026 11:08:56 -0300 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20autodetecta=20bin=C3=A1rio=20do=20Li?= =?UTF-8?q?breOffice=20na=20convers=C3=A3o=20de=20DOCX=20para=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit convert_docx_to_pdf tinha default="libreoffice", mas o CLI (pdf_generator.py) sempre repassava arguments.libreoffice_binary explicitamente, que fica None quando --libreoffice-binary não é informado - isso sobrescrevia o default da função (default de parâmetro só vale quando o argumento não é passado na chamada) e quebrava o subprocess com TypeError, mesmo com o LibreOffice instalado e no PATH. Move a resolução do binário para dentro de convert_docx_to_pdf: autodetecta via shutil.which (tenta "libreoffice", depois "soffice", já que várias instalações só têm um dos dois) e levanta RuntimeError claro quando nada é encontrado, em vez do TypeError interno do subprocess. Fixes #1291. --- packtools/sps/formats/pdf/utils/file_utils.py | 16 ++++- .../sps/formats/pdf/utils/test_file_utils.py | 60 +++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/packtools/sps/formats/pdf/utils/file_utils.py b/packtools/sps/formats/pdf/utils/file_utils.py index 1e47c4813..12c89fa09 100644 --- a/packtools/sps/formats/pdf/utils/file_utils.py +++ b/packtools/sps/formats/pdf/utils/file_utils.py @@ -9,19 +9,29 @@ class DirectoryRemovalError(Exception): ... -def convert_docx_to_pdf(docx_path, libreoffice_binary="libreoffice"): +def convert_docx_to_pdf(docx_path, libreoffice_binary=None): """ Converts a DOCX file to PDF format using LibreOffice in headless mode. The function runs a subprocess to call LibreOffice, specifying the input DOCX file and the output directory for the generated PDF file. Args: docx_path (str): The path to the DOCX file to be converted. - libreoffice_binary (str): The path to the LibreOffice binary. Defaults to "libreoffice". + libreoffice_binary (str): The path to the LibreOffice binary. If not provided, + it is autodetected on PATH (tries "libreoffice", then "soffice"). Raises: - RuntimeError: If the PDF file was not created successfully. + RuntimeError: If no LibreOffice binary is found, or if the PDF file was not + created successfully. Returns: str: The path to the generated PDF file. """ + if not libreoffice_binary: + libreoffice_binary = shutil.which("libreoffice") or shutil.which("soffice") + if not libreoffice_binary: + raise RuntimeError( + "LibreOffice binary not found on PATH. Install LibreOffice, or pass " + "libreoffice_binary (CLI: --libreoffice-binary) with the path to the " + "'libreoffice' or 'soffice' executable." + ) output_dir = os.path.dirname(docx_path) os.makedirs(output_dir, exist_ok=True) diff --git a/tests/sps/formats/pdf/utils/test_file_utils.py b/tests/sps/formats/pdf/utils/test_file_utils.py index e69de29bb..09c367ded 100644 --- a/tests/sps/formats/pdf/utils/test_file_utils.py +++ b/tests/sps/formats/pdf/utils/test_file_utils.py @@ -0,0 +1,60 @@ +import os +import tempfile +import unittest +from unittest.mock import patch + +from packtools.sps.formats.pdf.utils.file_utils import convert_docx_to_pdf + + +class TestConvertDocxToPdfBinaryResolution(unittest.TestCase): + """ + Regression test for a bug where the CLI always passed an explicit + libreoffice_binary argument (None, when --libreoffice-binary was + omitted) that shadowed this function's own default, breaking the + subprocess call with a TypeError instead of converting the file. + """ + + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(self.tmpdir.cleanup) + self.docx_path = os.path.join(self.tmpdir.name, "doc.docx") + with open(self.docx_path, "wb") as f: + f.write(b"") + self.pdf_path = os.path.join(self.tmpdir.name, "doc.pdf") + + def _touch_pdf(self, *args, **kwargs): + with open(self.pdf_path, "wb") as f: + f.write(b"") + + @patch("packtools.sps.formats.pdf.utils.file_utils.subprocess.run") + def test_explicit_binary_is_respected(self, mock_run): + mock_run.side_effect = self._touch_pdf + convert_docx_to_pdf(self.docx_path, libreoffice_binary="/custom/soffice") + self.assertEqual(mock_run.call_args[0][0][0], "/custom/soffice") + + @patch("packtools.sps.formats.pdf.utils.file_utils.subprocess.run") + @patch("packtools.sps.formats.pdf.utils.file_utils.shutil.which") + def test_autodetects_libreoffice_when_binary_omitted(self, mock_which, mock_run): + mock_which.side_effect = lambda name: "/usr/bin/libreoffice" if name == "libreoffice" else None + mock_run.side_effect = self._touch_pdf + convert_docx_to_pdf(self.docx_path, libreoffice_binary=None) + self.assertEqual(mock_run.call_args[0][0][0], "/usr/bin/libreoffice") + + @patch("packtools.sps.formats.pdf.utils.file_utils.subprocess.run") + @patch("packtools.sps.formats.pdf.utils.file_utils.shutil.which") + def test_falls_back_to_soffice_when_libreoffice_missing(self, mock_which, mock_run): + mock_which.side_effect = lambda name: "/usr/bin/soffice" if name == "soffice" else None + mock_run.side_effect = self._touch_pdf + convert_docx_to_pdf(self.docx_path, libreoffice_binary=None) + self.assertEqual(mock_run.call_args[0][0][0], "/usr/bin/soffice") + + @patch("packtools.sps.formats.pdf.utils.file_utils.subprocess.run") + @patch("packtools.sps.formats.pdf.utils.file_utils.shutil.which", return_value=None) + def test_raises_clear_error_when_no_binary_found(self, mock_which, mock_run): + with self.assertRaises(RuntimeError): + convert_docx_to_pdf(self.docx_path, libreoffice_binary=None) + mock_run.assert_not_called() + + +if __name__ == "__main__": + unittest.main() From a1d31d51cf88719e32d77402c9c31e88f3648532 Mon Sep 17 00:00:00 2001 From: Rossi-Luciano Date: Tue, 25 Aug 2026 08:57:47 -0300 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20ajusta=20exce=C3=A7=C3=A3o=20e=20cor?= =?UTF-8?q?rige=20makedirs=20vazio=20em=20convert=5Fdocx=5Fto=5Fpdf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajustes pedidos na revisão do PR #1292: - Variável do binário resolvido renomeada para "binary" (pode ser libreoffice ou soffice, não só "libreoffice"); ausência agora levanta FileNotFoundError em vez de RuntimeError. - output_dir = os.path.dirname(docx_path) é "" quando docx_path não tem componente de diretório (ex.: CLI com -o saida.pdf), e os.makedirs("") levanta FileNotFoundError - mesmo bug já registrado na issue #773. Corrigido com output_dir = os.path.dirname(docx_path) or "." e pulando o makedirs nesse caso. Validado manualmente com "-o saida.pdf" (sem diretório) - antes quebrava, agora gera o PDF normalmente. --- packtools/sps/formats/pdf/utils/file_utils.py | 24 +++++++------- .../sps/formats/pdf/utils/test_file_utils.py | 32 ++++++++++++++++++- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/packtools/sps/formats/pdf/utils/file_utils.py b/packtools/sps/formats/pdf/utils/file_utils.py index 12c89fa09..941c5ecb4 100644 --- a/packtools/sps/formats/pdf/utils/file_utils.py +++ b/packtools/sps/formats/pdf/utils/file_utils.py @@ -19,25 +19,25 @@ def convert_docx_to_pdf(docx_path, libreoffice_binary=None): libreoffice_binary (str): The path to the LibreOffice binary. If not provided, it is autodetected on PATH (tries "libreoffice", then "soffice"). Raises: - RuntimeError: If no LibreOffice binary is found, or if the PDF file was not - created successfully. + FileNotFoundError: If no LibreOffice binary is found. + RuntimeError: If the PDF file was not created successfully. Returns: str: The path to the generated PDF file. """ - if not libreoffice_binary: - libreoffice_binary = shutil.which("libreoffice") or shutil.which("soffice") - if not libreoffice_binary: - raise RuntimeError( - "LibreOffice binary not found on PATH. Install LibreOffice, or pass " - "libreoffice_binary (CLI: --libreoffice-binary) with the path to the " - "'libreoffice' or 'soffice' executable." + binary = libreoffice_binary or shutil.which("libreoffice") or shutil.which("soffice") + if not binary: + raise FileNotFoundError( + "LibreOffice binary ('libreoffice' or 'soffice') was not found on PATH. " + "Install LibreOffice, or pass libreoffice_binary (CLI: --libreoffice-binary) " + "with the path to the executable." ) - output_dir = os.path.dirname(docx_path) - os.makedirs(output_dir, exist_ok=True) + output_dir = os.path.dirname(docx_path) or "." + if output_dir != ".": + os.makedirs(output_dir, exist_ok=True) subprocess.run([ - libreoffice_binary, + binary, '--headless', '--convert-to', 'pdf', diff --git a/tests/sps/formats/pdf/utils/test_file_utils.py b/tests/sps/formats/pdf/utils/test_file_utils.py index 09c367ded..a8d86974d 100644 --- a/tests/sps/formats/pdf/utils/test_file_utils.py +++ b/tests/sps/formats/pdf/utils/test_file_utils.py @@ -51,10 +51,40 @@ def test_falls_back_to_soffice_when_libreoffice_missing(self, mock_which, mock_r @patch("packtools.sps.formats.pdf.utils.file_utils.subprocess.run") @patch("packtools.sps.formats.pdf.utils.file_utils.shutil.which", return_value=None) def test_raises_clear_error_when_no_binary_found(self, mock_which, mock_run): - with self.assertRaises(RuntimeError): + with self.assertRaises(FileNotFoundError): convert_docx_to_pdf(self.docx_path, libreoffice_binary=None) mock_run.assert_not_called() +class TestConvertDocxToPdfRelativeOutputPath(unittest.TestCase): + """ + Regression test for issue #773: os.path.dirname("doc.docx") is "" when + docx_path has no directory component (e.g. CLI -o doc.pdf), and + os.makedirs("") raises FileNotFoundError. + """ + + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(self.tmpdir.cleanup) + self._cwd = os.getcwd() + os.chdir(self.tmpdir.name) + self.addCleanup(os.chdir, self._cwd) + + self.docx_path = "doc.docx" + with open(self.docx_path, "wb") as f: + f.write(b"") + self.pdf_path = "doc.pdf" + + def _touch_pdf(self, *args, **kwargs): + with open(self.pdf_path, "wb") as f: + f.write(b"") + + @patch("packtools.sps.formats.pdf.utils.file_utils.subprocess.run") + def test_docx_path_without_directory_component_does_not_raise(self, mock_run): + mock_run.side_effect = self._touch_pdf + result = convert_docx_to_pdf(self.docx_path, libreoffice_binary="/usr/bin/soffice") + self.assertEqual(os.path.normpath(result), self.pdf_path) + + if __name__ == "__main__": unittest.main()