fix PyPDF2 3.x page copying + rename documentation

- 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
This commit is contained in:
Ernad Husremovic 2025-09-02 19:31:35 +02:00
parent 31702e7006
commit ae4fa097e7
3 changed files with 129 additions and 8 deletions

View file

@ -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)

View file

@ -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 = '''
<cac:AdditionalDocumentReference
xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2">
<cbc:ID>%s</cbc:ID>
<cac:Attachment>
<cbc:EmbeddedDocumentBinaryObject mimeCode="application/pdf" filename=%s>
%s
</cbc:EmbeddedDocumentBinaryObject>
</cac:Attachment>
</cac:AdditionalDocumentReference>
''' % (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

View file

@ -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