Files
enterprise-addons/sign/utils/pdf_handling.py
T

133 lines
5.7 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import logging
from io import BytesIO
from odoo.exceptions import ValidationError
from odoo.tools.pdf import DependencyError, errors, NameObject, PdfFileReader, PdfFileWriter, PdfReadError, NumberObject, DictionaryObject
from odoo.tools.translate import LazyTranslate
_lt = LazyTranslate(__name__)
_logger = logging.getLogger(__name__)
def get_valid_pdf_data(pdf_bytes, strict=True):
"""
Validate and return a readable PDF file object from the given byte data.
:param pdf_bytes: Raw byte data of the PDF file to be validated.
:param strict: Enforce strict parsing of the PDF file.
:return: A valid and non-encrypted PdfFileReader instance.
:raises ValidationError: If cannot return non-encrypted PdfFileReader instance.
"""
# If strict=True is explicitly requested, try strict first then fall back to lenient.
# By default we favor lenient parsing to cope with malformed yet readable PDFs.
# pypdf strict=False enables best-effort parsing for PDFs that don't follow the spec exactly.
# See https://pypdf.readthedocs.io/en/stable/user/robustness.html
strict_modes = (True, False) if strict else (False,)
for strict_flag in strict_modes:
try:
pdf_reader = PdfFileReader(BytesIO(pdf_bytes), strict_flag)
if pdf_reader.isEncrypted:
continue
if strict_flag is False and strict:
_logger.warning("Strict PDF parsing failed; falling back to lenient mode.")
return pdf_reader
except (DependencyError, UnicodeDecodeError, PdfReadError, errors.PyPdfError):
_logger.warning("Failed to read PDF data (strict=%s).", strict_flag, exc_info=True)
continue
raise ValidationError(_lt(
"It seems that we're not able to process one of the uploaded pdf. It is either"
" encrypted, or encoded in a format we do not support."
))
# TODO: Wait for the next Debian release to bump the pypdf dependency to >= 5.8.0.
# Starting from pypdf 5.8.0, true annotation flattening is natively supported. Once upgraded,
# this workaround can be replaced with real PDF flattening using the function `update_page_form_field_values`
def flatten_pdf(base64_pdf):
"""
Makes a PDF non-editable by locking all interactive form fields to Read-Only.
Note: Due to limitations in the currently supported PyPDF version, true
flattening (rendering fields as static background text and removing widgets)
is not performed. Instead, this function locks the data and UI layers of the
widgets so the user cannot alter the values.
:param base64_pdf: Base64-encoded string of the original PDF.
:return: Base64-encoded string of the flattened, non-editable PDF.
:raises ValidationError: If the PDF cannot be decoded or parsed.
"""
try:
pdf_bytes = base64.b64decode(base64_pdf)
# Use lenient parsing to better handle malformed PDFs while still refusing encrypted ones.
# strict=False allows pypdf to apply best-effort recovery for non-spec-compliant PDFs.
pdf_reader = get_valid_pdf_data(pdf_bytes, strict=False)
output_pdf = PdfFileWriter()
output_pdf.appendPagesFromReader(pdf_reader)
for page_number in range(output_pdf.getNumPages()):
page = output_pdf.getPage(page_number)
if "/Annots" in page:
for annot_ref in page["/Annots"]:
annot_obj = annot_ref.getObject()
# If the annotation is an interactive form Widget
if annot_obj.get("/Subtype") == "/Widget":
if "/FT" in annot_obj and "/T" in annot_obj:
parent_annot_obj = annot_obj
else:
parent_annot_obj = annot_obj.get("/Parent", DictionaryObject()).getObject()
# Bit 1 (Value 1) = Read-Only Data
current_ff = parent_annot_obj.get("/Ff", 0)
parent_annot_obj[NameObject("/Ff")] = NumberObject(current_ff | 1)
except errors.PyPdfError as e:
_logger.warning("Failed to parse PDF during locking interactive widgets: %s", e)
return base64_pdf
output_stream = BytesIO()
output_pdf.write(output_stream)
return base64.b64encode(output_stream.getvalue())
def _draw_field_value(can, annot_ref):
"""
Auxiliary function to draw a field value (text, checkbox, radio button).
"""
_logger.warning("The `_draw_field_value` function is deprecated, unused, and will be removed.")
annot = annot_ref.getObject()
rect = annot.get("/Rect")
if not rect:
return
field_value = _get_field_value(annot)
if field_value:
x, y = float(rect[0]), float(rect[1])
can.setFont("Helvetica", 10)
can.drawString(x + 2, y + 2, field_value)
def _get_field_value(annot):
""" Return the display value extracted from a PDF annotation.
:param annot: The annotation dictionary representing the PDF form field.
:return: The string value to draw, or a checkmark character for active buttons.
:rtype: str
"""
_logger.warning("The `_get_field_value` function is deprecated, unused, and will be removed.")
field_type = annot.get("/FT")
if field_type == "/Btn": # checkbox or radio
appearance_state = annot.get("/AS")
if appearance_state and appearance_state != NameObject("/Off"):
# Render a ✓ symbol for checked Check Box or Radio Button
return chr(0x2713)
else: # if btn is not checked don't render anything
return ""
return str(annot.get("/V") or "")