# -*- coding: utf-8 -*- # Fusion PDF Template Filler # Generic utility for filling any PDF template with data overlays. # Uses the same pattern as Odoo Enterprise Sign module (sign/utils/pdf_handling.py): # - Read original PDF page dimensions from mediaBox # - Create reportlab Canvas overlay at the same page size # - Convert percentage positions (0.0-1.0) to absolute PDF coordinates # - Merge overlay onto original page via mergePage() import logging from io import BytesIO from reportlab.pdfgen import canvas from reportlab.lib.utils import ImageReader from odoo.tools.pdf import PdfFileReader, PdfFileWriter _logger = logging.getLogger(__name__) class PDFTemplateFiller: """Generic PDF template filler. Works with any template, any number of pages.""" @staticmethod def fill_template(template_pdf_bytes, fields_by_page, context, signatures=None): """Fill a PDF template by overlaying text/checkmarks/signatures at configured positions. Args: template_pdf_bytes: bytes of the original PDF fields_by_page: {page_num: [field_dicts]} where page_num is 1-based Each field_dict has: field_key, pos_x, pos_y, width, height, field_type, font_size, font_name context: flat dict of {field_key: value} with all available data signatures: dict of {field_key: binary_png} for signature image fields Returns: bytes of the filled PDF (all pages preserved) """ if signatures is None: signatures = {} try: original = PdfFileReader(BytesIO(template_pdf_bytes)) except Exception as e: _logger.error("Failed to read template PDF: %s", e) raise output = PdfFileWriter() num_pages = original.getNumPages() for page_idx in range(num_pages): page = original.getPage(page_idx) page_num = page_idx + 1 # 1-based page number mb = page.mediaBox page_w = float(mb.getWidth()) page_h = float(mb.getHeight()) origin_x = float(mb.getLowerLeft_x()) origin_y = float(mb.getLowerLeft_y()) fields = fields_by_page.get(page_num, []) if fields: overlay_buf = BytesIO() c = canvas.Canvas( overlay_buf, pagesize=(origin_x + page_w, origin_y + page_h), ) for field in fields: PDFTemplateFiller._draw_field( c, field, context, signatures, page_w, page_h, origin_x, origin_y, ) c.save() overlay_buf.seek(0) # Merge overlay onto original page (same as sign module) overlay_pdf = PdfFileReader(overlay_buf) page.mergePage(overlay_pdf.getPage(0)) output.addPage(page) result = BytesIO() output.write(result) return result.getvalue() @staticmethod def _draw_field(c, field, context, signatures, page_w, page_h, origin_x=0, origin_y=0): """Draw a single field onto the reportlab canvas. Args: c: reportlab Canvas field: dict with field_key, pos_x, pos_y, width, height, field_type, etc. context: data context dict signatures: dict of {field_key: binary} for signature fields page_w: page width in PDF points page_h: page height in PDF points origin_x: mediaBox lower-left X (accounts for non-zero origin) origin_y: mediaBox lower-left Y (accounts for non-zero origin) """ field_key = field.get('field_key') or field.get('field_name', '') field_type = field.get('field_type', 'text') value = context.get(field_key, field.get('default_value', '')) if not value and field_type != 'signature': return # Convert percentage positions to absolute PDF coordinates. # pos_x/pos_y are 0.0-1.0 ratios from top-left of the visible page. # PDF coordinate system: origin at bottom-left, Y goes up. # origin_x/origin_y account for PDFs whose mediaBox doesn't start at (0,0). abs_x = field['pos_x'] * page_w + origin_x abs_y = (origin_y + page_h) - (field['pos_y'] * page_h) font_name = field.get('font_name', 'Helvetica') font_size = field.get('font_size', 10.0) if field_type in ('text', 'date'): c.setFont(font_name, font_size) text_val = str(value) field_h = field.get('height', 0.018) * page_h text_y = abs_y - field_h + (field_h - font_size) / 2 align = field.get('text_align', 'left') if align == 'center': center_x = abs_x + (field.get('width', 0.15) * page_w) / 2 c.drawCentredString(center_x, text_y, text_val) elif align == 'right': right_x = abs_x + field.get('width', 0.15) * page_w c.drawRightString(right_x, text_y, text_val) else: c.drawString(abs_x, text_y, text_val) elif field_type == 'checkbox': if value: # Draw a cross mark (✗) that fills the checkbox box cb_w = field.get('width', 0.015) * page_w cb_h = field.get('height', 0.018) * page_h # Inset slightly so the cross doesn't touch the box edges pad = min(cb_w, cb_h) * 0.15 x1 = abs_x + pad y1 = abs_y - cb_h + pad x2 = abs_x + cb_w - pad y2 = abs_y - pad c.saveState() c.setStrokeColorRGB(0, 0, 0) c.setLineWidth(1.5) # Draw X (two diagonal lines) c.line(x1, y1, x2, y2) c.line(x1, y2, x2, y1) c.restoreState() elif field_type == 'signature': sig_data = signatures.get(field_key) if sig_data: try: img = ImageReader(BytesIO(sig_data)) sig_w = field.get('width', 0.15) * page_w sig_h = field.get('height', 0.05) * page_h # Draw signature image (position from top, so adjust Y) c.drawImage( img, abs_x, abs_y - sig_h, width=sig_w, height=sig_h, mask='auto', ) except Exception as e: _logger.warning("Failed to draw signature for %s: %s", field_key, e)