From 71a54e4eb7200b3bc6caf04b5178dcc72e62e4e7 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Thu, 12 Feb 2026 15:28:57 +0700 Subject: [PATCH] feat: Implement client-side image compression and server-side rendering for image type sign items on PDF documents. --- models/__init__.py | 2 +- models/sign_document.py | 159 +++++++++++++++++++++++++++++ static/src/js/sign_image_upload.js | 80 ++++++++++++--- 3 files changed, 223 insertions(+), 18 deletions(-) create mode 100644 models/sign_document.py diff --git a/models/__init__.py b/models/__init__.py index 5da7954..411f980 100755 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- - from . import sign_item_type +from . import sign_document diff --git a/models/sign_document.py b/models/sign_document.py new file mode 100644 index 0000000..750d6c0 --- /dev/null +++ b/models/sign_document.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +import base64 +import io +from odoo import models, api +from odoo.exceptions import ValidationError +from odoo.tools.pdf import PdfFileReader, PdfFileWriter, PdfReadError +from reportlab.pdfgen import canvas +from reportlab.lib.utils import ImageReader +from PIL import UnidentifiedImageError + +class SignDocument(models.Model): + _inherit = 'sign.document' + + def render_document_with_items(self, signed_values=None, values_dict=None, final_log_hash=None): + # 1. Get the base output from super (standard + other modules) + base_output = super(SignDocument, self).render_document_with_items(signed_values, values_dict, final_log_hash) + + # If super returns None (e.g. encrypted PDF), we respect it. + if not base_output: + return base_output + + # 2. Check if we have any 'image' items to render + items_by_page = self._get_sign_items_by_page() + has_image_items = any( + item.type_id.item_type == 'image' + for page_items in items_by_page.values() + for item in page_items + ) + + if not has_image_items: + return base_output + + # 3. Load the base output into PdfFileReader + # base_output is a BytesIO + try: + base_pdf = PdfFileReader(base_output, strict=False) + except (ValueError, PdfReadError): + # Should not happen if super succeeded, but safety first + return base_output + + if not signed_values: + signed_values, values_dict = self._get_preview_values() + + # 4. Prepare a new canvas overlay for OUR items + packet = io.BytesIO() + # We need page size from the base_pdf (which might have been rotated/modified?) + # Actually super returns a PDF where pages are merged. + # We should use the same page size logic as original method. + # But wait, we iterate pages. + + # Let's see how original method creates canvas: + # can = canvas.Canvas(packet, pagesize=self._get_page_size(old_pdf)) + # We need to replicate this per-page iteration to draw on the correct page. + + # Problem: canvas is one continuous stream of pages. + # We need to make a canvas that matches the page count and sizes of base_pdf. + + # However, we can just create a single canvas, add pages, and then merge. + # But we need the correct page size for each page. + + # Let's iterate base_pdf pages to get sizes. + + # Odoo's original code uses self._get_page_size(old_pdf) which returns MAX width/height. + # Then for each page it gets mediaBox. + + # We will do the same loop structure. + + can = canvas.Canvas(packet, pagesize=self._get_page_size(base_pdf)) + + for p in range(0, base_pdf.getNumPages()): + page = base_pdf.getPage(p) + width = float(abs(page.mediaBox.getWidth())) + height = float(abs(page.mediaBox.getHeight())) + + # Rotation handling (same as original) + rotation = page.get('/Rotate', 0) + if rotation and isinstance(rotation, int): + can.rotate(rotation) + if rotation == 90: + width, height = height, width + can.translate(0, -height) + elif rotation == 180: + can.translate(-width, -height) + elif rotation == 270: + width, height = height, width + can.translate(-width, 0) + + # Function to fix transparency (copied/imported from original or re-defined?) + # Since it's not exported, we re-define it or import if possible. + # _fix_image_transparency is not exported. We will redefine it helper. + + items = items_by_page.get(p + 1, []) + for item in items: + if item.type_id.item_type != 'image': + continue + + value_dict = signed_values.get(item.id) + if not value_dict: + continue + + value = value_dict.get('value') + # For image type, value is the base64 image + if not value: + continue + + try: + image_data = base64.b64decode(value[value.find(',') + 1:]) + image_reader = ImageReader(io.BytesIO(image_data)) + except (UnidentifiedImageError, ValueError): + # Skip invalid images + continue + + # Setup transparency override if needed (simplified version) + # We can try to access the protected method if we really want, but let's implement the logic. + if hasattr(image_reader, '_image'): + _fix_image_transparency(image_reader._image) + + can.drawImage( + image_reader, + width * item.posX, + height * (1 - item.posY - item.height), + width * item.width, + height * item.height, + 'auto', + True + ) + + can.showPage() + + can.save() + + # 5. Merge our new overlay with the base_pdf + item_pdf = PdfFileReader(packet) + new_pdf = PdfFileWriter() + + for p in range(0, base_pdf.getNumPages()): + page = base_pdf.getPage(p) + # Merge the overlay + if p < item_pdf.getNumPages(): + page.mergePage(item_pdf.getPage(p)) + new_pdf.addPage(page) + + output = io.BytesIO() + try: + new_pdf.write(output) + except PdfReadError: + raise ValidationError(self.env._("There was an issue generating the document.")) + + return output + +def _fix_image_transparency(image): + # Copied from sign/models/sign_document.py + if image.mode != 'RGBA': + return + pixels = image.load() + for x in range(image.size[0]): + for y in range(image.size[1]): + if pixels[x, y] == (0, 0, 0, 0): + pixels[x, y] = (255, 255, 255, 0) diff --git a/static/src/js/sign_image_upload.js b/static/src/js/sign_image_upload.js index e8b80d1..d5b46b6 100755 --- a/static/src/js/sign_image_upload.js +++ b/static/src/js/sign_image_upload.js @@ -9,7 +9,7 @@ patch(SignablePDFIframe.prototype, { if (signItem.data.type === 'image') { // Add data-type attribute for CSS targeting signItem.el.setAttribute('data-type', 'image'); - + // Function to adjust font size based on container dimensions const adjustFontSize = () => { const placeholder = signItem.el.querySelector('.o_placeholder'); @@ -23,16 +23,16 @@ patch(SignablePDFIframe.prototype, { placeholder.style.lineHeight = '1'; } }; - + // Initial font size adjustment setTimeout(adjustFontSize, 100); - + // Watch for size changes if (window.ResizeObserver) { const resizeObserver = new ResizeObserver(adjustFontSize); resizeObserver.observe(signItem.el); } - + const input = signItem.el.querySelector('.o_sign_image_upload_input'); if (!input) return; @@ -47,29 +47,75 @@ patch(SignablePDFIframe.prototype, { input.addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; + const reader = new FileReader(); - reader.onload = () => { - const result = reader.result; - signItem.el.dataset.value = result; - // Manually update the DOM to show the image - const img = signItem.el.querySelector('img'); - if (img) { - img.src = result; - } else { + reader.onload = (readerEvent) => { + const img = new Image(); + img.onload = () => { + // Create a canvas for compression + const canvas = document.createElement('canvas'); + let width = img.width; + let height = img.height; + + // Calculate new dimensions (e.g., max width/height 1920 to keep it reasonable) + const MAX_SIZE = 1920; + if (width > height) { + if (width > MAX_SIZE) { + height *= MAX_SIZE / width; + width = MAX_SIZE; + } + } else { + if (height > MAX_SIZE) { + width *= MAX_SIZE / height; + height = MAX_SIZE; + } + } + + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, width, height); + + // Compress to JPEG with 0.7 quality + const compressedDataUrl = canvas.toDataURL('image/jpeg', 0.7); + + // Set the value on the element + // Use a specific attribute or just data-value which is used by getSignatureValue + // We also need to add a hidden input or update state if Odoo framework expects it + + // Update the internal state which Odoo Sign might be using + signItem.data.value = compressedDataUrl; + signItem.el.dataset.value = compressedDataUrl; + + // Display the image // Remove placeholder if it exists const placeholder = signItem.el.querySelector('.o_placeholder'); if (placeholder) placeholder.remove(); + // Remove existing image + const existingImg = signItem.el.querySelector('img'); + if (existingImg) existingImg.remove(); + const newImg = document.createElement('img'); - newImg.src = result; + newImg.src = compressedDataUrl; newImg.style.maxWidth = '100%'; newImg.style.maxHeight = '100%'; newImg.style.objectFit = 'contain'; - signItem.el.appendChild(newImg); - } + // Append to a specific container if needed, or just to el + // sign_item_body is usually where content goes + const body = signItem.el.querySelector('.sign_item_body') || signItem.el; + body.appendChild(newImg); - this.handleInput(); // Trigger validation check + this.signItemValues[signItem.id] = compressedDataUrl; + + // Trigger validation/saving mechanism + // We might need to call a method to notify Odoo that value changed + // In standard sign, usually it listens to input or checks dirty state. + // Setting data-value is what getSignatureValueFromElement checks (based on our other patch) + }; + img.src = readerEvent.target.result; }; reader.readAsDataURL(file); }); @@ -78,7 +124,7 @@ patch(SignablePDFIframe.prototype, { getSignatureValueFromElement(item) { if (item.data.type === 'image') { - return item.el.dataset.value || false; + return item.el.dataset.value || item.data.value || false; } return super.getSignatureValueFromElement(item); }