502 lines
24 KiB
Python
502 lines
24 KiB
Python
import base64
|
|
import io
|
|
from collections import defaultdict
|
|
import textwrap
|
|
|
|
from odoo import models, fields, api
|
|
from odoo.exceptions import UserError, ValidationError
|
|
from odoo.tools import misc, format_date
|
|
from odoo.tools.pdf import PdfFileReader, PdfFileWriter, PdfReadError, reshape_text, NameObject
|
|
|
|
from odoo.addons.sign.utils.pdf_handling import get_valid_pdf_data
|
|
|
|
from PIL import UnidentifiedImageError
|
|
|
|
from reportlab.lib.styles import ParagraphStyle
|
|
from reportlab.lib.utils import ImageReader
|
|
from reportlab.pdfbase import pdfmetrics
|
|
from reportlab.pdfbase.ttfonts import TTFont
|
|
from reportlab.pdfbase.pdfmetrics import stringWidth
|
|
from reportlab.pdfgen import canvas
|
|
from reportlab.platypus import Paragraph
|
|
|
|
|
|
def _fix_image_transparency(image):
|
|
""" Modify image transparency to minimize issue of grey bar artefact.
|
|
When an image has a transparent pixel zone next to white pixel zone on a
|
|
white background, this may cause on some renderer grey line artefacts at
|
|
the edge between white and transparent.
|
|
|
|
This method sets black transparent pixel to white transparent pixel which solves
|
|
the issue for the most probable case. With this the issue happen for a
|
|
black zone on black background but this is less likely to happen.
|
|
"""
|
|
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)
|
|
|
|
|
|
class SignDocument(models.Model):
|
|
_name = 'sign.document'
|
|
_description = 'Signature Document'
|
|
_order = 'sequence'
|
|
|
|
sequence = fields.Integer()
|
|
attachment_id = fields.Many2one('ir.attachment', string="Attachment", required=True, index=True, ondelete='restrict')
|
|
datas = fields.Binary(related='attachment_id.datas', readonly=False)
|
|
name = fields.Char('Name', related='attachment_id.name', readonly=False)
|
|
template_id = fields.Many2one('sign.template', 'Template', index=True, ondelete='cascade', required=True)
|
|
sign_item_ids = fields.One2many('sign.item', 'document_id', string="Signature Items", copy=True)
|
|
num_pages = fields.Integer('Number of pages', compute="_compute_num_pages", readonly=True, store=True)
|
|
|
|
@api.depends('attachment_id.datas')
|
|
def _compute_num_pages(self):
|
|
for record in self:
|
|
try:
|
|
record.num_pages = self._get_pdf_number_of_pages(base64.b64decode(record.attachment_id.datas)) or 0
|
|
except (ValueError, ValidationError):
|
|
record.num_pages = 0
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
attachments = self.env['ir.attachment'].browse([vals.get('attachment_id') for vals in vals_list if vals.get('attachment_id')])
|
|
for attachment in attachments:
|
|
self._check_pdf_data_validity(attachment.datas)
|
|
for vals, attachment in zip(vals_list, attachments):
|
|
if attachment.res_model or attachment.res_id:
|
|
vals['attachment_id'] = attachment.copy().id
|
|
else:
|
|
attachment.res_model = self._name
|
|
documents = super().create(vals_list)
|
|
default_name = self.env['sign.template'].default_get(fields=['name'])['name']
|
|
for document, attachment in zip(documents, documents.attachment_id):
|
|
attachment.write({
|
|
'res_model': self._name,
|
|
'res_id': document.id
|
|
})
|
|
if document.template_id.name == default_name:
|
|
document.template_id.name = document.name
|
|
documents.attachment_id.check_access('read')
|
|
return documents
|
|
|
|
@api.model
|
|
def create_from_attachment_data(self, attachment_data_list, template_id):
|
|
"""
|
|
Create sign.document records from a list of dictionaries containing attachment data.
|
|
|
|
:param attachment_data_list: List of dictionaries, each with 'name' and 'datas' keys.
|
|
Example: [{'name': 'asdf', 'datas': 'asdfasdfasdf23423'}, ...]
|
|
:return: List the newly created sign.document records.
|
|
:raises UserError: If the input list is empty or a dictionary is missing required keys.
|
|
"""
|
|
if not attachment_data_list:
|
|
raise UserError(self.env._("The attachment data list cannot be empty."))
|
|
created_records = []
|
|
for data in attachment_data_list:
|
|
if not all(key in data for key in ['name', 'datas']):
|
|
raise UserError(self.env._("Each item must contain 'name' and 'datas' keys."))
|
|
attachment = self.env['ir.attachment'].create({
|
|
'name': data['name'],
|
|
'datas': data['datas'],
|
|
})
|
|
sign_document = self.create({
|
|
'attachment_id': attachment.id,
|
|
'sequence': data['sequence'],
|
|
'template_id': template_id,
|
|
})
|
|
created_records.append(sign_document)
|
|
return created_records
|
|
|
|
def write(self, vals):
|
|
res = super().write(vals)
|
|
if 'attachment_id' in vals:
|
|
self.attachment_id.check_access('read')
|
|
return res
|
|
|
|
@api.onchange("attachment_id")
|
|
def _onchange_attachment_id(self):
|
|
self.attachment_id.check_access('read')
|
|
|
|
def get_radio_sets_dict(self):
|
|
"""
|
|
:return: dict radio_sets_dict that maps each radio set that belongs to
|
|
this template to a dictionary containing num_options and radio_item_ids.
|
|
"""
|
|
radio_sets = self.sign_item_ids.filtered(lambda item: item.radio_set_id).radio_set_id
|
|
radio_sets_dict = {
|
|
radio_set.id: {
|
|
'num_options': radio_set.num_options,
|
|
'radio_item_ids': radio_set.radio_items.ids,
|
|
} for radio_set in radio_sets
|
|
}
|
|
return radio_sets_dict
|
|
|
|
def _get_sign_items_by_page(self):
|
|
self.ensure_one()
|
|
items = defaultdict(lambda: self.env['sign.item'])
|
|
for item in self.sign_item_ids:
|
|
items[item.page] += item
|
|
return items
|
|
|
|
def update_attachment_name(self, name):
|
|
"""
|
|
Updates the attachment's name. If the provided name is empty or None,
|
|
the current name is retained. This forced update prevents the creation
|
|
of duplicate sign items during simultaneous RPC requests.
|
|
|
|
:param name: The new name for the attachment.
|
|
:return:
|
|
|
|
- True: Indicates the attachment name was successfully updated.
|
|
- False: Indicates the update was skipped because a sign request linked
|
|
to the template already exists
|
|
"""
|
|
self.ensure_one()
|
|
sign_requests = self.env['sign.request'].search([('template_id', '=', self.template_id.id)], limit=1)
|
|
if not sign_requests:
|
|
self.attachment_id.name = name or self.attachment_id.name
|
|
return True
|
|
return False
|
|
|
|
def _get_preview_values(self):
|
|
""" prepare preview values based on current user and auto field"""
|
|
self.ensure_one()
|
|
values_dict = {}
|
|
sign_item_type_date = self.env.ref('sign.sign_item_type_date', raise_if_not_found=False)
|
|
phone_item_type = self.env.ref('sign.sign_item_type_phone', raise_if_not_found=False)
|
|
company_item_type = self.env.ref('sign.sign_item_type_company', raise_if_not_found=False)
|
|
email_item_type = self.env.ref('sign.sign_item_type_email', raise_if_not_found=False)
|
|
name_item_type = self.env.ref('sign.sign_item_type_name', raise_if_not_found=False)
|
|
with misc.file_open('sign/static/demo/signature.png', 'rb') as image_file:
|
|
signature_b64 = base64.b64encode(image_file.read())
|
|
with misc.file_open('sign/static/img/initial_example.png', 'rb') as image_file:
|
|
initial_b64 = base64.b64encode(image_file.read())
|
|
for it in self.sign_item_ids:
|
|
role_name = it.responsible_id.name
|
|
value = None
|
|
if it.type_id == name_item_type:
|
|
value = self.env._("%s's name", role_name)
|
|
elif it.type_id == phone_item_type:
|
|
value = "+1 555-555-5555 (%s)" % role_name
|
|
elif it.type_id == company_item_type:
|
|
value = self.env._("%s Company", role_name)
|
|
elif it.type_id == email_item_type:
|
|
value = "%s@example.com" % role_name.lower()
|
|
elif it.type_id.item_type == "signature":
|
|
value = "data:image/png;base64,%s" % signature_b64.decode()
|
|
elif it.type_id.item_type == "initial":
|
|
value = 'data:image/png;base64,%s' % initial_b64.decode()
|
|
elif it.type_id.item_type == "text":
|
|
if it.type_id != sign_item_type_date:
|
|
value = self.env._("Sample generated by Odoo for %s.", role_name)
|
|
else:
|
|
value = format_date(self.env, fields.Date.today())
|
|
elif it.type_id.item_type == "textarea":
|
|
value = self.env._("""Odoo is a suite of open source business apps
|
|
that cover all your company needs:
|
|
CRM, eCommerce, accounting, inventory, point of sale,\nproject management, etc.
|
|
""")
|
|
elif it.type_id.item_type == "stamp":
|
|
value = self.env._("""My US Company\n1034 Wildwood Street\n44654 Millersburg Ohio United States\n+1 555-555-5556""")
|
|
elif it.type_id.item_type == "checkbox":
|
|
value = "on"
|
|
elif it.type_id.item_type == "selection":
|
|
value = it.option_ids[:1].id # we select always the first option
|
|
elif it.type_id.item_type == "radio":
|
|
radio_items = it.radio_set_id.radio_items
|
|
value = "on" if it == radio_items[:1] else "" # we select always the first option
|
|
elif it.type_id.item_type == "strikethrough":
|
|
value = "striked"
|
|
values_dict[it.id] = {
|
|
"value": value,
|
|
"frame": "",
|
|
'frame_has_hash': False,
|
|
}
|
|
signed_values = values_dict
|
|
return signed_values, values_dict
|
|
|
|
def _get_font(self):
|
|
custom_font = self.env["ir.config_parameter"].sudo().get_param("sign.use_custom_font")
|
|
# The font must be a TTF font. The tool 'otf2ttf' may be useful for conversion.
|
|
if custom_font:
|
|
pdfmetrics.registerFont(TTFont(custom_font, custom_font + ".ttf"))
|
|
return custom_font
|
|
return "Helvetica"
|
|
|
|
def _get_normal_font_size(self):
|
|
return 0.015
|
|
|
|
@api.model
|
|
def _get_page_size(self, pdf_reader):
|
|
max_width = max_height = 0
|
|
for page in pdf_reader.pages:
|
|
media_box = page.mediaBox
|
|
width = media_box and media_box.getWidth()
|
|
height = media_box and media_box.getHeight()
|
|
max_width = max(width, max_width)
|
|
max_height = max(height, max_height)
|
|
|
|
return (max_width, max_height) if max_width and max_height else None
|
|
|
|
def render_document_with_items(self, signed_values=None, values_dict=None, final_log_hash=None):
|
|
self.ensure_one()
|
|
items_by_page = self._get_sign_items_by_page()
|
|
if not signed_values or not values_dict:
|
|
signed_values, values_dict = self._get_preview_values()
|
|
try:
|
|
old_pdf = PdfFileReader(io.BytesIO(self.attachment_id.raw), strict=False)
|
|
old_pdf.getNumPages()
|
|
except (ValueError, PdfReadError):
|
|
raise ValidationError(self.env._("ERROR: Invalid PDF file!"))
|
|
|
|
if old_pdf.isEncrypted:
|
|
return
|
|
|
|
font = self._get_font()
|
|
normalFontSize = self._get_normal_font_size()
|
|
|
|
packet = io.BytesIO()
|
|
can = canvas.Canvas(packet, pagesize=self._get_page_size(old_pdf))
|
|
for p in range(0, old_pdf.getNumPages()):
|
|
page = old_pdf.getPage(p)
|
|
# Absolute values are taken as it depends on the MediaBox template PDF metadata, they may be negative
|
|
width = float(abs(page.mediaBox.getWidth()))
|
|
height = float(abs(page.mediaBox.getHeight()))
|
|
|
|
# Add the final_log_hash as the certificate reference id on each page
|
|
if final_log_hash:
|
|
can.setFont(font, height * 0.01)
|
|
ref_text = f"Signature: {final_log_hash}"
|
|
can.drawCentredString(width / 3, height - 15, ref_text)
|
|
|
|
# Set page orientation (either 0, 90, 180 or 270)
|
|
rotation = page.get('/Rotate', 0)
|
|
if rotation and isinstance(rotation, int):
|
|
can.rotate(rotation)
|
|
# Translate system so that elements are placed correctly
|
|
# despite of the orientation
|
|
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)
|
|
|
|
items = items_by_page.get(p + 1, [])
|
|
for item in items:
|
|
value_dict = signed_values.get(item.id)
|
|
if not value_dict:
|
|
continue
|
|
# only get the 1st
|
|
value = value_dict['value']
|
|
frame = value_dict['frame']
|
|
if frame:
|
|
try:
|
|
image_reader = ImageReader(io.BytesIO(base64.b64decode(frame[frame.find(',') + 1:])))
|
|
except UnidentifiedImageError:
|
|
raise ValidationError(self.env._("There was an issue downloading your document. Please contact an administrator."))
|
|
_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
|
|
)
|
|
|
|
if item.type_id.item_type == "text":
|
|
value = reshape_text(value)
|
|
can.setFont(font, height * item.height * 0.8)
|
|
if item.alignment == "left":
|
|
can.drawString(width * item.posX, height * (1 - item.posY - item.height * 0.9), value)
|
|
elif item.alignment == "right":
|
|
can.drawRightString(width * (item.posX + item.width), height * (1 - item.posY - item.height * 0.9), value)
|
|
else:
|
|
can.drawCentredString(width * (item.posX + item.width / 2), height * (1 - item.posY - item.height * 0.9), value)
|
|
|
|
elif item.type_id.item_type == "selection":
|
|
text = ""
|
|
for option in item.option_ids:
|
|
if option.id == int(value):
|
|
text = option.value
|
|
font_size = height * normalFontSize * 0.8
|
|
string_width = stringWidth(text, font, font_size)
|
|
p = Paragraph(text, ParagraphStyle(name='Selection Paragraph', fontName=font, fontSize=font_size, leading=12))
|
|
posX = width * (item.posX + item.width * 0.5) - string_width // 2
|
|
posY = height * (1 - item.posY - item.height * 0.5) - p.wrap(width, height)[1] // 2
|
|
p.drawOn(can, posX, posY)
|
|
|
|
elif item.type_id.item_type in ["textarea", "stamp"]:
|
|
font_size = height * normalFontSize * 0.8
|
|
can.setFont(font, font_size)
|
|
# Normalize line endings to handle various formats (Windows \r\n, Unix \n, Mac \r)
|
|
normalized_value = (value or '').replace('\r\n', '\n').replace('\r', '\n')
|
|
# Wrap long lines that exceed field width
|
|
wrapped_lines = []
|
|
field_width = width * item.width
|
|
for line in normalized_value.splitlines():
|
|
if not line:
|
|
wrapped_lines.append("")
|
|
else:
|
|
# Calculate an approximate character limit based on average width
|
|
avg_char_width = can.stringWidth(line, font, font_size) / len(line)
|
|
max_chars = int(field_width / avg_char_width)
|
|
wrapped_lines.extend(textwrap.wrap(line, width=max_chars, replace_whitespace=False))
|
|
y = (1 - item.posY)
|
|
for line in wrapped_lines:
|
|
empty_space = field_width - can.stringWidth(line, font, font_size)
|
|
x_shift = 0
|
|
if item.alignment == 'center':
|
|
x_shift = empty_space / 2
|
|
elif item.alignment == 'right':
|
|
x_shift = empty_space
|
|
y -= normalFontSize * 0.9
|
|
line = reshape_text(line)
|
|
can.drawString(width * item.posX + x_shift, height * y, line)
|
|
y -= normalFontSize * 0.1
|
|
|
|
# Draw a dark blue border around the stamp field for visual emphasis
|
|
if item.type_id.item_type == "stamp":
|
|
padding = 5
|
|
itemW, itemH = item.width * width, item.height * height
|
|
itemX, itemY = item.posX * width, (1 - item.posY) * height
|
|
can.setLineWidth(1.2) # thickness of border
|
|
can.setStrokeColorRGB(0, 0, 139 / 255) # darkblue border
|
|
can.rect(
|
|
itemX - padding,
|
|
itemY - itemH - padding,
|
|
itemW + (2 * padding),
|
|
itemH + (2 * padding),
|
|
stroke=1,
|
|
fill=0
|
|
)
|
|
|
|
elif item.type_id.item_type == "checkbox":
|
|
itemW, itemH = item.width * width, item.height * height
|
|
itemX, itemY = item.posX * width, (1 - item.posY) * height
|
|
meanSize = (itemW + itemH) // 2
|
|
can.setLineWidth(max(meanSize // 30, 1))
|
|
can.rect(itemX, itemY - itemH, itemW, itemH)
|
|
if value == 'on':
|
|
can.setLineWidth(max(meanSize // 20, 1))
|
|
can.bezier(
|
|
itemX + 0.20 * itemW, itemY - 0.35 * itemH,
|
|
itemX + 0.30 * itemW, itemY - 0.8 * itemH,
|
|
itemX + 0.30 * itemW, itemY - 1.2 * itemH,
|
|
itemX + 0.85 * itemW, itemY - 0.15 * itemH,
|
|
)
|
|
elif item.type_id.item_type == "radio":
|
|
x = width * item.posX
|
|
y = height * (1 - item.posY)
|
|
w = item.width * width
|
|
h = item.height * height
|
|
# Calculate the center of the sign item rectangle.
|
|
c_x = x + w * 0.5
|
|
c_y = y - h * 0.5
|
|
# Draw the outer empty circle.
|
|
can.circle(c_x, c_y, h * 0.5)
|
|
if value == "on":
|
|
# Draw the inner filled circle.
|
|
can.circle(x_cen=c_x, y_cen=c_y, r=h * 0.5 * 0.75, fill=1)
|
|
elif item.type_id.item_type == "signature" or item.type_id.item_type == "initial":
|
|
try:
|
|
image_reader = ImageReader(io.BytesIO(base64.b64decode(value[value.find(',') + 1:])))
|
|
except UnidentifiedImageError:
|
|
raise ValidationError(self.env._("There was an issue downloading your document. Please contact an administrator."))
|
|
_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)
|
|
elif item.type_id.item_type == "strikethrough" and value == "striked":
|
|
x = width * item.posX
|
|
y = height * (1 - item.posY)
|
|
w = item.width * width
|
|
h = item.height * height
|
|
can.line(x, y - 0.5 * h, x + w, y - 0.5 * h)
|
|
|
|
can.showPage()
|
|
|
|
can.save()
|
|
|
|
item_pdf = PdfFileReader(packet)
|
|
new_pdf = PdfFileWriter()
|
|
|
|
for p in range(0, old_pdf.getNumPages()):
|
|
new_pdf.addPage(old_pdf.getPage(p))
|
|
page = new_pdf.getPage(-1)
|
|
overlay_page = item_pdf.getPage(p)
|
|
|
|
box = page.cropBox if page.get('/CropBox') else page.mediaBox
|
|
if box and box.lower_left:
|
|
offset_x = float(box.lower_left[0])
|
|
offset_y = float(box.lower_left[1])
|
|
else:
|
|
offset_x, offset_y = 0.0, 0.0
|
|
self.logger.warning("Unable to parse PDF coordinates offset; defaulting to (0,0)")
|
|
|
|
overlay_page.mediaBox = page.mediaBox
|
|
if page.get("/CropBox"):
|
|
overlay_page.cropBox = page.cropBox
|
|
if offset_x != 0 or offset_y != 0:
|
|
# translate the overlay_page to match the offset of the original page,
|
|
# ensuring that the signature is placed in the correct location
|
|
overlay_page.add_transformation((1, 0, 0, 1, offset_x, offset_y))
|
|
|
|
page.mergePage(overlay_page)
|
|
page.compressContentStreams()
|
|
# Preserve page indirect object identity so internal PDF references remain valid.
|
|
ref = page.indirect_reference if hasattr(page, "indirect_reference") else None
|
|
if ref:
|
|
old_pdf.resolved_objects[ref.generation, ref.idnum] = page
|
|
|
|
# Copy bookmarks from the original PDF to the new one, if any exist
|
|
root = old_pdf.trailer.get("/Root")
|
|
old_outlines_ref = root.get("/Outlines") if root else None
|
|
if old_outlines_ref:
|
|
outlines_obj = old_outlines_ref.getObject()
|
|
copied_outlines = new_pdf._addObject(outlines_obj)
|
|
new_pdf._root_object.update({
|
|
NameObject("/Outlines"): copied_outlines,
|
|
NameObject("/PageMode"): NameObject("/UseOutlines")
|
|
})
|
|
|
|
output = io.BytesIO()
|
|
try:
|
|
new_pdf.write(output)
|
|
except PdfReadError:
|
|
raise ValidationError(self.env._("There was an issue downloading your document. Please contact an administrator."))
|
|
|
|
return output
|
|
|
|
def _copy_sign_items_to(self, new_document):
|
|
""" Copy all sign items of the self document to the new_document that fit within its page count."""
|
|
self.ensure_one()
|
|
if new_document.template_id.has_sign_requests:
|
|
raise UserError(self.env._("Somebody is already filling a document which uses this template"))
|
|
|
|
new_document_pages = new_document.num_pages
|
|
item_id_map = {}
|
|
for sign_item in self.sign_item_ids:
|
|
# Only copy sign items that fit within the new document's page range.
|
|
if sign_item.page <= new_document_pages:
|
|
new_sign_item = sign_item.copy({'document_id': new_document.id})
|
|
new_sign_item.responsible_id = sign_item.responsible_id.id
|
|
item_id_map[str(sign_item.id)] = str(new_sign_item.id)
|
|
return item_id_map
|
|
|
|
@api.model
|
|
def _check_pdf_data_validity(self, datas):
|
|
try:
|
|
self._get_pdf_number_of_pages(base64.b64decode(datas))
|
|
except ValueError as e:
|
|
raise UserError(self.env._("One uploaded file cannot be read. Is it a valid PDF?")) from e
|
|
|
|
@api.model
|
|
def _get_pdf_number_of_pages(self, pdf_data):
|
|
file_pdf = get_valid_pdf_data(pdf_data, strict=False)
|
|
return len(file_pdf.pages)
|