From ae4fa097e7d98a1f488048d9b9606e44a3682537 Mon Sep 17 00:00:00 2001 From: Ernad Husremovic Date: Tue, 2 Sep 2025 19:31:35 +0200 Subject: [PATCH] fix PyPDF2 3.x page copying + rename documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added explicit page copying after cloneReaderDocumentRoot() calls - Renamed PATCH_PDFWRITER.md to PATCH_PYPDF2_PDFWRITER.md - Prevents 327-byte empty PDFs in PyPDF2 3.x 🤖 assisted by claude --- .../models/ir_actions_report.py | 4 + .../models/ir_actions_report.py.backup | 109 ++++++++++++++++++ ...PDFWRITER.md => PATCH_PYPDF2_PDFWRITER.md} | 24 ++-- 3 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 odoo-bringout-oca-ocb-account_edi_ubl_cii/account_edi_ubl_cii/models/ir_actions_report.py.backup rename odoo-bringout-oca-ocb-account_edi_ubl_cii/doc/{PATCH_PDFWRITER.md => PATCH_PYPDF2_PDFWRITER.md} (75%) diff --git a/odoo-bringout-oca-ocb-account_edi_ubl_cii/account_edi_ubl_cii/models/ir_actions_report.py b/odoo-bringout-oca-ocb-account_edi_ubl_cii/account_edi_ubl_cii/models/ir_actions_report.py index 232c5b4..1ac18b5 100644 --- a/odoo-bringout-oca-ocb-account_edi_ubl_cii/account_edi_ubl_cii/models/ir_actions_report.py +++ b/odoo-bringout-oca-ocb-account_edi_ubl_cii/account_edi_ubl_cii/models/ir_actions_report.py @@ -91,6 +91,10 @@ class IrActionsReport(models.Model): # Post-process and embed the additional files. writer = OdooPdfFileWriter() writer.cloneReaderDocumentRoot(reader) + # Copy all pages from the reader to the writer (required for PyPDF2 3.x) + for page_num in range(reader.getNumPages()): + page = reader.getPage(page_num) + writer.addPage(page) # Generate and embed Factur-X xml_content, _errors = self.env['account.edi.xml.cii']._export_invoice(invoice) diff --git a/odoo-bringout-oca-ocb-account_edi_ubl_cii/account_edi_ubl_cii/models/ir_actions_report.py.backup b/odoo-bringout-oca-ocb-account_edi_ubl_cii/account_edi_ubl_cii/models/ir_actions_report.py.backup new file mode 100644 index 0000000..232c5b4 --- /dev/null +++ b/odoo-bringout-oca-ocb-account_edi_ubl_cii/account_edi_ubl_cii/models/ir_actions_report.py.backup @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models +from odoo.tools import cleanup_xml_node +from odoo.tools.pdf import OdooPdfFileReader, OdooPdfFileWriter + +from lxml import etree +import base64 +from xml.sax.saxutils import escape, quoteattr +import io + + +class IrActionsReport(models.Model): + _inherit = 'ir.actions.report' + + def _is_invoice_report(self, report_ref): + # EXTENDS account + # allows to add factur-x.xml to custom PDF templates (comma separated list of template names) + custom_templates = self.env['ir.config_parameter'].sudo().get_param('account.custom_templates_facturx_list', '') + custom_templates = [report.strip() for report in custom_templates.split(',')] + return super()._is_invoice_report(report_ref) or self._get_report(report_ref).report_name in custom_templates + + def _add_pdf_into_invoice_xml(self, invoice, stream_data): + format_codes = ['ubl_bis3', 'ubl_de', 'nlcius_1', 'efff_1'] + edi_attachments = invoice.edi_document_ids.filtered(lambda d: d.edi_format_id.code in format_codes).sudo().attachment_id + for edi_attachment in edi_attachments: + old_xml = base64.b64decode(edi_attachment.with_context(bin_size=False).datas, validate=True) + tree = etree.fromstring(old_xml) + anchor_elements = tree.xpath("//*[local-name()='AccountingSupplierParty']") + additional_document_elements = tree.xpath("//*[local-name()='AdditionalDocumentReference']") + # with this clause, we ensure the xml are only postprocessed once (even when the invoice is reset to + # draft then validated again) + if anchor_elements and not additional_document_elements: + pdf_stream = stream_data['stream'] + pdf_content_b64 = base64.b64encode(pdf_stream.getvalue()).decode() + pdf_name = '%s.pdf' % invoice.name.replace('/', '_') + to_inject = ''' + + %s + + + %s + + + + ''' % (escape(pdf_name), quoteattr(pdf_name), pdf_content_b64) + + anchor_index = tree.index(anchor_elements[0]) + tree.insert(anchor_index, etree.fromstring(to_inject)) + new_xml = etree.tostring(cleanup_xml_node(tree), xml_declaration=True, encoding='UTF-8') + edi_attachment.sudo().write({ + 'res_model': 'account.move', + 'res_id': invoice.id, + 'datas': base64.b64encode(new_xml), + 'mimetype': 'application/xml', + }) + + def _render_qweb_pdf_prepare_streams(self, report_ref, data, res_ids=None): + # EXTENDS base + # Add the pdf report in the XML as base64 string. + collected_streams = super()._render_qweb_pdf_prepare_streams(report_ref, data, res_ids=res_ids) + + if collected_streams \ + and res_ids \ + and self._is_invoice_report(report_ref): + for res_id, stream_data in collected_streams.items(): + invoice = self.env['account.move'].browse(res_id) + self._add_pdf_into_invoice_xml(invoice, stream_data) + + # If Factur-X isn't already generated, generate and embed it inside the PDF + if len(res_ids) == 1: + invoice = self.env['account.move'].browse(res_ids) + edi_doc_codes = invoice.edi_document_ids.edi_format_id.mapped('code') + # If Factur-X hasn't been generated, generate and embed it anyway + if invoice.is_sale_document() \ + and invoice.state == 'posted' \ + and 'facturx_1_0_05' not in edi_doc_codes \ + and self.env.ref('account_edi_ubl_cii.edi_facturx_1_0_05', raise_if_not_found=False): + # Add the attachments to the pdf file + pdf_stream = collected_streams[invoice.id]['stream'] + + # Read pdf content. + pdf_content = pdf_stream.getvalue() + reader_buffer = io.BytesIO(pdf_content) + reader = OdooPdfFileReader(reader_buffer, strict=False) + + # Post-process and embed the additional files. + writer = OdooPdfFileWriter() + writer.cloneReaderDocumentRoot(reader) + + # Generate and embed Factur-X + xml_content, _errors = self.env['account.edi.xml.cii']._export_invoice(invoice) + writer.addAttachment( + name=self.env['account.edi.xml.cii']._export_invoice_filename(invoice), + data=xml_content, + subtype='text/xml', + ) + + # Replace the current content. + pdf_stream.close() + new_pdf_stream = io.BytesIO() + writer.write(new_pdf_stream) + collected_streams[invoice.id]['stream'] = new_pdf_stream + + return collected_streams diff --git a/odoo-bringout-oca-ocb-account_edi_ubl_cii/doc/PATCH_PDFWRITER.md b/odoo-bringout-oca-ocb-account_edi_ubl_cii/doc/PATCH_PYPDF2_PDFWRITER.md similarity index 75% rename from odoo-bringout-oca-ocb-account_edi_ubl_cii/doc/PATCH_PDFWRITER.md rename to odoo-bringout-oca-ocb-account_edi_ubl_cii/doc/PATCH_PYPDF2_PDFWRITER.md index b60e2c2..d870c7d 100644 --- a/odoo-bringout-oca-ocb-account_edi_ubl_cii/doc/PATCH_PDFWRITER.md +++ b/odoo-bringout-oca-ocb-account_edi_ubl_cii/doc/PATCH_PYPDF2_PDFWRITER.md @@ -22,25 +22,33 @@ The account_edi_ubl_cii module uses PyPDF2 for: ## Solution -**This package requires NO direct patches** because it uses: -1. `OdooPdfFileWriter` from `odoo.tools.pdf` (oca-ocb-base) -2. `OdooPdfFileReader` from `odoo.tools.pdf` (oca-ocb-base) +**This package includes direct fixes** for PyPDF2 3.x page copying issue: +1. Uses `OdooPdfFileWriter` from `odoo.tools.pdf` (oca-ocb-base) for compatibility +2. **CRITICAL FIX**: Added explicit page copying after `cloneReaderDocumentRoot()` -The main compatibility layer in `oca-ocb-base` handles all PyPDF2 version compatibility automatically. +In PyPDF2 3.x, `cloneReaderDocumentRoot()` only copies document structure, not content pages. This was causing 327-byte empty PDFs. The fix includes explicit page copying: + +```python +writer.cloneReaderDocumentRoot(reader) +# Copy all pages from the reader to the writer (required for PyPDF2 3.x) +for page_num in range(reader.getNumPages()): + page = reader.getPage(page_num) + writer.addPage(page) +``` ## Files Using PyPDF2 ### `account_edi_ubl_cii/models/ir_actions_report.py` - Uses `OdooPdfFileWriter` for standards-compliant PDF generation (automatically compatible) -- Calls `writer.cloneReaderDocumentRoot(reader)` +- **FIXED**: Added explicit page copying after `writer.cloneReaderDocumentRoot(reader)` - Embeds UBL/CII XML in PDF documents ## Implementation Details -**No code changes needed** in this package. Compatibility is achieved through: +**Direct code changes applied** in this package: -1. **Dependency**: Requires `oca-ocb-base` with `pdfwrite` branch -2. **Automatic compatibility**: `OdooPdfFileWriter` handles all PyPDF2 version differences +1. **Dependency**: Requires `oca-ocb-base` with `pdfwrite` branch for compatibility classes +2. **Page copying fix**: Added explicit page copying loop in `_render_qweb_pdf_prepare_streams()` 3. **Standards compliance**: UBL/CII standards maintained through compatibility layer ## Testing