feat: Implement client-side image compression and server-side rendering for image type sign items on PDF documents.

This commit is contained in:
Suherdy Yacob 2026-02-12 15:28:57 +07:00
parent c7b48e0afc
commit 71a54e4eb7
3 changed files with 223 additions and 18 deletions

View File

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from . import sign_item_type from . import sign_item_type
from . import sign_document

159
models/sign_document.py Normal file
View File

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

View File

@ -47,29 +47,75 @@ patch(SignablePDFIframe.prototype, {
input.addEventListener('change', (e) => { input.addEventListener('change', (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = (readerEvent) => {
const result = reader.result; const img = new Image();
signItem.el.dataset.value = result; img.onload = () => {
// Manually update the DOM to show the image // Create a canvas for compression
const img = signItem.el.querySelector('img'); const canvas = document.createElement('canvas');
if (img) { let width = img.width;
img.src = result; 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 { } 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 // Remove placeholder if it exists
const placeholder = signItem.el.querySelector('.o_placeholder'); const placeholder = signItem.el.querySelector('.o_placeholder');
if (placeholder) placeholder.remove(); if (placeholder) placeholder.remove();
// Remove existing image
const existingImg = signItem.el.querySelector('img');
if (existingImg) existingImg.remove();
const newImg = document.createElement('img'); const newImg = document.createElement('img');
newImg.src = result; newImg.src = compressedDataUrl;
newImg.style.maxWidth = '100%'; newImg.style.maxWidth = '100%';
newImg.style.maxHeight = '100%'; newImg.style.maxHeight = '100%';
newImg.style.objectFit = 'contain'; 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); reader.readAsDataURL(file);
}); });
@ -78,7 +124,7 @@ patch(SignablePDFIframe.prototype, {
getSignatureValueFromElement(item) { getSignatureValueFromElement(item) {
if (item.data.type === 'image') { if (item.data.type === 'image') {
return item.el.dataset.value || false; return item.el.dataset.value || item.data.value || false;
} }
return super.getSignatureValueFromElement(item); return super.getSignatureValueFromElement(item);
} }