Add an automated resizing for images exceeding the limits
authorAlexander Ebert <ebert@woltlab.com>
Thu, 25 Apr 2024 15:30:36 +0000 (17:30 +0200)
committerAlexander Ebert <ebert@woltlab.com>
Sat, 8 Jun 2024 10:19:38 +0000 (12:19 +0200)
ts/WoltLabSuite/Core/Component/File/Upload.ts
wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js
wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php
wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php
wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php
wcfsetup/install/files/lib/system/file/processor/ResizeConfiguration.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/file/processor/ResizeFileType.class.php [new file with mode: 0644]

index fd2573fd1c9721ae1bafc58562c2a7ffa721e4c0..2e149ca9319e439628baba3ff3dd0587e99e238c 100644 (file)
@@ -3,6 +3,7 @@ import { upload as filesUpload } from "WoltLabSuite/Core/Api/Files/Upload";
 import WoltlabCoreFileElement from "./woltlab-core-file";
 import { Response as UploadChunkResponse, uploadChunk } from "WoltLabSuite/Core/Api/Files/Chunk/Chunk";
 import { generateThumbnails } from "WoltLabSuite/Core/Api/Files/GenerateThumbnails";
+import ImageResizer from "WoltLabSuite/Core/Image/Resizer";
 
 export type ThumbnailsGenerated = {
   data: GenerateThumbnailsResponse;
@@ -16,6 +17,13 @@ type ThumbnailData = {
 
 type GenerateThumbnailsResponse = ThumbnailData[];
 
+type ResizeConfiguration = {
+  maxWidth: number;
+  maxHeight: number;
+  fileType: "image/jpeg" | "image/webp" | "keep";
+  quality: number;
+};
+
 async function upload(element: WoltlabCoreFileUploadElement, file: File): Promise<void> {
   const typeName = element.dataset.typeName!;
 
@@ -85,16 +93,82 @@ async function getSha256Hash(data: BufferSource): Promise<string> {
     .join("");
 }
 
+function clearPreviousErrors(element: WoltlabCoreFileUploadElement): void {
+  element.parentElement?.querySelectorAll(".innerError").forEach((x) => x.remove());
+}
+
+async function resizeImage(element: WoltlabCoreFileUploadElement, file: File): Promise<File> {
+  switch (file.type) {
+    case "image/jpeg":
+    case "image/png":
+    case "image/webp":
+      // Potential candidate for a resize operation.
+      break;
+
+    default:
+      // Not an image or an unsupported file type.
+      return file;
+  }
+
+  const timeout = new Promise<File>((resolve) => {
+    window.setTimeout(() => resolve(file), 10_000);
+  });
+
+  const resizeConfiguration = JSON.parse(element.dataset.resizeConfiguration!) as ResizeConfiguration;
+
+  const resizer = new ImageResizer();
+  const { image, exif } = await resizer.loadFile(file);
+
+  const maxHeight = resizeConfiguration.maxHeight;
+  let maxWidth = resizeConfiguration.maxWidth;
+  if (window.devicePixelRatio >= 2) {
+    const actualWidth = window.screen.width * window.devicePixelRatio;
+    const actualHeight = window.screen.height * window.devicePixelRatio;
+
+    // If the dimensions are equal then this is a screenshot from a HiDPI
+    // device, thus we downscale this to the “natural” dimensions.
+    if (actualWidth === image.width && actualHeight === image.height) {
+      maxWidth = Math.min(maxWidth, window.screen.width);
+    }
+  }
+
+  const canvas = await resizer.resize(image, maxWidth, maxHeight, resizeConfiguration.quality, true, timeout);
+  if (canvas === undefined) {
+    // The resize operation failed, timed out or there was no need to perform
+    // any scaling whatsoever.
+    return file;
+  }
+
+  let fileType: string = resizeConfiguration.fileType;
+  if (fileType === "image/jpeg" || fileType === "image/webp") {
+    fileType = "image/webp";
+  } else {
+    fileType = file.type;
+  }
+
+  const resizedFile = await resizer.saveFile(
+    {
+      exif,
+      image: canvas,
+    },
+    file.name,
+    fileType,
+    resizeConfiguration.quality,
+  );
+
+  return resizedFile;
+}
+
 export function setup(): void {
-  wheneverFirstSeen("woltlab-core-file-upload", (element) => {
+  wheneverFirstSeen("woltlab-core-file-upload", (element: WoltlabCoreFileUploadElement) => {
     element.addEventListener("upload", (event: CustomEvent<File>) => {
-      // TODO: Add some pipeline logic here.
+      const file = event.detail;
 
-      // TODO: Add a canvas based resize to the pipeline.
+      clearPreviousErrors(element);
 
-      // TODO: We need to pass around the file using some dedicated type of
-      //       data structure because it can be modified somehow.
-      void upload(element, event.detail);
+      void resizeImage(element, file).then((resizedFile) => {
+        void upload(element, resizedFile);
+      });
     });
   });
 }
index 752856b0be9eb48e35c5cecf2d1e552d5d24ef7a..a822e10cc8225755e89ce6563c0a2e68742e84ad 100644 (file)
@@ -1,7 +1,8 @@
-define(["require", "exports", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Api/Files/Upload", "WoltLabSuite/Core/Api/Files/Chunk/Chunk", "WoltLabSuite/Core/Api/Files/GenerateThumbnails"], function (require, exports, Selector_1, Upload_1, Chunk_1, GenerateThumbnails_1) {
+define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Api/Files/Upload", "WoltLabSuite/Core/Api/Files/Chunk/Chunk", "WoltLabSuite/Core/Api/Files/GenerateThumbnails", "WoltLabSuite/Core/Image/Resizer"], function (require, exports, tslib_1, Selector_1, Upload_1, Chunk_1, GenerateThumbnails_1, Resizer_1) {
     "use strict";
     Object.defineProperty(exports, "__esModule", { value: true });
     exports.setup = void 0;
+    Resizer_1 = tslib_1.__importDefault(Resizer_1);
     async function upload(element, file) {
         const typeName = element.dataset.typeName;
         const fileHash = await getSha256Hash(await file.arrayBuffer());
@@ -51,10 +52,64 @@ define(["require", "exports", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite
             .map((b) => b.toString(16).padStart(2, "0"))
             .join("");
     }
+    function clearPreviousErrors(element) {
+        element.parentElement?.querySelectorAll(".innerError").forEach((x) => x.remove());
+    }
+    async function resizeImage(element, file) {
+        switch (file.type) {
+            case "image/jpeg":
+            case "image/png":
+            case "image/webp":
+                // Potential candidate for a resize operation.
+                break;
+            default:
+                // Not an image or an unsupported file type.
+                return file;
+        }
+        const timeout = new Promise((resolve) => {
+            window.setTimeout(() => resolve(file), 10000);
+        });
+        const resizeConfiguration = JSON.parse(element.dataset.resizeConfiguration);
+        const resizer = new Resizer_1.default();
+        const { image, exif } = await resizer.loadFile(file);
+        const maxHeight = resizeConfiguration.maxHeight;
+        let maxWidth = resizeConfiguration.maxWidth;
+        if (window.devicePixelRatio >= 2) {
+            const actualWidth = window.screen.width * window.devicePixelRatio;
+            const actualHeight = window.screen.height * window.devicePixelRatio;
+            // If the dimensions are equal then this is a screenshot from a HiDPI
+            // device, thus we downscale this to the “natural” dimensions.
+            if (actualWidth === image.width && actualHeight === image.height) {
+                maxWidth = Math.min(maxWidth, window.screen.width);
+            }
+        }
+        const canvas = await resizer.resize(image, maxWidth, maxHeight, resizeConfiguration.quality, true, timeout);
+        if (canvas === undefined) {
+            // The resize operation failed, timed out or there was no need to perform
+            // any scaling whatsoever.
+            return file;
+        }
+        let fileType = resizeConfiguration.fileType;
+        if (fileType === "image/jpeg" || fileType === "image/webp") {
+            fileType = "image/webp";
+        }
+        else {
+            fileType = file.type;
+        }
+        const resizedFile = await resizer.saveFile({
+            exif,
+            image: canvas,
+        }, file.name, fileType, resizeConfiguration.quality);
+        return resizedFile;
+    }
     function setup() {
         (0, Selector_1.wheneverFirstSeen)("woltlab-core-file-upload", (element) => {
             element.addEventListener("upload", (event) => {
-                void upload(element, event.detail);
+                const file = event.detail;
+                clearPreviousErrors(element);
+                void resizeImage(element, file).then((resizedFile) => {
+                    void upload(element, resizedFile);
+                });
             });
         });
     }
index 598b2121409dfa69e61c6043a801bcc4531b63cd..af6dfd8629106bc8ad61bf27bd19dcdff146cb61 100644 (file)
@@ -127,6 +127,21 @@ final class AttachmentFileProcessor implements IFileProcessor
         );
     }
 
+    #[\Override]
+    public function getResizeConfiguration(): ResizeConfiguration
+    {
+        if (!\ATTACHMENT_IMAGE_AUTOSCALE) {
+            return ResizeConfiguration::unbounded();
+        }
+
+        return new ResizeConfiguration(
+            \ATTACHMENT_IMAGE_AUTOSCALE_MAX_WIDTH,
+            \ATTACHMENT_IMAGE_AUTOSCALE_MAX_HEIGHT,
+            ResizeFileType::fromString(\ATTACHMENT_IMAGE_AUTOSCALE_FILE_TYPE),
+            \ATTACHMENT_IMAGE_AUTOSCALE_QUALITY
+        );
+    }
+
     #[\Override]
     public function getThumbnailFormats(): array
     {
index 2ffb4e40cf39a0240c29daa299cef16ffa599824..88141f1f4328c0c76389b10cb8d7f63042fbec49 100644 (file)
@@ -62,11 +62,13 @@ final class FileProcessor extends SingletonFactory
                     data-type-name="%s"
                     data-context="%s"
                     data-file-extensions="%s"
+                    data-resize-configuration="%s"
                 ></woltlab-core-file-upload>
                 HTML,
             StringUtil::encodeHTML($fileProcessor->getTypeName()),
             StringUtil::encodeHTML(JSON::encode($context)),
             StringUtil::encodeHTML($allowedFileExtensions),
+            StringUtil::encodeHTML(JSON::encode($fileProcessor->getResizeConfiguration())),
         );
     }
 
index d18a484598614f3949ce46057b83b3aef73cf39d..56d49368ab53919fdca6fc9d0014398c5b00c75b 100644 (file)
@@ -25,6 +25,8 @@ interface IFileProcessor
 
     public function getAllowedFileExtensions(array $context): array;
 
+    public function getResizeConfiguration(): ResizeConfiguration;
+
     public function getTypeName(): string;
 
     public function getUploadResponse(File $file): array;
diff --git a/wcfsetup/install/files/lib/system/file/processor/ResizeConfiguration.class.php b/wcfsetup/install/files/lib/system/file/processor/ResizeConfiguration.class.php
new file mode 100644 (file)
index 0000000..d5d3ee3
--- /dev/null
@@ -0,0 +1,38 @@
+<?php
+
+namespace wcf\system\file\processor;
+
+final class ResizeConfiguration implements \JsonSerializable
+{
+    public function __construct(
+        public readonly int $maxWidth,
+        public readonly int $maxHeight,
+        public readonly ResizeFileType $fileType,
+        public readonly int $quality,
+    ) {
+        if ($quality <= 0 || $quality > 100) {
+            throw new \OutOfRangeException("The quality value must be larger than 0 and less than or equal to 100.");
+        }
+    }
+
+    #[\Override]
+    public function jsonSerialize(): mixed
+    {
+        return [
+            'maxWidth' => $this->maxWidth,
+            'maxHeight' => $this->maxHeight,
+            'fileType' => $this->fileType->toString(),
+            'quality' => $this->quality,
+        ];
+    }
+
+    public static function unbounded(): self
+    {
+        return new self(
+            -1,
+            -1,
+            ResizeFileType::Keep,
+            100
+        );
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/file/processor/ResizeFileType.class.php b/wcfsetup/install/files/lib/system/file/processor/ResizeFileType.class.php
new file mode 100644 (file)
index 0000000..943731c
--- /dev/null
@@ -0,0 +1,28 @@
+<?php
+
+namespace wcf\system\file\processor;
+
+enum ResizeFileType
+{
+    case Jpeg;
+    case Keep;
+    case Webp;
+
+    public function toString(): string
+    {
+        return match ($this) {
+            self::Jpeg => 'image/jpeg',
+            self::Keep => 'keep',
+            self::Webp => 'image/webp',
+        };
+    }
+
+    public static function fromString(string $fileType): self
+    {
+        return match ($fileType) {
+            'image/jpeg' => self::Jpeg,
+            'keep' => self::Keep,
+            'image/webp' => self::Webp,
+        };
+    }
+}