feat: Implement client-side image compression and server-side rendering for image type sign items on PDF documents.
This commit is contained in:
parent
c7b48e0afc
commit
71a54e4eb7
@ -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
159
models/sign_document.py
Normal 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)
|
||||||
@ -9,7 +9,7 @@ patch(SignablePDFIframe.prototype, {
|
|||||||
if (signItem.data.type === 'image') {
|
if (signItem.data.type === 'image') {
|
||||||
// Add data-type attribute for CSS targeting
|
// Add data-type attribute for CSS targeting
|
||||||
signItem.el.setAttribute('data-type', 'image');
|
signItem.el.setAttribute('data-type', 'image');
|
||||||
|
|
||||||
// Function to adjust font size based on container dimensions
|
// Function to adjust font size based on container dimensions
|
||||||
const adjustFontSize = () => {
|
const adjustFontSize = () => {
|
||||||
const placeholder = signItem.el.querySelector('.o_placeholder');
|
const placeholder = signItem.el.querySelector('.o_placeholder');
|
||||||
@ -23,16 +23,16 @@ patch(SignablePDFIframe.prototype, {
|
|||||||
placeholder.style.lineHeight = '1';
|
placeholder.style.lineHeight = '1';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial font size adjustment
|
// Initial font size adjustment
|
||||||
setTimeout(adjustFontSize, 100);
|
setTimeout(adjustFontSize, 100);
|
||||||
|
|
||||||
// Watch for size changes
|
// Watch for size changes
|
||||||
if (window.ResizeObserver) {
|
if (window.ResizeObserver) {
|
||||||
const resizeObserver = new ResizeObserver(adjustFontSize);
|
const resizeObserver = new ResizeObserver(adjustFontSize);
|
||||||
resizeObserver.observe(signItem.el);
|
resizeObserver.observe(signItem.el);
|
||||||
}
|
}
|
||||||
|
|
||||||
const input = signItem.el.querySelector('.o_sign_image_upload_input');
|
const input = signItem.el.querySelector('.o_sign_image_upload_input');
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
|
|
||||||
@ -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;
|
||||||
} else {
|
|
||||||
|
// 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
|
// 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);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user