From 58f2c6a5fc2784aa8a2d3a8e79b88d0178377da6 Mon Sep 17 00:00:00 2001 From: Michael Shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 10 Jun 2022 01:39:20 -0700 Subject: [PATCH 01/14] webp thumbnail support with png fallback --- src/documents/models.py | 9 +++++++-- src/documents/parsers.py | 4 ++-- src/documents/views.py | 7 ++++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/documents/models.py b/src/documents/models.py index 0061e5d0f..fe6d9ca20 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -293,11 +293,16 @@ class Document(models.Model): @property def thumbnail_path(self): - file_name = f"{self.pk:07}.png" + file_name = f"{self.pk:07}.webp" if self.storage_type == self.STORAGE_TYPE_GPG: file_name += ".gpg" - return os.path.join(settings.THUMBNAIL_DIR, file_name) + thumb = os.path.join(settings.THUMBNAIL_DIR, file_name) + + if os.path.exists(thumb): + return thumb + else: + return os.path.splitext(thumb)[0] + ".png" @property def thumbnail_file(self): diff --git a/src/documents/parsers.py b/src/documents/parsers.py index 469ec2f1e..bc8af0ec8 100644 --- a/src/documents/parsers.py +++ b/src/documents/parsers.py @@ -191,7 +191,7 @@ def make_thumbnail_from_pdf(in_path, temp_dir, logging_group=None) -> str: """ The thumbnail of a PDF is just a 500px wide image of the first page. """ - out_path = os.path.join(temp_dir, "convert.png") + out_path = os.path.join(temp_dir, "convert.webp") # Run convert to get a decent thumbnail try: @@ -321,7 +321,7 @@ class DocumentParser(LoggingMixin): def get_optimised_thumbnail(self, document_path, mime_type, file_name=None): thumbnail = self.get_thumbnail(document_path, mime_type, file_name) - if settings.OPTIMIZE_THUMBNAILS: + if settings.OPTIMIZE_THUMBNAILS and os.path.splitext(thumbnail)[1] == ".png": out_path = os.path.join(self.tempdir, "thumb_optipng.png") args = ( diff --git a/src/documents/views.py b/src/documents/views.py index cdd38180b..1e9583bc5 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -362,7 +362,12 @@ class DocumentViewSet( handle = doc.thumbnail_file # TODO: Send ETag information and use that to send new thumbnails # if available - return HttpResponse(handle, content_type="image/png") + content_type = ( + "image/webp" + if os.path.splitext(doc.thumbnail_path)[1] == ".webp" + else "image/png" + ) + return HttpResponse(handle, content_type=content_type) except (FileNotFoundError, Document.DoesNotExist): raise Http404() From 6844f8f2bff76cd41d32848e6a750a6a40e5b075 Mon Sep 17 00:00:00 2001 From: Trenton Holmes Date: Fri, 10 Jun 2022 06:56:28 -0700 Subject: [PATCH 02/14] Minor tweaks to getting the document thumbnail path. Adds text thumbnail as webp --- src/documents/models.py | 28 +++++++++++++++------------- src/paperless_text/parsers.py | 4 ++-- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/documents/models.py b/src/documents/models.py index fe6d9ca20..bd69aaff1 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -3,6 +3,7 @@ import logging import os import re from collections import OrderedDict +from typing import Optional import dateutil.parser import pathvalidate @@ -228,7 +229,7 @@ class Document(models.Model): verbose_name = _("document") verbose_name_plural = _("documents") - def __str__(self): + def __str__(self) -> str: # Convert UTC database time to local time created = datetime.date.isoformat(timezone.localdate(self.created)) @@ -242,7 +243,7 @@ class Document(models.Model): return res @property - def source_path(self): + def source_path(self) -> str: if self.filename: fname = str(self.filename) else: @@ -257,11 +258,11 @@ class Document(models.Model): return open(self.source_path, "rb") @property - def has_archive_version(self): + def has_archive_version(self) -> bool: return self.archive_filename is not None @property - def archive_path(self): + def archive_path(self) -> Optional[str]: if self.has_archive_version: return os.path.join(settings.ARCHIVE_DIR, str(self.archive_filename)) else: @@ -271,7 +272,7 @@ class Document(models.Model): def archive_file(self): return open(self.archive_path, "rb") - def get_public_filename(self, archive=False, counter=0, suffix=None): + def get_public_filename(self, archive=False, counter=0, suffix=None) -> str: result = str(self) if counter: @@ -292,17 +293,18 @@ class Document(models.Model): return get_default_file_extension(self.mime_type) @property - def thumbnail_path(self): - file_name = f"{self.pk:07}.webp" + def thumbnail_path(self) -> str: + png_file_name = f"{self.pk:07}.png" + webp_file_name = f"{self.pk:07}.webp" if self.storage_type == self.STORAGE_TYPE_GPG: - file_name += ".gpg" + png_file_name += ".gpg" + webp_file_name += ".gpg" - thumb = os.path.join(settings.THUMBNAIL_DIR, file_name) + thumb = os.path.join(settings.THUMBNAIL_DIR, webp_file_name) - if os.path.exists(thumb): - return thumb - else: - return os.path.splitext(thumb)[0] + ".png" + if not os.path.exists(thumb): + thumb = os.path.join(settings.THUMBNAIL_DIR, png_file_name) + return thumb @property def thumbnail_file(self): diff --git a/src/paperless_text/parsers.py b/src/paperless_text/parsers.py index fe7e823b3..4889c54df 100644 --- a/src/paperless_text/parsers.py +++ b/src/paperless_text/parsers.py @@ -30,8 +30,8 @@ class TextDocumentParser(DocumentParser): ) draw.text((5, 5), read_text(), font=font, fill="black") - out_path = os.path.join(self.tempdir, "thumb.png") - img.save(out_path) + out_path = os.path.join(self.tempdir, "thumb.webp") + img.save(out_path, format="WEBP") return out_path From 20092dadadb59c66c1d14924c2a6e5e739ad6b78 Mon Sep 17 00:00:00 2001 From: Trenton Holmes Date: Fri, 10 Jun 2022 07:15:43 -0700 Subject: [PATCH 03/14] Adds untested manual conversion command --- .../management/commands/convert_thumbnails.py | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/documents/management/commands/convert_thumbnails.py diff --git a/src/documents/management/commands/convert_thumbnails.py b/src/documents/management/commands/convert_thumbnails.py new file mode 100644 index 000000000..249209580 --- /dev/null +++ b/src/documents/management/commands/convert_thumbnails.py @@ -0,0 +1,76 @@ +import logging +import shutil +import tempfile +import time +from pathlib import Path + +from django.core.management.base import BaseCommand +from documents.models import Document +from documents.parsers import run_convert + + +logger = logging.getLogger("paperless.management.convert_thumbnails") + + +class Command(BaseCommand): + + help = """ + Converts existing PNG thumbnails into + WebP format. + """.replace( + " ", + "", + ) + + def handle(self, *args, **options): + + self.stdout.write("Converting all PNG thumbnails to WebP") + + start = time.time() + + documents = Document.objects.all() + + for document in documents: + existing_thumbnail = Path(document.thumbnail_path) + + if existing_thumbnail.suffix == "png": + + self.stdout.write(f"Converting thumbnail: {existing_thumbnail}") + + converted_thumbnail = Path(tempfile.mkstemp(suffix=".webp")) + + try: + run_convert( + density=300, + scale="500x5000>", + alpha="remove", + strip=True, + trim=False, + auto_orient=True, + input_file=f"{existing_thumbnail}[0]", + output_file=str(converted_thumbnail), + ) + + self.stdout.write("Replacing existing thumbnail") + + if converted_thumbnail.exists(): + shutil.copy(converted_thumbnail, existing_thumbnail) + + self.stdout.write( + self.style.SUCCESS("Conversion to WebP completed"), + ) + + except Exception as e: + self.stderr.write( + self.style.ERROR( + f"Error converting thumbnail (existing will be kept): {e}", + ), + ) + finally: + if converted_thumbnail.exists(): + converted_thumbnail.unlink() + + end = time.time() + duration = end - start + + self.stdout.write(f"Conversion completed in {duration:.3f}s") From 153d0bb12a75f6a57403c15dc847743a40140a58 Mon Sep 17 00:00:00 2001 From: Trenton Holmes Date: Fri, 10 Jun 2022 07:59:22 -0700 Subject: [PATCH 04/14] Corrects the logic of thumbnail path to account for both getting existing path or building expected path --- src/documents/models.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/documents/models.py b/src/documents/models.py index bd69aaff1..d889ef2c5 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -300,10 +300,26 @@ class Document(models.Model): png_file_name += ".gpg" webp_file_name += ".gpg" - thumb = os.path.join(settings.THUMBNAIL_DIR, webp_file_name) + # This property is used to both generate the file path + # and locate the file itself + # Hence why this looks a little weird - if not os.path.exists(thumb): - thumb = os.path.join(settings.THUMBNAIL_DIR, png_file_name) + webp_file_path = os.path.join(settings.THUMBNAIL_DIR, webp_file_name) + png_file_path = thumb = os.path.join(settings.THUMBNAIL_DIR, png_file_name) + + # 1. Assume the thumbnail is WebP + + if not os.path.exists(webp_file_path): + # 2. If WebP doesn't exist, check PNG + if not os.path.exists(png_file_path): + # 3. If PNG doesn't exist, filename is being constructed, return WebP + thumb = webp_file_path + else: + # 2.1 - PNG file exists, return path to it + thumb = png_file_name + else: + # 1.1 - WebP file exists, return path to it + thumb = webp_file_path return thumb @property From 34192349be82f2f01ad95a03c1073e17b10fc6b4 Mon Sep 17 00:00:00 2001 From: Trenton Holmes Date: Fri, 10 Jun 2022 08:56:25 -0700 Subject: [PATCH 05/14] Corrects the functionality of the webp conversion script --- docker/install_management_commands.sh | 14 +++- .../management/commands/convert_thumbnails.py | 84 +++++++++++-------- src/documents/models.py | 7 +- 3 files changed, 67 insertions(+), 38 deletions(-) diff --git a/docker/install_management_commands.sh b/docker/install_management_commands.sh index bf8bbeb93..beb600fdb 100755 --- a/docker/install_management_commands.sh +++ b/docker/install_management_commands.sh @@ -2,7 +2,19 @@ set -eu -for command in document_archiver document_exporter document_importer mail_fetcher document_create_classifier document_index document_renamer document_retagger document_thumbnails document_sanity_checker manage_superuser; +for command in convert_thumbnails \ + decrypt_documents \ + document_archiver \ + document_exporter \ + document_importer \ + mail_fetcher \ + document_create_classifier \ + document_index \ + document_renamer \ + document_retagger \ + document_thumbnails \ + document_sanity_checker \ + manage_superuser; do echo "installing $command..." sed "s/management_command/$command/g" management_script.sh > /usr/local/bin/$command diff --git a/src/documents/management/commands/convert_thumbnails.py b/src/documents/management/commands/convert_thumbnails.py index 249209580..0be4cb702 100644 --- a/src/documents/management/commands/convert_thumbnails.py +++ b/src/documents/management/commands/convert_thumbnails.py @@ -30,47 +30,65 @@ class Command(BaseCommand): documents = Document.objects.all() - for document in documents: - existing_thumbnail = Path(document.thumbnail_path) + with tempfile.TemporaryDirectory() as tempdir: - if existing_thumbnail.suffix == "png": + for document in documents: + existing_thumbnail = Path(document.thumbnail_path).resolve() - self.stdout.write(f"Converting thumbnail: {existing_thumbnail}") + if existing_thumbnail.suffix == ".png": - converted_thumbnail = Path(tempfile.mkstemp(suffix=".webp")) + self.stdout.write(f"Converting thumbnail: {existing_thumbnail}") - try: - run_convert( - density=300, - scale="500x5000>", - alpha="remove", - strip=True, - trim=False, - auto_orient=True, - input_file=f"{existing_thumbnail}[0]", - output_file=str(converted_thumbnail), - ) + # Change the existing filename suffix from png to webp + converted_thumbnail_name = existing_thumbnail.with_suffix( + ".webp", + ).name - self.stdout.write("Replacing existing thumbnail") + # Create the expected output filename in the tempdir + converted_thumbnail = ( + Path(tempdir) / Path(converted_thumbnail_name) + ).resolve() - if converted_thumbnail.exists(): - shutil.copy(converted_thumbnail, existing_thumbnail) + try: + # Run actual conversion + run_convert( + density=300, + scale="500x5000>", + alpha="remove", + strip=True, + trim=False, + auto_orient=True, + input_file=f"{existing_thumbnail}[0]", + output_file=str(converted_thumbnail), + ) - self.stdout.write( - self.style.SUCCESS("Conversion to WebP completed"), - ) + if converted_thumbnail.exists(): + # Copy newly created thumbnail to thumbnail directory + shutil.copy(converted_thumbnail, existing_thumbnail.parent) - except Exception as e: - self.stderr.write( - self.style.ERROR( - f"Error converting thumbnail (existing will be kept): {e}", - ), - ) - finally: - if converted_thumbnail.exists(): - converted_thumbnail.unlink() + # Remove the PNG version + existing_thumbnail.unlink() - end = time.time() - duration = end - start + self.stdout.write( + self.style.SUCCESS( + "Conversion to WebP completed", + ), + ) + else: + # Highly unlike to reach here + self.stderr.write( + self.style.WARNING("Converted thumbnail doesn't exist"), + ) + + except Exception as e: + self.stderr.write( + self.style.ERROR( + f"Error converting thumbnail" + f" (existing file unchanged): {e}", + ), + ) + + end = time.time() + duration = end - start self.stdout.write(f"Conversion completed in {duration:.3f}s") diff --git a/src/documents/models.py b/src/documents/models.py index d889ef2c5..221086ca2 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -305,10 +305,9 @@ class Document(models.Model): # Hence why this looks a little weird webp_file_path = os.path.join(settings.THUMBNAIL_DIR, webp_file_name) - png_file_path = thumb = os.path.join(settings.THUMBNAIL_DIR, png_file_name) + png_file_path = os.path.join(settings.THUMBNAIL_DIR, png_file_name) # 1. Assume the thumbnail is WebP - if not os.path.exists(webp_file_path): # 2. If WebP doesn't exist, check PNG if not os.path.exists(png_file_path): @@ -316,11 +315,11 @@ class Document(models.Model): thumb = webp_file_path else: # 2.1 - PNG file exists, return path to it - thumb = png_file_name + thumb = png_file_path else: # 1.1 - WebP file exists, return path to it thumb = webp_file_path - return thumb + return os.path.normpath(thumb) @property def thumbnail_file(self): From 12cdcf76816966af03fb452a3862710b8b1b030e Mon Sep 17 00:00:00 2001 From: Trenton Holmes Date: Fri, 10 Jun 2022 08:56:55 -0700 Subject: [PATCH 06/14] Adds information system check for PNG thumbnail existence --- src/documents/__init__.py | 3 ++- src/documents/checks.py | 23 ++++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/documents/__init__.py b/src/documents/__init__.py index dc94f2bdd..005c6e2fb 100644 --- a/src/documents/__init__.py +++ b/src/documents/__init__.py @@ -1,5 +1,6 @@ # this is here so that django finds the checks. from .checks import changed_password_check from .checks import parser_check +from .checks import png_thumbnail_check -__all__ = ["changed_password_check", "parser_check"] +__all__ = ["changed_password_check", "parser_check", "png_thumbnail_check"] diff --git a/src/documents/checks.py b/src/documents/checks.py index 4ac49a2c2..0a9511bd6 100644 --- a/src/documents/checks.py +++ b/src/documents/checks.py @@ -1,7 +1,9 @@ import textwrap +from pathlib import Path from django.conf import settings from django.core.checks import Error +from django.core.checks import Info from django.core.checks import register from django.core.exceptions import FieldError from django.db.utils import OperationalError @@ -11,7 +13,6 @@ from documents.signals import document_consumer_declaration @register() def changed_password_check(app_configs, **kwargs): - from documents.models import Document from paperless.db import GnuPG @@ -67,3 +68,23 @@ def parser_check(app_configs, **kwargs): ] else: return [] + + +@register() +def png_thumbnail_check(app_configs, **kwargs): + from documents.models import Document + + try: + documents = Document.objects.all() + for document in documents: + existing_thumbnail = Path(document.thumbnail_path).resolve() + if existing_thumbnail.suffix == ".png": + return [ + Info( + "PNG thumbnails found, consider running convert_thumbnails " + "to convert to WebP", + ), + ] + return [] + except (OperationalError, ProgrammingError, FieldError): + return [] # No documents table yet From b50325c3a3822c8463e0483d45573d1f4cb11228 Mon Sep 17 00:00:00 2001 From: Trenton Holmes Date: Fri, 10 Jun 2022 08:57:36 -0700 Subject: [PATCH 07/14] Minor tweak to determining content type --- src/documents/views.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/documents/views.py b/src/documents/views.py index 1e9583bc5..bcd2958f8 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -362,11 +362,12 @@ class DocumentViewSet( handle = doc.thumbnail_file # TODO: Send ETag information and use that to send new thumbnails # if available - content_type = ( - "image/webp" - if os.path.splitext(doc.thumbnail_path)[1] == ".webp" - else "image/png" - ) + thumbnail_path = doc.thumbnail_path + if os.path.splitext(thumbnail_path)[1] == ".webp": + content_type = "image/webp" + else: + content_type = "image/png" + return HttpResponse(handle, content_type=content_type) except (FileNotFoundError, Document.DoesNotExist): raise Http404() From 08c3d6e84b17da2acfb10250438fe357398e5e0e Mon Sep 17 00:00:00 2001 From: Trenton Holmes Date: Fri, 10 Jun 2022 10:12:01 -0700 Subject: [PATCH 08/14] Fixes existing testing, adds test coverage of new command --- src/documents/tests/test_management.py | 2 +- .../test_management_convert_thumbnail.py | 168 ++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 src/documents/tests/test_management_convert_thumbnail.py diff --git a/src/documents/tests/test_management.py b/src/documents/tests/test_management.py index 5e45086fe..65ed36ff9 100644 --- a/src/documents/tests/test_management.py +++ b/src/documents/tests/test_management.py @@ -163,7 +163,7 @@ class TestDecryptDocuments(TestCase): self.assertEqual(doc.filename, "0000004.pdf") self.assertTrue(os.path.isfile(os.path.join(originals_dir, "0000004.pdf"))) self.assertTrue(os.path.isfile(doc.source_path)) - self.assertTrue(os.path.isfile(os.path.join(thumb_dir, f"{doc.id:07}.png"))) + self.assertTrue(os.path.isfile(os.path.join(thumb_dir, f"{doc.id:07}.webp"))) self.assertTrue(os.path.isfile(doc.thumbnail_path)) with doc.source_file as f: diff --git a/src/documents/tests/test_management_convert_thumbnail.py b/src/documents/tests/test_management_convert_thumbnail.py new file mode 100644 index 000000000..162f05cfe --- /dev/null +++ b/src/documents/tests/test_management_convert_thumbnail.py @@ -0,0 +1,168 @@ +import filecmp +import shutil +import tempfile +from io import StringIO +from pathlib import Path +from unittest import mock + +from django.core.management import call_command +from django.test import override_settings +from django.test import TestCase +from documents.models import Document + + +class TestConvertThumbnails(TestCase): + def call_command(self): + stdout = StringIO() + stderr = StringIO() + call_command( + "convert_thumbnails", + "--no-color", + stdout=stdout, + stderr=stderr, + ) + return stdout.getvalue(), stderr.getvalue() + + def setUp(self): + """ + Creates a document in the database + """ + super().setUp() + + self.doc = Document.objects.create( + pk=1, + checksum="A", + title="A", + content="first document", + mime_type="application/pdf", + ) + self.doc.save() + + def pretend_convert_output(self, *args, **kwargs): + """ + Pretends to do the conversion, by copying the input file + to the output file + """ + shutil.copy2( + Path(kwargs["input_file"].rstrip("[0]")), + Path(kwargs["output_file"]), + ) + + def create_webp_thumbnail_file(self, thumb_dir): + """ + Creates a dummy WebP thumbnail file in the given directory, based on + the database Document + """ + thumb_file = Path(thumb_dir) / Path(f"{self.doc.pk:07}.webp") + thumb_file.write_text("this is a dummy webp file") + return thumb_file + + def create_png_thumbnail_file(self, thumb_dir): + """ + Creates a dummy PNG thumbnail file in the given directory, based on + the database Document + """ + thumb_file = Path(thumb_dir) / Path(f"{self.doc.pk:07}.png") + thumb_file.write_text("this is a dummy png file") + return thumb_file + + @mock.patch("documents.management.commands.convert_thumbnails.run_convert") + def test_do_nothing_if_converted(self, run_convert_mock): + """ + GIVEN: + - Document exists with default WebP thumbnail path + WHEN: + - Thumbnail conversion is attempted + THEN: + - Nothing is converted + """ + + stdout, _ = self.call_command() + run_convert_mock.assert_not_called() + self.assertIn("Converting all PNG thumbnails to WebP", stdout) + + @mock.patch("documents.management.commands.convert_thumbnails.run_convert") + def test_convert_single_thumbnail(self, run_convert_mock): + """ + GIVEN: + - Document exists with PNG thumbnail + WHEN: + - Thumbnail conversion is attempted + THEN: + - Single thumbnail is converted + """ + + run_convert_mock.side_effect = self.pretend_convert_output + + with tempfile.TemporaryDirectory() as thumbnail_dir: + + with override_settings( + THUMBNAIL_DIR=thumbnail_dir, + ): + + thumb_file = self.create_png_thumbnail_file(thumbnail_dir) + + stdout, _ = self.call_command() + + run_convert_mock.assert_called_once() + self.assertIn(f"{thumb_file}", stdout) + self.assertIn("Conversion to WebP completed", stdout) + + self.assertFalse(thumb_file.exists()) + self.assertTrue(thumb_file.with_suffix(".webp").exists()) + + @mock.patch("documents.management.commands.convert_thumbnails.run_convert") + def test_convert_errors_out(self, run_convert_mock): + """ + GIVEN: + - Document exists with PNG thumbnail + WHEN: + - Thumbnail conversion is attempted, but raises an exception + THEN: + - Single thumbnail is converted + """ + + run_convert_mock.side_effect = OSError + + with tempfile.TemporaryDirectory() as thumbnail_dir: + + with override_settings( + THUMBNAIL_DIR=thumbnail_dir, + ): + + thumb_file = self.create_png_thumbnail_file(thumbnail_dir) + + _, stderr = self.call_command() + + run_convert_mock.assert_called_once() + self.assertIn("Error converting thumbnail", stderr) + self.assertTrue(thumb_file.exists()) + + @mock.patch("documents.management.commands.convert_thumbnails.run_convert") + def test_convert_single_thumbnail_no_output(self, run_convert_mock): + """ + GIVEN: + - Document exists with PNG thumbnail + WHEN: + - Thumbnail conversion is attempted, but there is no output WebP + THEN: + - Single thumbnail is converted + """ + + with tempfile.TemporaryDirectory() as thumbnail_dir: + + with override_settings( + THUMBNAIL_DIR=thumbnail_dir, + ): + + thumb_file = self.create_png_thumbnail_file(thumbnail_dir) + + stdout, stderr = self.call_command() + + run_convert_mock.assert_called_once() + self.assertIn(f"{thumb_file}", stdout) + self.assertNotIn("Conversion to WebP completed", stdout) + self.assertIn("Converted thumbnail doesn't exist", stderr) + + self.assertTrue(thumb_file.exists()) + self.assertFalse(thumb_file.with_suffix(".webp").exists()) From 87322d7732a916053b3c9ef35932f1ba3ad55581 Mon Sep 17 00:00:00 2001 From: Trenton Holmes Date: Fri, 10 Jun 2022 11:26:14 -0700 Subject: [PATCH 09/14] Adds quick documentation of new functionality --- docs/administration.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/administration.rst b/docs/administration.rst index dfb88ff80..f1930256c 100644 --- a/docs/administration.rst +++ b/docs/administration.rst @@ -518,3 +518,15 @@ Basic usage to disable encryption of your document store: .. code:: decrypt_documents [--passphrase SECR3TP4SSPHRA$E] + +Managing thumbnail format +=================== + +Document thumbnails were originally created as PNG format. Newly +uploaded documents are now using WebP to reduce both storage space and +page loading times. To convert older PNG format thumbnails to WebP +run: + +.. code:: + + convert_thumbnails From 7d9a9033f93cd5f6c73dce9c9a723af8f610472a Mon Sep 17 00:00:00 2001 From: Trenton Holmes Date: Fri, 10 Jun 2022 13:17:41 -0700 Subject: [PATCH 10/14] Fixes the re-generation of thumbnails making webp file, but named as PNG files --- .../management/commands/document_thumbnails.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/documents/management/commands/document_thumbnails.py b/src/documents/management/commands/document_thumbnails.py index c9928c7cc..595d8ba3b 100644 --- a/src/documents/management/commands/document_thumbnails.py +++ b/src/documents/management/commands/document_thumbnails.py @@ -1,6 +1,7 @@ import logging import multiprocessing import shutil +from pathlib import Path import tqdm from django import db @@ -11,7 +12,7 @@ from ...parsers import get_parser_class_for_mime_type def _process_document(doc_in): - document = Document.objects.get(id=doc_in) + document: Document = Document.objects.get(id=doc_in) parser_class = get_parser_class_for_mime_type(document.mime_type) if parser_class: @@ -21,6 +22,13 @@ def _process_document(doc_in): return try: + + existing_thumbnail = Path(document.thumbnail_path).resolve() + + # Remove an existing PNG format thumbnail, if it existed + if existing_thumbnail.exists() and existing_thumbnail.suffix == ".png": + existing_thumbnail.unlink() + thumb = parser.get_optimised_thumbnail( document.source_path, document.mime_type, @@ -69,7 +77,7 @@ class Command(BaseCommand): ids = [doc.id for doc in documents] # Note to future self: this prevents django from reusing database - # conncetions between processes, which is bad and does not work + # connections between processes, which is bad and does not work # with postgres. db.connections.close_all() From e8868d7ebf144fc32612dd751a16e57c4d9d3f57 Mon Sep 17 00:00:00 2001 From: Trenton Holmes Date: Sat, 11 Jun 2022 08:38:49 -0700 Subject: [PATCH 11/14] Entirely removes the optipng, updates ghostscript fall back to also use WebP. Updates the conversion to use a multiprocessing pool --- .github/workflows/reusable-ci-backend.yml | 2 +- Dockerfile | 1 - docs/configuration.rst | 10 -- docs/setup.rst | 5 +- paperless.conf.example | 2 - src/documents/consumer.py | 2 +- .../management/commands/convert_thumbnails.py | 91 ++++++++++--------- .../management/commands/document_archiver.py | 2 +- .../commands/document_thumbnails.py | 2 +- src/documents/models.py | 12 +-- src/documents/parsers.py | 28 +----- src/documents/tests/test_consumer.py | 6 +- .../test_management_convert_thumbnail.py | 29 ------ src/documents/tests/test_parsers.py | 25 ----- src/paperless/checks.py | 2 +- src/paperless/settings.py | 4 - src/paperless/tests/test_checks.py | 4 +- 17 files changed, 65 insertions(+), 162 deletions(-) diff --git a/.github/workflows/reusable-ci-backend.yml b/.github/workflows/reusable-ci-backend.yml index 977011b2c..e872e8696 100644 --- a/.github/workflows/reusable-ci-backend.yml +++ b/.github/workflows/reusable-ci-backend.yml @@ -74,7 +74,7 @@ jobs: name: Install system dependencies run: | sudo apt-get update -qq - sudo apt-get install -qq --no-install-recommends unpaper tesseract-ocr imagemagick ghostscript optipng libzbar0 poppler-utils + sudo apt-get install -qq --no-install-recommends unpaper tesseract-ocr imagemagick ghostscript libzbar0 poppler-utils - name: Install Python dependencies run: | diff --git a/Dockerfile b/Dockerfile index 5338d8aa4..fda47998c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -77,7 +77,6 @@ ARG RUNTIME_PACKAGES="\ libraqm0 \ libgnutls30 \ libjpeg62-turbo \ - optipng \ python3 \ python3-pip \ python3-setuptools \ diff --git a/docs/configuration.rst b/docs/configuration.rst index b7ab978f4..a5db55927 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -712,13 +712,6 @@ PAPERLESS_CONVERT_TMPDIR= Default is none, which disables the temporary directory. -PAPERLESS_OPTIMIZE_THUMBNAILS= - Use optipng to optimize thumbnails. This usually reduces the size of - thumbnails by about 20%, but uses considerable compute time during - consumption. - - Defaults to true. - PAPERLESS_POST_CONSUME_SCRIPT= After a document is consumed, Paperless can trigger an arbitrary script if you like. This script will be passed a number of arguments for you to work @@ -789,9 +782,6 @@ PAPERLESS_CONVERT_BINARY= PAPERLESS_GS_BINARY= Defaults to "/usr/bin/gs". -PAPERLESS_OPTIPNG_BINARY= - Defaults to "/usr/bin/optipng". - .. _configuration-docker: diff --git a/docs/setup.rst b/docs/setup.rst index 90b952e4c..b8d3ab8a3 100644 --- a/docs/setup.rst +++ b/docs/setup.rst @@ -286,7 +286,6 @@ writing. Windows is not and will never be supported. * ``fonts-liberation`` for generating thumbnails for plain text files * ``imagemagick`` >= 6 for PDF conversion - * ``optipng`` for optimizing thumbnails * ``gnupg`` for handling encrypted documents * ``libpq-dev`` for PostgreSQL * ``libmagic-dev`` for mime type detection @@ -298,7 +297,7 @@ writing. Windows is not and will never be supported. .. code:: - python3 python3-pip python3-dev imagemagick fonts-liberation optipng gnupg libpq-dev libmagic-dev mime-support libzbar0 poppler-utils + python3 python3-pip python3-dev imagemagick fonts-liberation gnupg libpq-dev libmagic-dev mime-support libzbar0 poppler-utils These dependencies are required for OCRmyPDF, which is used for text recognition. @@ -730,8 +729,6 @@ configuring some options in paperless can help improve performance immensely: * If you want to perform OCR on the device, consider using ``PAPERLESS_OCR_CLEAN=none``. This will speed up OCR times and use less memory at the expense of slightly worse OCR results. -* Set ``PAPERLESS_OPTIMIZE_THUMBNAILS`` to 'false' if you want faster consumption - times. Thumbnails will be about 20% larger. * If using docker, consider setting ``PAPERLESS_WEBSERVER_WORKERS`` to 1. This will save some memory. diff --git a/paperless.conf.example b/paperless.conf.example index 97e907e1f..bb2449e05 100644 --- a/paperless.conf.example +++ b/paperless.conf.example @@ -65,7 +65,6 @@ #PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS=false #PAPERLESS_CONSUMER_ENABLE_BARCODES=false #PAPERLESS_CONSUMER_ENABLE_BARCODES=PATCHT -#PAPERLESS_OPTIMIZE_THUMBNAILS=true #PAPERLESS_PRE_CONSUME_SCRIPT=/path/to/an/arbitrary/script.sh #PAPERLESS_POST_CONSUME_SCRIPT=/path/to/an/arbitrary/script.sh #PAPERLESS_FILENAME_DATE_ORDER=YMD @@ -84,4 +83,3 @@ #PAPERLESS_CONVERT_BINARY=/usr/bin/convert #PAPERLESS_GS_BINARY=/usr/bin/gs -#PAPERLESS_OPTIPNG_BINARY=/usr/bin/optipng diff --git a/src/documents/consumer.py b/src/documents/consumer.py index 5e3d01fbc..e5794ce4f 100644 --- a/src/documents/consumer.py +++ b/src/documents/consumer.py @@ -273,7 +273,7 @@ class Consumer(LoggingMixin): self.log("debug", f"Generating thumbnail for {self.filename}...") self._send_progress(70, 100, "WORKING", MESSAGE_GENERATING_THUMBNAIL) - thumbnail = document_parser.get_optimised_thumbnail( + thumbnail = document_parser.get_thumbnail( self.path, mime_type, self.filename, diff --git a/src/documents/management/commands/convert_thumbnails.py b/src/documents/management/commands/convert_thumbnails.py index 0be4cb702..089c689c9 100644 --- a/src/documents/management/commands/convert_thumbnails.py +++ b/src/documents/management/commands/convert_thumbnails.py @@ -1,4 +1,5 @@ import logging +import multiprocessing.pool import shutil import tempfile import time @@ -8,10 +9,44 @@ from django.core.management.base import BaseCommand from documents.models import Document from documents.parsers import run_convert - logger = logging.getLogger("paperless.management.convert_thumbnails") +def _do_convert(work_package): + _, existing_thumbnail, converted_thumbnail = work_package + try: + + logger.info(f"Converting thumbnail: {existing_thumbnail}") + + # Run actual conversion + run_convert( + density=300, + scale="500x5000>", + alpha="remove", + strip=True, + trim=False, + auto_orient=True, + input_file=f"{existing_thumbnail}[0]", + output_file=str(converted_thumbnail), + ) + + # Copy newly created thumbnail to thumbnail directory + shutil.copy(converted_thumbnail, existing_thumbnail.parent) + + # Remove the PNG version + existing_thumbnail.unlink() + + logger.info( + "Conversion to WebP completed, " + f"replaced {existing_thumbnail.name} with {converted_thumbnail.name}", + ) + + except Exception as e: + logger.error( + f"Error converting thumbnail" f" (existing file unchanged): {e}", + ) + + class Command(BaseCommand): help = """ @@ -24,21 +59,19 @@ class Command(BaseCommand): def handle(self, *args, **options): - self.stdout.write("Converting all PNG thumbnails to WebP") - + logger.info("Converting all PNG thumbnails to WebP") start = time.time() - documents = Document.objects.all() with tempfile.TemporaryDirectory() as tempdir: + work_packages = [] + for document in documents: existing_thumbnail = Path(document.thumbnail_path).resolve() if existing_thumbnail.suffix == ".png": - self.stdout.write(f"Converting thumbnail: {existing_thumbnail}") - # Change the existing filename suffix from png to webp converted_thumbnail_name = existing_thumbnail.with_suffix( ".webp", @@ -49,46 +82,16 @@ class Command(BaseCommand): Path(tempdir) / Path(converted_thumbnail_name) ).resolve() - try: - # Run actual conversion - run_convert( - density=300, - scale="500x5000>", - alpha="remove", - strip=True, - trim=False, - auto_orient=True, - input_file=f"{existing_thumbnail}[0]", - output_file=str(converted_thumbnail), - ) + # Package up the necessary info + work_packages.append( + (document, existing_thumbnail, converted_thumbnail), + ) - if converted_thumbnail.exists(): - # Copy newly created thumbnail to thumbnail directory - shutil.copy(converted_thumbnail, existing_thumbnail.parent) - - # Remove the PNG version - existing_thumbnail.unlink() - - self.stdout.write( - self.style.SUCCESS( - "Conversion to WebP completed", - ), - ) - else: - # Highly unlike to reach here - self.stderr.write( - self.style.WARNING("Converted thumbnail doesn't exist"), - ) - - except Exception as e: - self.stderr.write( - self.style.ERROR( - f"Error converting thumbnail" - f" (existing file unchanged): {e}", - ), - ) + if len(work_packages): + with multiprocessing.pool.Pool(processes=4, maxtasksperchild=4) as pool: + pool.map(_do_convert, work_packages) end = time.time() duration = end - start - self.stdout.write(f"Conversion completed in {duration:.3f}s") + logger.info(f"Conversion completed in {duration:.3f}s") diff --git a/src/documents/management/commands/document_archiver.py b/src/documents/management/commands/document_archiver.py index bf0f352b5..c51f1baeb 100644 --- a/src/documents/management/commands/document_archiver.py +++ b/src/documents/management/commands/document_archiver.py @@ -41,7 +41,7 @@ def handle_document(document_id): try: parser.parse(document.source_path, mime_type, document.get_public_filename()) - thumbnail = parser.get_optimised_thumbnail( + thumbnail = parser.get_thumbnail( document.source_path, mime_type, document.get_public_filename(), diff --git a/src/documents/management/commands/document_thumbnails.py b/src/documents/management/commands/document_thumbnails.py index 595d8ba3b..535a0f670 100644 --- a/src/documents/management/commands/document_thumbnails.py +++ b/src/documents/management/commands/document_thumbnails.py @@ -29,7 +29,7 @@ def _process_document(doc_in): if existing_thumbnail.exists() and existing_thumbnail.suffix == ".png": existing_thumbnail.unlink() - thumb = parser.get_optimised_thumbnail( + thumb = parser.get_thumbnail( document.source_path, document.mime_type, document.get_public_filename(), diff --git a/src/documents/models.py b/src/documents/models.py index 221086ca2..9fed321c3 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -308,17 +308,11 @@ class Document(models.Model): png_file_path = os.path.join(settings.THUMBNAIL_DIR, png_file_name) # 1. Assume the thumbnail is WebP - if not os.path.exists(webp_file_path): - # 2. If WebP doesn't exist, check PNG - if not os.path.exists(png_file_path): - # 3. If PNG doesn't exist, filename is being constructed, return WebP - thumb = webp_file_path - else: - # 2.1 - PNG file exists, return path to it - thumb = png_file_path + if os.path.exists(png_file_path): + thumb = png_file_path else: - # 1.1 - WebP file exists, return path to it thumb = webp_file_path + return os.path.normpath(thumb) @property diff --git a/src/documents/parsers.py b/src/documents/parsers.py index bc8af0ec8..721346fb0 100644 --- a/src/documents/parsers.py +++ b/src/documents/parsers.py @@ -150,11 +150,14 @@ def run_convert( def get_default_thumbnail() -> str: + """ + Returns the path to a generic thumbnail + """ return os.path.join(os.path.dirname(__file__), "resources", "document.png") def make_thumbnail_from_pdf_gs_fallback(in_path, temp_dir, logging_group=None) -> str: - out_path = os.path.join(temp_dir, "convert_gs.png") + out_path = os.path.join(temp_dir, "convert_gs.webp") # if convert fails, fall back to extracting # the first PDF page as a PNG using Ghostscript @@ -319,29 +322,6 @@ class DocumentParser(LoggingMixin): """ raise NotImplementedError() - def get_optimised_thumbnail(self, document_path, mime_type, file_name=None): - thumbnail = self.get_thumbnail(document_path, mime_type, file_name) - if settings.OPTIMIZE_THUMBNAILS and os.path.splitext(thumbnail)[1] == ".png": - out_path = os.path.join(self.tempdir, "thumb_optipng.png") - - args = ( - settings.OPTIPNG_BINARY, - "-silent", - "-o5", - thumbnail, - "-out", - out_path, - ) - - self.log("debug", f"Execute: {' '.join(args)}") - - if not subprocess.Popen(args).wait() == 0: - raise ParseError(f"Optipng failed at {args}") - - return out_path - else: - return thumbnail - def get_text(self): return self.text diff --git a/src/documents/tests/test_consumer.py b/src/documents/tests/test_consumer.py index 637c0d95e..a770d3ff6 100644 --- a/src/documents/tests/test_consumer.py +++ b/src/documents/tests/test_consumer.py @@ -183,7 +183,7 @@ class DummyParser(DocumentParser): _, self.fake_thumb = tempfile.mkstemp(suffix=".png", dir=scratch_dir) self.archive_path = archive_path - def get_optimised_thumbnail(self, document_path, mime_type, file_name=None): + def get_thumbnail(self, document_path, mime_type, file_name=None): return self.fake_thumb def parse(self, document_path, mime_type, file_name=None): @@ -194,7 +194,7 @@ class CopyParser(DocumentParser): def get_thumbnail(self, document_path, mime_type, file_name=None): return self.fake_thumb - def get_optimised_thumbnail(self, document_path, mime_type, file_name=None): + def get_thumbnail(self, document_path, mime_type, file_name=None): return self.fake_thumb def __init__(self, logging_group, progress_callback=None): @@ -216,7 +216,7 @@ class FaultyParser(DocumentParser): super().__init__(logging_group) _, self.fake_thumb = tempfile.mkstemp(suffix=".png", dir=scratch_dir) - def get_optimised_thumbnail(self, document_path, mime_type, file_name=None): + def get_thumbnail(self, document_path, mime_type, file_name=None): return self.fake_thumb def parse(self, document_path, mime_type, file_name=None): diff --git a/src/documents/tests/test_management_convert_thumbnail.py b/src/documents/tests/test_management_convert_thumbnail.py index 162f05cfe..8413cec3a 100644 --- a/src/documents/tests/test_management_convert_thumbnail.py +++ b/src/documents/tests/test_management_convert_thumbnail.py @@ -137,32 +137,3 @@ class TestConvertThumbnails(TestCase): run_convert_mock.assert_called_once() self.assertIn("Error converting thumbnail", stderr) self.assertTrue(thumb_file.exists()) - - @mock.patch("documents.management.commands.convert_thumbnails.run_convert") - def test_convert_single_thumbnail_no_output(self, run_convert_mock): - """ - GIVEN: - - Document exists with PNG thumbnail - WHEN: - - Thumbnail conversion is attempted, but there is no output WebP - THEN: - - Single thumbnail is converted - """ - - with tempfile.TemporaryDirectory() as thumbnail_dir: - - with override_settings( - THUMBNAIL_DIR=thumbnail_dir, - ): - - thumb_file = self.create_png_thumbnail_file(thumbnail_dir) - - stdout, stderr = self.call_command() - - run_convert_mock.assert_called_once() - self.assertIn(f"{thumb_file}", stdout) - self.assertNotIn("Conversion to WebP completed", stdout) - self.assertIn("Converted thumbnail doesn't exist", stderr) - - self.assertTrue(thumb_file.exists()) - self.assertFalse(thumb_file.with_suffix(".webp").exists()) diff --git a/src/documents/tests/test_parsers.py b/src/documents/tests/test_parsers.py index 34711bca8..1942fe0dd 100644 --- a/src/documents/tests/test_parsers.py +++ b/src/documents/tests/test_parsers.py @@ -87,31 +87,6 @@ def fake_get_thumbnail(self, path, mimetype, file_name): return os.path.join(os.path.dirname(__file__), "examples", "no-text.png") -class TestBaseParser(TestCase): - def setUp(self) -> None: - - self.scratch = tempfile.mkdtemp() - override_settings(SCRATCH_DIR=self.scratch).enable() - - def tearDown(self) -> None: - shutil.rmtree(self.scratch) - - @mock.patch("documents.parsers.DocumentParser.get_thumbnail", fake_get_thumbnail) - @override_settings(OPTIMIZE_THUMBNAILS=True) - def test_get_optimised_thumbnail(self): - parser = DocumentParser(None) - - parser.get_optimised_thumbnail("any", "not important", "document.pdf") - - @mock.patch("documents.parsers.DocumentParser.get_thumbnail", fake_get_thumbnail) - @override_settings(OPTIMIZE_THUMBNAILS=False) - def test_get_optimised_thumb_disabled(self): - parser = DocumentParser(None) - - path = parser.get_optimised_thumbnail("any", "not important", "document.pdf") - self.assertEqual(path, fake_get_thumbnail(None, None, None, None)) - - class TestParserAvailability(TestCase): def test_file_extensions(self): diff --git a/src/paperless/checks.py b/src/paperless/checks.py index ee9b95e09..26d18b692 100644 --- a/src/paperless/checks.py +++ b/src/paperless/checks.py @@ -72,7 +72,7 @@ def binaries_check(app_configs, **kwargs): error = "Paperless can't find {}. Without it, consumption is impossible." hint = "Either it's not in your ${PATH} or it's not installed." - binaries = (settings.CONVERT_BINARY, settings.OPTIPNG_BINARY, "tesseract") + binaries = (settings.CONVERT_BINARY, "tesseract") check_messages = [] for binary in binaries: diff --git a/src/paperless/settings.py b/src/paperless/settings.py index 9a5d9453d..8c8aa8482 100644 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -526,8 +526,6 @@ CONSUMER_BARCODE_TIFF_SUPPORT = __get_boolean( CONSUMER_BARCODE_STRING = os.getenv("PAPERLESS_CONSUMER_BARCODE_STRING", "PATCHT") -OPTIMIZE_THUMBNAILS = __get_boolean("PAPERLESS_OPTIMIZE_THUMBNAILS", "true") - OCR_PAGES = int(os.getenv("PAPERLESS_OCR_PAGES", 0)) # The default language that tesseract will attempt to use when parsing @@ -570,8 +568,6 @@ CONVERT_MEMORY_LIMIT = os.getenv("PAPERLESS_CONVERT_MEMORY_LIMIT") GS_BINARY = os.getenv("PAPERLESS_GS_BINARY", "gs") -OPTIPNG_BINARY = os.getenv("PAPERLESS_OPTIPNG_BINARY", "optipng") - # Pre-2.x versions of Paperless stored your documents locally with GPG # encryption, but that is no longer the default. This behaviour is still diff --git a/src/paperless/tests/test_checks.py b/src/paperless/tests/test_checks.py index df0cb0afd..ba45ebf79 100644 --- a/src/paperless/tests/test_checks.py +++ b/src/paperless/tests/test_checks.py @@ -13,9 +13,9 @@ class TestChecks(DirectoriesMixin, TestCase): def test_binaries(self): self.assertEqual(binaries_check(None), []) - @override_settings(CONVERT_BINARY="uuuhh", OPTIPNG_BINARY="forgot") + @override_settings(CONVERT_BINARY="uuuhh") def test_binaries_fail(self): - self.assertEqual(len(binaries_check(None)), 2) + self.assertEqual(len(binaries_check(None)), 1) def test_paths_check(self): self.assertEqual(paths_check(None), []) From cc4cea1a41c19e01cf93e0b21d4da56d65600bb3 Mon Sep 17 00:00:00 2001 From: Trenton Holmes Date: Sat, 11 Jun 2022 13:04:21 -0700 Subject: [PATCH 12/14] Converts the conversion into a database migration --- docker/install_management_commands.sh | 3 +- .../management/commands/convert_thumbnails.py | 97 -------- .../1021_webp_thumbnail_conversion.py | 107 ++++++++ src/documents/models.py | 15 +- .../test_management_convert_thumbnail.py | 139 ----------- .../tests/test_migration_webp_conversion.py | 231 ++++++++++++++++++ 6 files changed, 340 insertions(+), 252 deletions(-) delete mode 100644 src/documents/management/commands/convert_thumbnails.py create mode 100644 src/documents/migrations/1021_webp_thumbnail_conversion.py delete mode 100644 src/documents/tests/test_management_convert_thumbnail.py create mode 100644 src/documents/tests/test_migration_webp_conversion.py diff --git a/docker/install_management_commands.sh b/docker/install_management_commands.sh index beb600fdb..e5c8b30a0 100755 --- a/docker/install_management_commands.sh +++ b/docker/install_management_commands.sh @@ -2,8 +2,7 @@ set -eu -for command in convert_thumbnails \ - decrypt_documents \ +for command in decrypt_documents \ document_archiver \ document_exporter \ document_importer \ diff --git a/src/documents/management/commands/convert_thumbnails.py b/src/documents/management/commands/convert_thumbnails.py deleted file mode 100644 index 089c689c9..000000000 --- a/src/documents/management/commands/convert_thumbnails.py +++ /dev/null @@ -1,97 +0,0 @@ -import logging -import multiprocessing.pool -import shutil -import tempfile -import time -from pathlib import Path - -from django.core.management.base import BaseCommand -from documents.models import Document -from documents.parsers import run_convert - -logger = logging.getLogger("paperless.management.convert_thumbnails") - - -def _do_convert(work_package): - _, existing_thumbnail, converted_thumbnail = work_package - try: - - logger.info(f"Converting thumbnail: {existing_thumbnail}") - - # Run actual conversion - run_convert( - density=300, - scale="500x5000>", - alpha="remove", - strip=True, - trim=False, - auto_orient=True, - input_file=f"{existing_thumbnail}[0]", - output_file=str(converted_thumbnail), - ) - - # Copy newly created thumbnail to thumbnail directory - shutil.copy(converted_thumbnail, existing_thumbnail.parent) - - # Remove the PNG version - existing_thumbnail.unlink() - - logger.info( - "Conversion to WebP completed, " - f"replaced {existing_thumbnail.name} with {converted_thumbnail.name}", - ) - - except Exception as e: - logger.error( - f"Error converting thumbnail" f" (existing file unchanged): {e}", - ) - - -class Command(BaseCommand): - - help = """ - Converts existing PNG thumbnails into - WebP format. - """.replace( - " ", - "", - ) - - def handle(self, *args, **options): - - logger.info("Converting all PNG thumbnails to WebP") - start = time.time() - documents = Document.objects.all() - - with tempfile.TemporaryDirectory() as tempdir: - - work_packages = [] - - for document in documents: - existing_thumbnail = Path(document.thumbnail_path).resolve() - - if existing_thumbnail.suffix == ".png": - - # Change the existing filename suffix from png to webp - converted_thumbnail_name = existing_thumbnail.with_suffix( - ".webp", - ).name - - # Create the expected output filename in the tempdir - converted_thumbnail = ( - Path(tempdir) / Path(converted_thumbnail_name) - ).resolve() - - # Package up the necessary info - work_packages.append( - (document, existing_thumbnail, converted_thumbnail), - ) - - if len(work_packages): - with multiprocessing.pool.Pool(processes=4, maxtasksperchild=4) as pool: - pool.map(_do_convert, work_packages) - - end = time.time() - duration = end - start - - logger.info(f"Conversion completed in {duration:.3f}s") diff --git a/src/documents/migrations/1021_webp_thumbnail_conversion.py b/src/documents/migrations/1021_webp_thumbnail_conversion.py new file mode 100644 index 000000000..c5a1c8733 --- /dev/null +++ b/src/documents/migrations/1021_webp_thumbnail_conversion.py @@ -0,0 +1,107 @@ +# Generated by Django 4.0.5 on 2022-06-11 15:40 +import logging +import multiprocessing.pool +import shutil +import tempfile +import time +from pathlib import Path + +from django.conf import settings +from django.db import migrations +from documents.parsers import run_convert + +logger = logging.getLogger("paperless.migrations") + + +def _do_convert(work_package): + existing_thumbnail, converted_thumbnail = work_package + try: + + logger.info(f"Converting thumbnail: {existing_thumbnail}") + + # Run actual conversion + run_convert( + density=300, + scale="500x5000>", + alpha="remove", + strip=True, + trim=False, + auto_orient=True, + input_file=f"{existing_thumbnail}[0]", + output_file=str(converted_thumbnail), + ) + + # Copy newly created thumbnail to thumbnail directory + shutil.copy(converted_thumbnail, existing_thumbnail.parent) + + # Remove the PNG version + existing_thumbnail.unlink() + + logger.info( + "Conversion to WebP completed, " + f"replaced {existing_thumbnail.name} with {converted_thumbnail.name}", + ) + + except Exception as e: + logger.error(f"Error converting thumbnail (existing file unchanged): {e}") + + +def _convert_thumbnails_to_webp(apps, schema_editor): + start = time.time() + + with tempfile.TemporaryDirectory() as tempdir: + + work_packages = [] + + for file in Path(settings.THUMBNAIL_DIR).glob("*.png"): + existing_thumbnail = file.resolve() + + # Change the existing filename suffix from png to webp + converted_thumbnail_name = existing_thumbnail.with_suffix( + ".webp", + ).name + + # Create the expected output filename in the tempdir + converted_thumbnail = ( + Path(tempdir) / Path(converted_thumbnail_name) + ).resolve() + + # Package up the necessary info + work_packages.append( + (existing_thumbnail, converted_thumbnail), + ) + + if len(work_packages): + + logger.info( + "\n\n" + " This is a one-time only migration to convert thumbnails for all of your\n" + " documents into WebP format. If you have a lot of documents though, \n" + " this may take a while, so a coffee break may be in order." + "\n", + ) + + with multiprocessing.pool.Pool( + processes=min(multiprocessing.cpu_count(), 4), + maxtasksperchild=4, + ) as pool: + pool.map(_do_convert, work_packages) + + end = time.time() + duration = end - start + + logger.info(f"Conversion completed in {duration:.3f}s") + + +class Migration(migrations.Migration): + + dependencies = [ + ("documents", "1020_merge_20220518_1839"), + ] + + operations = [ + migrations.RunPython( + code=_convert_thumbnails_to_webp, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 9fed321c3..f24ce462e 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -294,26 +294,13 @@ class Document(models.Model): @property def thumbnail_path(self) -> str: - png_file_name = f"{self.pk:07}.png" webp_file_name = f"{self.pk:07}.webp" if self.storage_type == self.STORAGE_TYPE_GPG: - png_file_name += ".gpg" webp_file_name += ".gpg" - # This property is used to both generate the file path - # and locate the file itself - # Hence why this looks a little weird - webp_file_path = os.path.join(settings.THUMBNAIL_DIR, webp_file_name) - png_file_path = os.path.join(settings.THUMBNAIL_DIR, png_file_name) - # 1. Assume the thumbnail is WebP - if os.path.exists(png_file_path): - thumb = png_file_path - else: - thumb = webp_file_path - - return os.path.normpath(thumb) + return os.path.normpath(webp_file_path) @property def thumbnail_file(self): diff --git a/src/documents/tests/test_management_convert_thumbnail.py b/src/documents/tests/test_management_convert_thumbnail.py deleted file mode 100644 index 8413cec3a..000000000 --- a/src/documents/tests/test_management_convert_thumbnail.py +++ /dev/null @@ -1,139 +0,0 @@ -import filecmp -import shutil -import tempfile -from io import StringIO -from pathlib import Path -from unittest import mock - -from django.core.management import call_command -from django.test import override_settings -from django.test import TestCase -from documents.models import Document - - -class TestConvertThumbnails(TestCase): - def call_command(self): - stdout = StringIO() - stderr = StringIO() - call_command( - "convert_thumbnails", - "--no-color", - stdout=stdout, - stderr=stderr, - ) - return stdout.getvalue(), stderr.getvalue() - - def setUp(self): - """ - Creates a document in the database - """ - super().setUp() - - self.doc = Document.objects.create( - pk=1, - checksum="A", - title="A", - content="first document", - mime_type="application/pdf", - ) - self.doc.save() - - def pretend_convert_output(self, *args, **kwargs): - """ - Pretends to do the conversion, by copying the input file - to the output file - """ - shutil.copy2( - Path(kwargs["input_file"].rstrip("[0]")), - Path(kwargs["output_file"]), - ) - - def create_webp_thumbnail_file(self, thumb_dir): - """ - Creates a dummy WebP thumbnail file in the given directory, based on - the database Document - """ - thumb_file = Path(thumb_dir) / Path(f"{self.doc.pk:07}.webp") - thumb_file.write_text("this is a dummy webp file") - return thumb_file - - def create_png_thumbnail_file(self, thumb_dir): - """ - Creates a dummy PNG thumbnail file in the given directory, based on - the database Document - """ - thumb_file = Path(thumb_dir) / Path(f"{self.doc.pk:07}.png") - thumb_file.write_text("this is a dummy png file") - return thumb_file - - @mock.patch("documents.management.commands.convert_thumbnails.run_convert") - def test_do_nothing_if_converted(self, run_convert_mock): - """ - GIVEN: - - Document exists with default WebP thumbnail path - WHEN: - - Thumbnail conversion is attempted - THEN: - - Nothing is converted - """ - - stdout, _ = self.call_command() - run_convert_mock.assert_not_called() - self.assertIn("Converting all PNG thumbnails to WebP", stdout) - - @mock.patch("documents.management.commands.convert_thumbnails.run_convert") - def test_convert_single_thumbnail(self, run_convert_mock): - """ - GIVEN: - - Document exists with PNG thumbnail - WHEN: - - Thumbnail conversion is attempted - THEN: - - Single thumbnail is converted - """ - - run_convert_mock.side_effect = self.pretend_convert_output - - with tempfile.TemporaryDirectory() as thumbnail_dir: - - with override_settings( - THUMBNAIL_DIR=thumbnail_dir, - ): - - thumb_file = self.create_png_thumbnail_file(thumbnail_dir) - - stdout, _ = self.call_command() - - run_convert_mock.assert_called_once() - self.assertIn(f"{thumb_file}", stdout) - self.assertIn("Conversion to WebP completed", stdout) - - self.assertFalse(thumb_file.exists()) - self.assertTrue(thumb_file.with_suffix(".webp").exists()) - - @mock.patch("documents.management.commands.convert_thumbnails.run_convert") - def test_convert_errors_out(self, run_convert_mock): - """ - GIVEN: - - Document exists with PNG thumbnail - WHEN: - - Thumbnail conversion is attempted, but raises an exception - THEN: - - Single thumbnail is converted - """ - - run_convert_mock.side_effect = OSError - - with tempfile.TemporaryDirectory() as thumbnail_dir: - - with override_settings( - THUMBNAIL_DIR=thumbnail_dir, - ): - - thumb_file = self.create_png_thumbnail_file(thumbnail_dir) - - _, stderr = self.call_command() - - run_convert_mock.assert_called_once() - self.assertIn("Error converting thumbnail", stderr) - self.assertTrue(thumb_file.exists()) diff --git a/src/documents/tests/test_migration_webp_conversion.py b/src/documents/tests/test_migration_webp_conversion.py new file mode 100644 index 000000000..a3a5fa6bc --- /dev/null +++ b/src/documents/tests/test_migration_webp_conversion.py @@ -0,0 +1,231 @@ +import shutil +import tempfile +from pathlib import Path +from typing import Callable +from typing import Iterable +from typing import Union +from unittest import mock + +from django.test import override_settings +from documents.tests.test_migration_archive_files import thumbnail_path +from documents.tests.utils import TestMigrations + + +@mock.patch( + "documents.migrations.1021_webp_thumbnail_conversion.multiprocessing.pool.Pool.map", +) +@mock.patch("documents.migrations.1021_webp_thumbnail_conversion.run_convert") +class TestMigrateWebPThumbnails(TestMigrations): + + migrate_from = "1020_merge_20220518_1839" + migrate_to = "1021_webp_thumbnail_conversion" + auto_migrate = False + + def pretend_convert_output(self, *args, **kwargs): + """ + Pretends to do the conversion, by copying the input file + to the output file + """ + shutil.copy2( + Path(kwargs["input_file"].rstrip("[0]")), + Path(kwargs["output_file"]), + ) + + def pretend_map(self, func: Callable, iterable: Iterable): + """ + Pretends to be the map of a multiprocessing.Pool, but secretly does + everything in series + """ + for item in iterable: + func(item) + + def create_dummy_thumbnails( + self, + thumb_dir: Path, + ext: str, + count: int, + start_count: int = 0, + ): + """ + Helper to create a certain count of files of given extension in a given directory + """ + for idx in range(count): + (Path(thumb_dir) / Path(f"{start_count + idx:07}.{ext}")).touch() + # Triple check expected files exist + self.assert_file_count_by_extension(ext, thumb_dir, count) + + def create_webp_thumbnail_files( + self, + thumb_dir: Path, + count: int, + start_count: int = 0, + ): + """ + Creates a dummy WebP thumbnail file in the given directory, based on + the database Document + """ + self.create_dummy_thumbnails(thumb_dir, "webp", count, start_count) + + def create_png_thumbnail_file( + self, + thumb_dir: Path, + count: int, + start_count: int = 0, + ): + """ + Creates a dummy PNG thumbnail file in the given directory, based on + the database Document + """ + self.create_dummy_thumbnails(thumb_dir, "png", count, start_count) + + def assert_file_count_by_extension( + self, + ext: str, + dir: Union[str, Path], + expected_count: int, + ): + """ + Helper to assert a certain count of given extension files in given directory + """ + if not isinstance(dir, Path): + dir = Path(dir) + matching_files = list(dir.glob(f"*.{ext}")) + self.assertEqual(len(matching_files), expected_count) + + def assert_png_file_count(self, dir: Path, expected_count: int): + """ + Helper to assert a certain count of PNG extension files in given directory + """ + self.assert_file_count_by_extension("png", dir, expected_count) + + def assert_webp_file_count(self, dir: Path, expected_count: int): + """ + Helper to assert a certain count of WebP extension files in given directory + """ + self.assert_file_count_by_extension("webp", dir, expected_count) + + def setUp(self): + + self.thumbnail_dir = Path(tempfile.mkdtemp()).resolve() + + return super().setUp() + + def tearDown(self) -> None: + + shutil.rmtree(self.thumbnail_dir) + + return super().tearDown() + + def test_do_nothing_if_converted( + self, + run_convert_mock: mock.MagicMock, + map_mock: mock.MagicMock, + ): + """ + GIVEN: + - Document exists with default WebP thumbnail path + WHEN: + - Thumbnail conversion is attempted + THEN: + - Nothing is converted + """ + map_mock.side_effect = self.pretend_map + + with override_settings( + THUMBNAIL_DIR=self.thumbnail_dir, + ): + + self.create_webp_thumbnail_files(self.thumbnail_dir, 3) + + self.performMigration() + run_convert_mock.assert_not_called() + + self.assert_webp_file_count(self.thumbnail_dir, 3) + + def test_convert_single_thumbnail( + self, + run_convert_mock: mock.MagicMock, + map_mock: mock.MagicMock, + ): + """ + GIVEN: + - Document exists with PNG thumbnail + WHEN: + - Thumbnail conversion is attempted + THEN: + - Single thumbnail is converted + """ + map_mock.side_effect = self.pretend_map + run_convert_mock.side_effect = self.pretend_convert_output + + with override_settings( + THUMBNAIL_DIR=self.thumbnail_dir, + ): + self.create_png_thumbnail_file(self.thumbnail_dir, 3) + + self.performMigration() + + run_convert_mock.assert_called() + self.assertEqual(run_convert_mock.call_count, 3) + + self.assert_webp_file_count(self.thumbnail_dir, 3) + + def test_convert_errors_out( + self, + run_convert_mock: mock.MagicMock, + map_mock: mock.MagicMock, + ): + """ + GIVEN: + - Document exists with PNG thumbnail + WHEN: + - Thumbnail conversion is attempted, but raises an exception + THEN: + - Single thumbnail is converted + """ + map_mock.side_effect = self.pretend_map + run_convert_mock.side_effect = OSError + + with override_settings( + THUMBNAIL_DIR=self.thumbnail_dir, + ): + + self.create_png_thumbnail_file(self.thumbnail_dir, 3) + + self.performMigration() + + run_convert_mock.assert_called() + self.assertEqual(run_convert_mock.call_count, 3) + + self.assert_png_file_count(self.thumbnail_dir, 3) + + def test_convert_mixed( + self, + run_convert_mock: mock.MagicMock, + map_mock: mock.MagicMock, + ): + """ + GIVEN: + - Document exists with PNG thumbnail + WHEN: + - Thumbnail conversion is attempted, but raises an exception + THEN: + - Single thumbnail is converted + """ + map_mock.side_effect = self.pretend_map + run_convert_mock.side_effect = self.pretend_convert_output + + with override_settings( + THUMBNAIL_DIR=self.thumbnail_dir, + ): + + self.create_png_thumbnail_file(self.thumbnail_dir, 3) + self.create_webp_thumbnail_files(self.thumbnail_dir, 2, start_count=3) + + self.performMigration() + + run_convert_mock.assert_called() + self.assertEqual(run_convert_mock.call_count, 3) + + self.assert_png_file_count(self.thumbnail_dir, 0) + self.assert_webp_file_count(self.thumbnail_dir, 5) From 1df517afd3854def511ce30dd252ffe79c974e0c Mon Sep 17 00:00:00 2001 From: Trenton Holmes Date: Sat, 11 Jun 2022 14:15:27 -0700 Subject: [PATCH 13/14] Removes last vestiges of PNG from the tests, code, docs and samples --- Dockerfile | 2 -- docs/administration.rst | 12 ---------- src/documents/__init__.py | 3 +-- src/documents/checks.py | 22 ------------------ .../management/commands/document_exporter.py | 2 +- .../commands/document_thumbnails.py | 7 ------ .../samples/documents/thumbnails/0000001.png | Bin 7913 -> 0 bytes .../samples/documents/thumbnails/0000001.webp | Bin 0 -> 2624 bytes .../samples/documents/thumbnails/0000002.png | Bin 7913 -> 0 bytes .../samples/documents/thumbnails/0000002.webp | Bin 0 -> 2624 bytes .../samples/documents/thumbnails/0000003.png | Bin 7913 -> 0 bytes .../samples/documents/thumbnails/0000003.webp | Bin 0 -> 2624 bytes .../documents/thumbnails/0000004.png.gpg | Bin 7141 -> 0 bytes .../documents/thumbnails/0000004.webp.gpg | Bin 0 -> 2712 bytes src/documents/tests/test_api.py | 4 ++-- src/documents/tests/test_consumer.py | 8 ++++--- src/documents/tests/test_management.py | 4 ++-- src/documents/tests/test_sanity_check.py | 4 ++-- src/documents/views.py | 6 +---- 19 files changed, 14 insertions(+), 60 deletions(-) delete mode 100644 src/documents/tests/samples/documents/thumbnails/0000001.png create mode 100644 src/documents/tests/samples/documents/thumbnails/0000001.webp delete mode 100644 src/documents/tests/samples/documents/thumbnails/0000002.png create mode 100644 src/documents/tests/samples/documents/thumbnails/0000002.webp delete mode 100644 src/documents/tests/samples/documents/thumbnails/0000003.png create mode 100644 src/documents/tests/samples/documents/thumbnails/0000003.webp delete mode 100644 src/documents/tests/samples/documents/thumbnails/0000004.png.gpg create mode 100644 src/documents/tests/samples/documents/thumbnails/0000004.webp.gpg diff --git a/Dockerfile b/Dockerfile index fda47998c..630cd367c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -83,8 +83,6 @@ ARG RUNTIME_PACKAGES="\ postgresql-client \ # For Numpy libatlas3-base \ - # thumbnail size reduction - pngquant \ # OCRmyPDF dependencies tesseract-ocr \ tesseract-ocr-eng \ diff --git a/docs/administration.rst b/docs/administration.rst index f1930256c..dfb88ff80 100644 --- a/docs/administration.rst +++ b/docs/administration.rst @@ -518,15 +518,3 @@ Basic usage to disable encryption of your document store: .. code:: decrypt_documents [--passphrase SECR3TP4SSPHRA$E] - -Managing thumbnail format -=================== - -Document thumbnails were originally created as PNG format. Newly -uploaded documents are now using WebP to reduce both storage space and -page loading times. To convert older PNG format thumbnails to WebP -run: - -.. code:: - - convert_thumbnails diff --git a/src/documents/__init__.py b/src/documents/__init__.py index 005c6e2fb..dc94f2bdd 100644 --- a/src/documents/__init__.py +++ b/src/documents/__init__.py @@ -1,6 +1,5 @@ # this is here so that django finds the checks. from .checks import changed_password_check from .checks import parser_check -from .checks import png_thumbnail_check -__all__ = ["changed_password_check", "parser_check", "png_thumbnail_check"] +__all__ = ["changed_password_check", "parser_check"] diff --git a/src/documents/checks.py b/src/documents/checks.py index 0a9511bd6..a014a0ac2 100644 --- a/src/documents/checks.py +++ b/src/documents/checks.py @@ -1,9 +1,7 @@ import textwrap -from pathlib import Path from django.conf import settings from django.core.checks import Error -from django.core.checks import Info from django.core.checks import register from django.core.exceptions import FieldError from django.db.utils import OperationalError @@ -68,23 +66,3 @@ def parser_check(app_configs, **kwargs): ] else: return [] - - -@register() -def png_thumbnail_check(app_configs, **kwargs): - from documents.models import Document - - try: - documents = Document.objects.all() - for document in documents: - existing_thumbnail = Path(document.thumbnail_path).resolve() - if existing_thumbnail.suffix == ".png": - return [ - Info( - "PNG thumbnails found, consider running convert_thumbnails " - "to convert to WebP", - ), - ] - return [] - except (OperationalError, ProgrammingError, FieldError): - return [] # No documents table yet diff --git a/src/documents/management/commands/document_exporter.py b/src/documents/management/commands/document_exporter.py index 4bddd51b8..526d59368 100644 --- a/src/documents/management/commands/document_exporter.py +++ b/src/documents/management/commands/document_exporter.py @@ -189,7 +189,7 @@ class Command(BaseCommand): original_target = os.path.join(self.target, original_name) document_dict[EXPORTER_FILE_NAME] = original_name - thumbnail_name = base_name + "-thumbnail.png" + thumbnail_name = base_name + "-thumbnail.webp" thumbnail_target = os.path.join(self.target, thumbnail_name) document_dict[EXPORTER_THUMBNAIL_NAME] = thumbnail_name diff --git a/src/documents/management/commands/document_thumbnails.py b/src/documents/management/commands/document_thumbnails.py index 535a0f670..b56bc0042 100644 --- a/src/documents/management/commands/document_thumbnails.py +++ b/src/documents/management/commands/document_thumbnails.py @@ -1,7 +1,6 @@ import logging import multiprocessing import shutil -from pathlib import Path import tqdm from django import db @@ -23,12 +22,6 @@ def _process_document(doc_in): try: - existing_thumbnail = Path(document.thumbnail_path).resolve() - - # Remove an existing PNG format thumbnail, if it existed - if existing_thumbnail.exists() and existing_thumbnail.suffix == ".png": - existing_thumbnail.unlink() - thumb = parser.get_thumbnail( document.source_path, document.mime_type, diff --git a/src/documents/tests/samples/documents/thumbnails/0000001.png b/src/documents/tests/samples/documents/thumbnails/0000001.png deleted file mode 100644 index a3a76840103812c371dd78f2e8bc704ea3eec1ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7913 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 Ruvbl>s;I6|ENAlKe*j2SP+b53 diff --git a/src/documents/tests/samples/documents/thumbnails/0000001.webp b/src/documents/tests/samples/documents/thumbnails/0000001.webp new file mode 100644 index 0000000000000000000000000000000000000000..a7ff623b24a24c1490dec1742efb28ce7da545d1 GIT binary patch literal 2624 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 literal 0 HcmV?d00001 diff --git a/src/documents/tests/samples/documents/thumbnails/0000002.png b/src/documents/tests/samples/documents/thumbnails/0000002.png deleted file mode 100644 index a3a76840103812c371dd78f2e8bc704ea3eec1ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7913 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 Ruvbl>s;I6|ENAlKe*j2SP+b53 diff --git a/src/documents/tests/samples/documents/thumbnails/0000002.webp b/src/documents/tests/samples/documents/thumbnails/0000002.webp new file mode 100644 index 0000000000000000000000000000000000000000..a7ff623b24a24c1490dec1742efb28ce7da545d1 GIT binary patch literal 2624 zcmV-G3cvMINk&FE3IG6CMM6+kP&gng3IG7`KLDKpD)a%406vjOp-UyCqoJiWn24|v z32AQObfFvbd-1RJp4C6aep2<=`Fq-~SUo}JSMqgTjulSz% zUf4Ns^#b*m>VNIOzxe6uiQvCr59Z(1y)N&WpeN~HR$ub`n0;yNBmCq0xBTH$)_p+u zH5g^;>IcE7!!K7*J`F}0db)w|YB0;y)DDcpile-!BGTR)&60>d2p)3sf6InmuAqDw z(zfHF^ZC$xro3zu6@{+aHz-mtsTgJcd<38+QQ@mdETJM%iOZvQy!xIj3qkyA${-N5 zj83zqC02XQNxa@6kuO{uWYOk37rSKg`K1qE#g|5wtU+|~?81Gud0~<+;GL1UlnMNa z^YX;l)-rD8ie|?>j8_ku>96IT4%vmuYq}*fiMEo@CUpRSgTUXBz948*b@7D4G?+{oB8)00C3a`crLiAJMIT;)F0Q4@pTg= zk8TKVpSj9BlOD^U_Vtd@u7Vcfp9Z50yIcE7!!K7*J`F}0db)w|YB0;y z)DMGEhF-3qd>V{0^>qW_)M1yas2>KS47C6N{{LS<022BlC{>70i&@QiW1W$cX@RHh z&$~wT&cZZ5ZzWxr&}HBKxV?IK33{DDzuXf!8n}3C7gB94(TK^{}EA}4n>7Ny_jMijR%-xoD0JK1< zyK!Jib*~}pltq>L8HnpYIzUk?U7LhyFOmM&;|Oaxa~Z3HP-s^zLrsn#NN`#;_GUTb z#_n!>RiP(Hvb9K2Hk@AT!spjPHv9q)i9Uf)E6V$WuUbKH@;b%pn^qQ7wH%ZNW}0)w z^)wQ`^WXd5QE_pS%vl$vBN~*Kf|1vnY61ukCkU=c=WuddH#0*TXMPx(hDV$KU$9?t zL`#!I*2q>euUU5S`lpk!qAd5ZRhy-VzxSa_Ni>LBnx;L79obH{#=Ek9gBFrH58KRz z#=)6S;p~6=11R3l$2Pvo7&{`sQQfb zOLbS48SXz(_@ThOkBlq6zj!KZl-FN}lQ4XA7HyfGEi7%ZjhRS~Q`_)}eG)=3yC?4s z5(*>Wv?@8<=+_CfU@&q}TNL*l8N~_>A-c8;(=aXtM9-r7ad)ImdcAw6dxW zHOgelATJ~5IScJi`6*Za&Z2wv_?9Di&mZ>0`}6iUi?H2MoxkW)5giHsT|DilWJSYF z6)stGfpvDp@j115D$=z6n z1NQQ%yGt(wgViY8yNi(~DXe2*aR9`R(HSqKSx8cxwEyZf94ZJ1NTyw(#NX9w{y#?he7v5k8d`_t1RdIrvWe+Xj& z9oEoqKHr<;{>AnSd zogP_2+qlLnz5AZ>&`>GF34=)WG26*ow0t*dkIC}WW??y8K)T0YOP$#88XSJis;)y+ z*WZZ0A=;4oECTVqLUYyE7D7`>x%e2G zo{0(_rv;S~AWM5T-2JDuSS0Dv{=VU1e%Y|=c&jeRFr1`TRNa3DQpjy( zw=NLvPru9rt~Z*{vndHkP6F3aDyrsm_h59Zi?FIim z(it9Y+jd%l9+(yb00VJ@&f>q+jT|$+?Z9-iqY#k~@(HG}3kJ3P z!6}u8ApsDt)v-C_yDz+g%y_M-JyR z9c!iH=&>aHv6tVv#Ajj3|M=^^QZUl{D>D9!Q_^0L9!LSF1|&vuvX9vCa(JWBs8wiiWY2ZMY7 z=Fg#yHB6Uauc{ARHobWbgz?8I@ewfUyN_n#_Hce4J*GGX1dge6M@dLdOnp^IwwL1% z{$)m7+JGHYw_OY2m1jCU4_>*%di}Z&5YdW`bFhv0jBvu*{T2##(O7KYCLTpg3R5KX zFG=K{F4X%&n8MI7PsvqQvSUE(+d>69)d5kDpLU4(nI&Du=l;!@7H7BZ0r3|ZvA^vG z_SP<+yflz%bRnTrAWxtgHF+~K(}?AU9FTy*Ux3ZPk7Mbb2lUfcp$H5nn@|2}8Ajvx zfp0~o9C*82Qrid4sxr?fhFjr?FOf9*Ou&}io*@Xhb|8=^-OKRnG#j<&uUQ{nirzGM z%c@AWw^JRP|7d|%)Ey4>(LYcw%hwYkcXnfJq`W64oBzs-Fuw`^h}H)4BY7n|!WYX0 z*qOc&(G$0+_JSNHOUL!#{jBH~3PCqOqswuP-fu1a{XN5XCZ_n^l&yQ8X>q3eQ@04? z&3h(EFCzTACB=gUy=weuxqRZlXe%;B@sVnZD#7eSth1XrSgHT+rY{TrtQBa?BFZu8 in}?oeZhY4Q)o&wyi3@~zh-WNDh`8wL1V8`)0002IHZUas literal 0 HcmV?d00001 diff --git a/src/documents/tests/samples/documents/thumbnails/0000003.png b/src/documents/tests/samples/documents/thumbnails/0000003.png deleted file mode 100644 index a3a76840103812c371dd78f2e8bc704ea3eec1ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7913 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 Ruvbl>s;I6|ENAlKe*j2SP+b53 diff --git a/src/documents/tests/samples/documents/thumbnails/0000003.webp b/src/documents/tests/samples/documents/thumbnails/0000003.webp new file mode 100644 index 0000000000000000000000000000000000000000..a7ff623b24a24c1490dec1742efb28ce7da545d1 GIT binary patch literal 2624 zcmV-G3cvMINk&FE3IG6CMM6+kP&gng3IG7`KLDKpD)a%406vjOp-UyCqoJiWn24|v z32AQObfFvbd-1RJp4C6aep2<=`Fq-~SUo}JSMqgTjulSz% zUf4Ns^#b*m>VNIOzxe6uiQvCr59Z(1y)N&WpeN~HR$ub`n0;yNBmCq0xBTH$)_p+u zH5g^;>IcE7!!K7*J`F}0db)w|YB0;y)DDcpile-!BGTR)&60>d2p)3sf6InmuAqDw z(zfHF^ZC$xro3zu6@{+aHz-mtsTgJcd<38+QQ@mdETJM%iOZvQy!xIj3qkyA${-N5 zj83zqC02XQNxa@6kuO{uWYOk37rSKg`K1qE#g|5wtU+|~?81Gud0~<+;GL1UlnMNa z^YX;l)-rD8ie|?>j8_ku>96IT4%vmuYq}*fiMEo@CUpRSgTUXBz948*b@7D4G?+{oB8)00C3a`crLiAJMIT;)F0Q4@pTg= zk8TKVpSj9BlOD^U_Vtd@u7Vcfp9Z50yIcE7!!K7*J`F}0db)w|YB0;y z)DMGEhF-3qd>V{0^>qW_)M1yas2>KS47C6N{{LS<022BlC{>70i&@QiW1W$cX@RHh z&$~wT&cZZ5ZzWxr&}HBKxV?IK33{DDzuXf!8n}3C7gB94(TK^{}EA}4n>7Ny_jMijR%-xoD0JK1< zyK!Jib*~}pltq>L8HnpYIzUk?U7LhyFOmM&;|Oaxa~Z3HP-s^zLrsn#NN`#;_GUTb z#_n!>RiP(Hvb9K2Hk@AT!spjPHv9q)i9Uf)E6V$WuUbKH@;b%pn^qQ7wH%ZNW}0)w z^)wQ`^WXd5QE_pS%vl$vBN~*Kf|1vnY61ukCkU=c=WuddH#0*TXMPx(hDV$KU$9?t zL`#!I*2q>euUU5S`lpk!qAd5ZRhy-VzxSa_Ni>LBnx;L79obH{#=Ek9gBFrH58KRz z#=)6S;p~6=11R3l$2Pvo7&{`sQQfb zOLbS48SXz(_@ThOkBlq6zj!KZl-FN}lQ4XA7HyfGEi7%ZjhRS~Q`_)}eG)=3yC?4s z5(*>Wv?@8<=+_CfU@&q}TNL*l8N~_>A-c8;(=aXtM9-r7ad)ImdcAw6dxW zHOgelATJ~5IScJi`6*Za&Z2wv_?9Di&mZ>0`}6iUi?H2MoxkW)5giHsT|DilWJSYF z6)stGfpvDp@j115D$=z6n z1NQQ%yGt(wgViY8yNi(~DXe2*aR9`R(HSqKSx8cxwEyZf94ZJ1NTyw(#NX9w{y#?he7v5k8d`_t1RdIrvWe+Xj& z9oEoqKHr<;{>AnSd zogP_2+qlLnz5AZ>&`>GF34=)WG26*ow0t*dkIC}WW??y8K)T0YOP$#88XSJis;)y+ z*WZZ0A=;4oECTVqLUYyE7D7`>x%e2G zo{0(_rv;S~AWM5T-2JDuSS0Dv{=VU1e%Y|=c&jeRFr1`TRNa3DQpjy( zw=NLvPru9rt~Z*{vndHkP6F3aDyrsm_h59Zi?FIim z(it9Y+jd%l9+(yb00VJ@&f>q+jT|$+?Z9-iqY#k~@(HG}3kJ3P z!6}u8ApsDt)v-C_yDz+g%y_M-JyR z9c!iH=&>aHv6tVv#Ajj3|M=^^QZUl{D>D9!Q_^0L9!LSF1|&vuvX9vCa(JWBs8wiiWY2ZMY7 z=Fg#yHB6Uauc{ARHobWbgz?8I@ewfUyN_n#_Hce4J*GGX1dge6M@dLdOnp^IwwL1% z{$)m7+JGHYw_OY2m1jCU4_>*%di}Z&5YdW`bFhv0jBvu*{T2##(O7KYCLTpg3R5KX zFG=K{F4X%&n8MI7PsvqQvSUE(+d>69)d5kDpLU4(nI&Du=l;!@7H7BZ0r3|ZvA^vG z_SP<+yflz%bRnTrAWxtgHF+~K(}?AU9FTy*Ux3ZPk7Mbb2lUfcp$H5nn@|2}8Ajvx zfp0~o9C*82Qrid4sxr?fhFjr?FOf9*Ou&}io*@Xhb|8=^-OKRnG#j<&uUQ{nirzGM z%c@AWw^JRP|7d|%)Ey4>(LYcw%hwYkcXnfJq`W64oBzs-Fuw`^h}H)4BY7n|!WYX0 z*qOc&(G$0+_JSNHOUL!#{jBH~3PCqOqswuP-fu1a{XN5XCZ_n^l&yQ8X>q3eQ@04? z&3h(EFCzTACB=gUy=weuxqRZlXe%;B@sVnZD#7eSth1XrSgHT+rY{TrtQBa?BFZu8 in}?oeZhY4Q)o&wyi3@~zh-WNDh`8wL1V8`)0002IHZUas literal 0 HcmV?d00001 diff --git a/src/documents/tests/samples/documents/thumbnails/0000004.png.gpg b/src/documents/tests/samples/documents/thumbnails/0000004.png.gpg deleted file mode 100644 index 8a61a91265589106b192d25f557987f1a574faef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7141 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 b)0CCg#qy=b28v$ju#MT_mQiT|m*D(i(OLi# diff --git a/src/documents/tests/samples/documents/thumbnails/0000004.webp.gpg b/src/documents/tests/samples/documents/thumbnails/0000004.webp.gpg new file mode 100644 index 0000000000000000000000000000000000000000..3abc69d360a4c61dba6b12e6c833bdf6b441d8f4 GIT binary patch literal 2712 zcmV;J3TO3<4Fm}T0-;;@IO|&ujsMc?0VS)=twtf`xvyXfcqt3jXqTw`8{(PsHlqKp zvQ<;i!TOdU2bcw#F!E(Q{3@R^u)Y3-1h2kRO5MLj#bAru`yRu1G5vDd7PU!coJf$& zTxKEi9D{7jbK}m#R!5(e0%xx+*0RX3RrG}d=Q5iaf|6gcnVa}(Xx;Ks2&5DyY$cjF z8_?s8N%6p6OrEM-l%l@KH-xkD@h+$dQqZRrq+i-!E-oB6}jkBv!JeE82u3Ch`GU zHj8F*Fgu{N5=>)__tjeku(lVGtpx(RJ_Nx{^)R9fyQMJTSQ!3K^GmdUU&5WP?B=nY-C zvbUQS;2kW^Yn4ID=j7ts==&yg|r!_R?hMKkyA6ww3EEiyRrw9vDz=@mPK7hV2&sE3`*;d zLSO|aprA{H-2n^+w^UiSzfK`evE~|b_<;mwlf_k5@!M6$h&O>Mx;Qzs?FPIYtNSai z(28VwW{7v-;yircu>ON&>m)Sw6jR^Dj9t(IRD2*xUXz_NdL9VI5E#gtj$tiw#g2{h zARnmicyVuuet!n(ZGhqHT9>o2OY6pxGhP<<8^+2oi^~3Vr_jZ<&za6s-idEYSxL>< zXLqSE??R$UoB%7nt>Pg6BVOI;w)!Ef zt1=3{NWrEGPfT-{+`o1f*3yB^t7U8+%<1%~^cAgJGz4<@m1}_MMDlcOxif3Ve1C2J zsUlW?6<1US2(>WU%4rpsBK5#?e40j8$ucA1IxbZ;TlJgr-2HEtbB=w(GdK9IMh#Fh zFqH7G)gX>4y0p~T;Tk~6EYTIxYvB;F%POrqNB?kGHY}v2e1(MNoIH2%GRjC8{@hbdLH%Jv zUSUQ;AcxN}*G;dHQ^T_=yPkrX<$qW((PCcArQAMVDgkWhW0&5u z-Ul=tZ{1F<)uv3%11uzx-&=tnw=V;-%Qp1b2H*Dxg@k9UHfN2yw1!;bRSVwqJer$m zX;nqV_Gi`6d`p81+V_R;B47$wDs92=OXW{rKnxF>f&0_kBM3Y76~n#8`2q zQ`zzmDkr*ATK@QHx6L&74mW%#u;Hv+h)Ql(_hqWYT0I9ubRfY}gSJgt`E*?Y>FHA= zfVhoB=!1Oj8J^*x7T9c6I~sVmf|OJt$aq!mG6_fuYZDJ^_(OEs>N@M!JQoJs=iD49 zqb7vc;yt{Ve3XVFT}88CXzdZTf^7|wnP0Yd=S|v3Rqr?6*gIX`N+2vY&ND)i5fCcr zJC%Yzd>`w-tqU<|W>#u%`y0RMNS$QFR;0-Y=u|0GjE8g03^Y#`ca29@-dLPH$@khQ zqzdZdMl_e>XFOHgB*K4W%tWreelKo7GTo24PaDOFDE{(hYE@n&#F6CL$8m2awzmJO zQC?7zcWaLPVcPTx-~s&{`HwB)>=f35S9AEg%z6-YsZILDM%iV{E`W++)jaE?(9O`^39Va}nI` z6IWnJ>9EHR5iu8PYEUNX6n(7G7Ok#HmroLISz1D2ZA&^Y?T<9QaYy1m9+XQeOpxUr zj3=vj0P8dhtbq7l_E>Zp8|?;=zZ7cYZ`wP7wwHKOud1P5888IrCkVG5q`sXK@L%ny z2{{~A7+q7hO6(EuD9bl?L==H^h!g;yw1w!z%x_Au%pst~#~@4Ki>Hd7|bHnq-zi_8C! zO%XY}qI*2vGF!R1Gr;4}V^HTX;r=_7^SBstf?W?iC4!2LD}b58TS()Ap%XshA4y^g zBn|YKBr^b!QE6%_2l?;v6f=GevixCI)@>_k>N;I}}{>C|g(zU@{y9Q>T zx*QtRIG(m_PWdDhIz@7NG`DVRfPr=* zi=s}T3rW4^j!Ir{2Wbk{b;m23!ARENWpotEO8D!OloX%2 zsl&t+#XczG0c1sC22TI#%}}k}+2Cr|nM>H9-l_2nG~R*KgRxv473195Kln&Tf1(|! z@ZTGuzP1zUC|{J9AkUJts$;6cv>%VB0uE{%WaS+hF+%=WLhhySU; zPx+!RGNuu*lm$EHiBxkQ);u8lRcH5{-k*YZmHf_8+ztH0hhgS9gx z(21r9KB)V6nN?<12(!@Dus5tl-t-NyCxz_O?5{Pszx<8m9HyeKkT{!3&{kOmj;~LlnSE|4HWSO@)R<2k5(F*&X9e;&hKX zl>Rf@Aci=l8*EBC9ti1)zls61z^IhP-&97Q?FLFctq-a3$Fefg`AWA};d&H|HN*7p z_c*#dMKt&+T#7N`Nr1P2MO26O!7mUT^K%4+_dm5I6Xa(EyVVPa>yWLt5OR-u7r|U4 S|3^~Dece>pl*7~{#c}PaRZyY; literal 0 HcmV?d00001 diff --git a/src/documents/tests/test_api.py b/src/documents/tests/test_api.py index 24bdc3a50..6d659e66d 100644 --- a/src/documents/tests/test_api.py +++ b/src/documents/tests/test_api.py @@ -176,7 +176,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): ) with open( - os.path.join(self.dirs.thumbnail_dir, f"{doc.pk:07d}.png"), + os.path.join(self.dirs.thumbnail_dir, f"{doc.pk:07d}.webp"), "wb", ) as f: f.write(content_thumbnail) @@ -1022,7 +1022,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): "samples", "documents", "thumbnails", - "0000001.png", + "0000001.webp", ) archive_file = os.path.join(os.path.dirname(__file__), "samples", "simple.pdf") diff --git a/src/documents/tests/test_consumer.py b/src/documents/tests/test_consumer.py index a770d3ff6..48f195903 100644 --- a/src/documents/tests/test_consumer.py +++ b/src/documents/tests/test_consumer.py @@ -180,7 +180,7 @@ class DummyParser(DocumentParser): def __init__(self, logging_group, scratch_dir, archive_path): super().__init__(logging_group, None) - _, self.fake_thumb = tempfile.mkstemp(suffix=".png", dir=scratch_dir) + _, self.fake_thumb = tempfile.mkstemp(suffix=".webp", dir=scratch_dir) self.archive_path = archive_path def get_thumbnail(self, document_path, mime_type, file_name=None): @@ -199,7 +199,7 @@ class CopyParser(DocumentParser): def __init__(self, logging_group, progress_callback=None): super().__init__(logging_group, progress_callback) - _, self.fake_thumb = tempfile.mkstemp(suffix=".png", dir=self.tempdir) + _, self.fake_thumb = tempfile.mkstemp(suffix=".webp", dir=self.tempdir) def parse(self, document_path, mime_type, file_name=None): self.text = "The text" @@ -214,7 +214,7 @@ class FaultyParser(DocumentParser): def __init__(self, logging_group, scratch_dir): super().__init__(logging_group) - _, self.fake_thumb = tempfile.mkstemp(suffix=".png", dir=scratch_dir) + _, self.fake_thumb = tempfile.mkstemp(suffix=".webp", dir=scratch_dir) def get_thumbnail(self, document_path, mime_type, file_name=None): return self.fake_thumb @@ -230,6 +230,8 @@ def fake_magic_from_file(file, mime=False): return "application/pdf" elif os.path.splitext(file)[1] == ".png": return "image/png" + elif os.path.splitext(file)[1] == ".webp": + return "image/webp" else: return "unknown" else: diff --git a/src/documents/tests/test_management.py b/src/documents/tests/test_management.py index 65ed36ff9..76a5459b5 100644 --- a/src/documents/tests/test_management.py +++ b/src/documents/tests/test_management.py @@ -150,9 +150,9 @@ class TestDecryptDocuments(TestCase): "samples", "documents", "thumbnails", - f"0000004.png.gpg", + f"0000004.webp.gpg", ), - os.path.join(thumb_dir, f"{doc.id:07}.png.gpg"), + os.path.join(thumb_dir, f"{doc.id:07}.webp.gpg"), ) call_command("decrypt_documents") diff --git a/src/documents/tests/test_sanity_check.py b/src/documents/tests/test_sanity_check.py index 5ebedd908..9bb424cbc 100644 --- a/src/documents/tests/test_sanity_check.py +++ b/src/documents/tests/test_sanity_check.py @@ -42,9 +42,9 @@ class TestSanityCheck(DirectoriesMixin, TestCase): "samples", "documents", "thumbnails", - "0000001.png", + "0000001.webp", ), - os.path.join(self.dirs.thumbnail_dir, "0000001.png"), + os.path.join(self.dirs.thumbnail_dir, "0000001.webp"), ) return Document.objects.create( diff --git a/src/documents/views.py b/src/documents/views.py index bcd2958f8..8fa86b4ca 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -362,11 +362,7 @@ class DocumentViewSet( handle = doc.thumbnail_file # TODO: Send ETag information and use that to send new thumbnails # if available - thumbnail_path = doc.thumbnail_path - if os.path.splitext(thumbnail_path)[1] == ".webp": - content_type = "image/webp" - else: - content_type = "image/png" + content_type = "image/webp" return HttpResponse(handle, content_type=content_type) except (FileNotFoundError, Document.DoesNotExist): From 222e1968d8603b4f2feabb46c336db30d0123b82 Mon Sep 17 00:00:00 2001 From: Trenton Holmes Date: Sun, 12 Jun 2022 09:12:02 -0700 Subject: [PATCH 14/14] Removes one last portion of PNG vs WebP --- src/documents/views.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/documents/views.py b/src/documents/views.py index 8fa86b4ca..b8d4075d0 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -362,9 +362,8 @@ class DocumentViewSet( handle = doc.thumbnail_file # TODO: Send ETag information and use that to send new thumbnails # if available - content_type = "image/webp" - return HttpResponse(handle, content_type=content_type) + return HttpResponse(handle, content_type="image/webp") except (FileNotFoundError, Document.DoesNotExist): raise Http404()