# -*- coding: utf-8 -*- # Fusion PDF Template Engine # Generic system for filling any funding agency's PDF forms import base64 import logging from io import BytesIO from odoo import api, fields, models, _ from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class FusionPdfTemplate(models.Model): _name = 'fusion.pdf.template' _description = 'PDF Form Template' _order = 'category, name' name = fields.Char(string='Template Name', required=True) category = fields.Selection([ ('adp', 'ADP - Assistive Devices Program'), ('mod', 'March of Dimes'), ('odsp', 'ODSP'), ('hardship', 'Hardship Funding'), ('other', 'Other'), ], string='Funding Agency', required=True, default='adp') version = fields.Char(string='Form Version', default='1.0') state = fields.Selection([ ('draft', 'Draft'), ('active', 'Active'), ('archived', 'Archived'), ], string='Status', default='draft', tracking=True) # The actual PDF template file pdf_file = fields.Binary(string='PDF Template', required=True, attachment=True) pdf_filename = fields.Char(string='PDF Filename') page_count = fields.Integer( string='Page Count', compute='_compute_page_count', store=True, ) # Page preview images for the visual editor preview_ids = fields.One2many( 'fusion.pdf.template.preview', 'template_id', string='Page Previews', ) # Field positions configured via the visual editor field_ids = fields.One2many( 'fusion.pdf.template.field', 'template_id', string='Template Fields', ) field_count = fields.Integer( string='Fields', compute='_compute_field_count', ) notes = fields.Text( string='Notes', help='Usage notes, which assessments/forms use this template', ) def write(self, vals): res = super().write(vals) if 'pdf_file' in vals and vals['pdf_file']: for rec in self: try: rec.action_generate_previews() except Exception as e: _logger.warning("Auto preview generation failed for %s: %s", rec.name, e) return res @api.model_create_multi def create(self, vals_list): records = super().create(vals_list) for rec in records: if rec.pdf_file: try: rec.action_generate_previews() except Exception as e: _logger.warning("Auto preview generation failed for %s: %s", rec.name, e) return records @api.depends('pdf_file') def _compute_page_count(self): for rec in self: if rec.pdf_file: try: from odoo.tools.pdf import PdfFileReader pdf_data = base64.b64decode(rec.pdf_file) reader = PdfFileReader(BytesIO(pdf_data)) rec.page_count = reader.getNumPages() except Exception as e: _logger.warning("Could not read PDF page count: %s", e) rec.page_count = 0 else: rec.page_count = 0 def action_generate_previews(self): """Generate PNG preview images from the PDF using poppler (pdftoppm). Falls back gracefully if the PDF is protected or poppler is not available. """ self.ensure_one() if not self.pdf_file: raise UserError(_('Please upload a PDF file first.')) import subprocess import tempfile import os pdf_data = base64.b64decode(self.pdf_file) try: with tempfile.TemporaryDirectory() as tmpdir: pdf_path = os.path.join(tmpdir, 'template.pdf') with open(pdf_path, 'wb') as f: f.write(pdf_data) # Use pdftoppm to convert each page to PNG result = subprocess.run( ['pdftoppm', '-png', '-r', '200', pdf_path, os.path.join(tmpdir, 'page')], capture_output=True, timeout=30, ) if result.returncode != 0: stderr = result.stderr.decode('utf-8', errors='replace') _logger.warning("pdftoppm failed: %s", stderr) raise UserError(_( 'Could not generate previews automatically. ' 'The PDF may be protected. Please upload preview images manually ' 'in the Page Previews tab (screenshots of each page).' )) # Find generated PNG files png_files = sorted([ f for f in os.listdir(tmpdir) if f.startswith('page-') and f.endswith('.png') ]) if not png_files: raise UserError(_('No pages were generated. Please upload preview images manually.')) # Delete existing previews self.preview_ids.unlink() # Create preview records for idx, png_file in enumerate(png_files): png_path = os.path.join(tmpdir, png_file) with open(png_path, 'rb') as f: image_data = base64.b64encode(f.read()) self.env['fusion.pdf.template.preview'].create({ 'template_id': self.id, 'page': idx + 1, 'image': image_data, 'image_filename': f'page_{idx + 1}.png', }) _logger.info("Generated %d preview images for template %s", len(png_files), self.name) except subprocess.TimeoutExpired: raise UserError(_('PDF conversion timed out. Please upload preview images manually.')) except FileNotFoundError: raise UserError(_( 'poppler-utils (pdftoppm) is not installed on the server. ' 'Please upload preview images manually in the Page Previews tab.' )) @api.depends('field_ids') def _compute_field_count(self): for rec in self: rec.field_count = len(rec.field_ids) def action_activate(self): """Set template to active.""" self.ensure_one() if not self.pdf_file: raise UserError(_('Please upload a PDF file before activating.')) self.state = 'active' def action_archive(self): """Archive the template.""" self.ensure_one() self.state = 'archived' def action_reset_draft(self): """Reset to draft.""" self.ensure_one() self.state = 'draft' def action_open_field_editor(self): """Open the visual field position editor.""" self.ensure_one() return { 'type': 'ir.actions.act_url', 'url': f'/fusion/pdf-editor/{self.id}', 'target': 'new', } def generate_filled_pdf(self, context_data, signatures=None): """Generate a filled PDF using this template and the provided data. Args: context_data: flat dict of {field_key: value} signatures: dict of {field_key: binary_png} for signature fields Returns: bytes of the filled PDF """ self.ensure_one() if not self.pdf_file: raise UserError(_('Template has no PDF file.')) if self.state != 'active': _logger.warning("Generating PDF from non-active template %s", self.name) from ..utils.pdf_filler import PDFTemplateFiller template_bytes = base64.b64decode(self.pdf_file) # Build fields_by_page dict fields_by_page = {} for field in self.field_ids.filtered(lambda f: f.is_active): page = field.page if page not in fields_by_page: fields_by_page[page] = [] fields_by_page[page].append({ 'field_name': field.name, 'field_key': field.field_key or field.name, 'pos_x': field.pos_x, 'pos_y': field.pos_y, 'width': field.width, 'height': field.height, 'field_type': field.field_type, 'font_size': field.font_size, 'font_name': field.font_name or 'Helvetica', }) return PDFTemplateFiller.fill_template( template_bytes, fields_by_page, context_data, signatures ) class FusionPdfTemplatePreview(models.Model): _name = 'fusion.pdf.template.preview' _description = 'PDF Template Page Preview' _order = 'page' template_id = fields.Many2one( 'fusion.pdf.template', string='Template', required=True, ondelete='cascade', index=True, ) page = fields.Integer(string='Page Number', required=True, default=1) image = fields.Binary(string='Page Image (PNG)', attachment=True) image_filename = fields.Char(string='Image Filename') class FusionPdfTemplateField(models.Model): _name = 'fusion.pdf.template.field' _description = 'PDF Template Field' _order = 'page, sequence' template_id = fields.Many2one( 'fusion.pdf.template', string='Template', required=True, ondelete='cascade', index=True, ) name = fields.Char( string='Field Name', required=True, help='Internal identifier, e.g. client_last_name', ) label = fields.Char( string='Display Label', help='Human-readable label shown in the editor, e.g. "Last Name"', ) sequence = fields.Integer(string='Sequence', default=10) page = fields.Integer(string='Page', default=1, required=True) # Percentage-based positioning (0.0 to 1.0) -- same as sign.item pos_x = fields.Float( string='Position X', digits=(4, 3), help='Horizontal position as ratio (0.0 = left edge, 1.0 = right edge)', ) pos_y = fields.Float( string='Position Y', digits=(4, 3), help='Vertical position as ratio (0.0 = top edge, 1.0 = bottom edge)', ) width = fields.Float( string='Width', digits=(4, 3), default=0.150, help='Width as ratio of page width', ) height = fields.Float( string='Height', digits=(4, 3), default=0.015, help='Height as ratio of page height', ) # Rendering settings field_type = fields.Selection([ ('text', 'Text'), ('checkbox', 'Checkbox'), ('signature', 'Signature Image'), ('date', 'Date'), ], string='Field Type', default='text', required=True) font_size = fields.Float(string='Font Size', default=10.0) font_name = fields.Selection([ ('Helvetica', 'Helvetica'), ('Courier', 'Courier'), ('Times-Roman', 'Times Roman'), ], string='Font', default='Helvetica') # Data mapping field_key = fields.Char( string='Data Key', help='Key to look up in the data context dict.\n' 'Examples: client_last_name, client_health_card, consent_date, signature_page_11\n' 'The generating code passes a flat dict of all available data.', ) default_value = fields.Char( string='Default Value', help='Fallback value if field_key returns empty', ) is_active = fields.Boolean(string='Active', default=True)