From 445dac2e9b64a5ce67a61b74ab0ea3b54c54d940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= Date: Thu, 8 Feb 2024 14:52:08 +0100 Subject: [PATCH] misc: add |convert_image_format filter tag (#86003) --- debian/control | 3 +- tests/test_templates.py | 82 +++++++++++++++++++++++++++++++ wcs/qommon/templatetags/qommon.py | 53 ++++++++++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/debian/control b/debian/control index 76ef9feac..7ae35c836 100644 --- a/debian/control +++ b/debian/control @@ -43,7 +43,8 @@ Depends: graphviz, uwsgi-plugin-python3, ${misc:Depends}, ${python3:Depends}, -Recommends: libreoffice-writer-nogui | libreoffice-writer, +Recommends: graphicsmagick, + libreoffice-writer-nogui | libreoffice-writer, poppler-utils, python3-docutils, python3-langdetect, diff --git a/tests/test_templates.py b/tests/test_templates.py index 8ada81501..235ec6bd3 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -2,6 +2,8 @@ import datetime import html import os import string +import subprocess +from unittest import mock import pytest from django.test import override_settings @@ -1696,3 +1698,83 @@ def test_details_format(pub): pub.loggederror_class.wipe() assert tmpl.render(context) == 'String:\n foo' assert pub.loggederror_class.count() == 0 + + +@pytest.mark.parametrize('image_format', ['jpeg', 'png', 'pdf']) +def test_convert_image_format(pub, image_format): + with pub.complex_data(): + img = Template('{{ url|qrcode|convert_image_format:"%s" }}' % image_format).render( + {'url': 'http://example.com/', 'allow_complex': True} + ) + assert pub.has_cached_complex_data(img) + value = pub.get_cached_complex_data(img) + assert value.orig_filename == 'qrcode.%s' % image_format + assert value.content_type == {'jpeg': 'image/jpeg', 'png': 'image/png', 'pdf': 'application/pdf'}.get( + image_format + ) + with value.get_file_pointer() as fp: + if image_format in ('jpeg', 'png'): + img = PIL.Image.open(fp) + assert img.format == image_format.upper() + assert img.size == (330, 330) + assert ( + zbar_decode_qrcode(img, symbols=[ZBarSymbol.QRCODE])[0].data.decode() + == 'http://example.com/' + ) + else: + assert b'%PDF-' in fp.read()[:200] + + +def test_convert_image_format_no_name(pub): + with pub.complex_data(): + img = Template('{{ url|qrcode|rename_file:""|convert_image_format:"jpeg" }}').render( + {'url': 'http://example.com/', 'allow_complex': True} + ) + assert pub.has_cached_complex_data(img) + value = pub.get_cached_complex_data(img) + assert value.orig_filename == 'file.jpeg' + + +def test_convert_image_format_errors(pub): + pub.loggederror_class.wipe() + with pub.complex_data(): + img = Template('{{ "xxx"|convert_image_format:"gif" }}').render({'allow_complex': True}) + assert pub.has_cached_complex_data(img) + assert pub.get_cached_complex_data(img) is None + assert pub.loggederror_class.count() == 1 + assert ( + pub.loggederror_class.select()[0].summary + == '|convert_image_format: unknown format (must be one of jpeg, pdf, png)' + ) + + pub.loggederror_class.wipe() + with pub.complex_data(): + img = Template('{{ "xxx"|convert_image_format:"jpeg" }}').render({'allow_complex': True}) + assert pub.has_cached_complex_data(img) + assert pub.get_cached_complex_data(img) is None + assert pub.loggederror_class.count() == 1 + assert pub.loggederror_class.select()[0].summary == '|convert_image_format: missing input' + + pub.loggederror_class.wipe() + with mock.patch('subprocess.run', side_effect=FileNotFoundError()): + with pub.complex_data(): + img = Template('{{ url|qrcode|convert_image_format:"jpeg" }}').render( + {'url': 'http://example.com/', 'allow_complex': True} + ) + assert pub.has_cached_complex_data(img) + assert pub.get_cached_complex_data(img) is None + assert pub.loggederror_class.count() == 1 + assert pub.loggederror_class.select()[0].summary == '|convert_image_format: not supported' + + pub.loggederror_class.wipe() + with mock.patch( + 'subprocess.run', side_effect=subprocess.CalledProcessError(returncode=-1, cmd='xx', stderr=b'xxx') + ): + with pub.complex_data(): + img = Template('{{ url|qrcode|convert_image_format:"jpeg" }}').render( + {'url': 'http://example.com/', 'allow_complex': True} + ) + assert pub.has_cached_complex_data(img) + assert pub.get_cached_complex_data(img) is None + assert pub.loggederror_class.count() == 1 + assert pub.loggederror_class.select()[0].summary == '|convert_image_format: conversion error (xxx)' diff --git a/wcs/qommon/templatetags/qommon.py b/wcs/qommon/templatetags/qommon.py index aab8e75d8..a1b502128 100644 --- a/wcs/qommon/templatetags/qommon.py +++ b/wcs/qommon/templatetags/qommon.py @@ -24,6 +24,7 @@ import math import os import random import string +import subprocess import urllib.parse from decimal import Decimal from decimal import DivisionByZero as DecimalDivisionByZero @@ -1086,6 +1087,58 @@ def rename_file(value, new_name): return file_object +@register.filter +def convert_image_format(value, new_format): + from wcs.fields import FileField + + formats = { + 'jpeg': 'image/jpeg', + 'pdf': 'application/pdf', + 'png': 'image/png', + } + if new_format not in formats: + get_publisher().record_error( + _('|convert_image_format: unknown format (must be one of %s)') % ', '.join(formats.keys()) + ) + return None + + try: + file_object = FileField.convert_value_from_anything(value) + except ValueError: + file_object = None + if not file_object: + get_publisher().record_error(_('|convert_image_format: missing input')) + return None + + if file_object.base_filename: + current_name, current_format = os.path.splitext(file_object.base_filename) + if current_format == f'.{new_format}': + return file_object + new_name = f'{current_name}.{new_format}' + else: + new_name = '%s.%s' % (_('file'), new_format) + + try: + proc = subprocess.run( + ['gm', 'convert', '-', f'{new_format}:-'], + input=file_object.get_content(), + capture_output=True, + check=True, + ) + except FileNotFoundError: + get_publisher().record_error(_('|convert_image_format: not supported')) + return None + except subprocess.CalledProcessError as e: + get_publisher().record_error(_('|convert_image_format: conversion error (%s)' % e.stderr.decode())) + return None + + new_file_object = FileField.convert_value_from_anything( + {'content': proc.stdout, 'filename': new_name, 'content_type': formats[new_format]} + ) + + return new_file_object + + @register.filter def first(value): try: