CCA: Render the first page of PDF to an image as the thumbnail

The previous PDF image extraction logic is error-prone and hard to
maintain. This CL renders the first page of PDF to an image instead of
extracting the first image in the PDF as the thumbnail.

Bug: 330084715, 329069826
Test: Gallery button can display PDFs from CCA and other sources correctly
Change-Id: I0655cbeaec17ed4e00902ec1f74331e603fb0e5f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/5392765
Reviewed-by: Ashley Prasad <ashleydp@google.com>
Commit-Queue: Chu-Hsuan Yang <chuhsuan@chromium.org>
Reviewed-by: Takashi Toyoshima <toyoshim@chromium.org>
Reviewed-by: Sean Li <seannli@google.com>
Reviewed-by: Lei Zhang <thestig@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1292827}
diff --git a/ash/webui/camera_app_ui/camera_app_helper.mojom b/ash/webui/camera_app_ui/camera_app_helper.mojom
index d7579aa..51132e5 100644
--- a/ash/webui/camera_app_ui/camera_app_helper.mojom
+++ b/ash/webui/camera_app_ui/camera_app_helper.mojom
@@ -311,4 +311,7 @@
   // Calling the Update() whenever the screen is locked/unlocked.
   SetScreenLockedMonitor(pending_remote<ScreenLockedMonitor> monitor)
       => (bool is_screen_locked);
+
+  // Returns the first page of a PDF as a JPEG.
+  RenderPdfAsJpeg(array<uint8> pdf_data) => (array<uint8> jpeg_data);
 };
diff --git a/ash/webui/camera_app_ui/camera_app_helper_impl.cc b/ash/webui/camera_app_ui/camera_app_helper_impl.cc
index b3e30fd..72b4679 100644
--- a/ash/webui/camera_app_ui/camera_app_helper_impl.cc
+++ b/ash/webui/camera_app_ui/camera_app_helper_impl.cc
@@ -628,4 +628,9 @@
       ash::SessionManagerClient::Get()->IsScreenLocked());
 }
 
+void CameraAppHelperImpl::RenderPdfAsJpeg(const std::vector<uint8_t>& pdf_data,
+                                          RenderPdfAsJpegCallback callback) {
+  camera_app_ui_->delegate()->RenderPdfAsJpeg(pdf_data, std::move(callback));
+}
+
 }  // namespace ash
diff --git a/ash/webui/camera_app_ui/camera_app_helper_impl.h b/ash/webui/camera_app_ui/camera_app_helper_impl.h
index 3c9f4dc..07fa006 100644
--- a/ash/webui/camera_app_ui/camera_app_helper_impl.h
+++ b/ash/webui/camera_app_ui/camera_app_helper_impl.h
@@ -116,6 +116,8 @@
   void GetEventsSender(GetEventsSenderCallback callback) override;
   void SetScreenLockedMonitor(mojo::PendingRemote<ScreenLockedMonitor> monitor,
                               SetScreenLockedMonitorCallback callback) override;
+  void RenderPdfAsJpeg(const std::vector<uint8_t>& pdf_data,
+                       RenderPdfAsJpegCallback callback) override;
 
  private:
   void CheckExternalScreenState();
diff --git a/ash/webui/camera_app_ui/camera_app_ui_delegate.h b/ash/webui/camera_app_ui/camera_app_ui_delegate.h
index 76a328a..62f6f1a2 100644
--- a/ash/webui/camera_app_ui/camera_app_ui_delegate.h
+++ b/ash/webui/camera_app_ui/camera_app_ui_delegate.h
@@ -133,6 +133,11 @@
 
   // Gets the system language of the current profile.
   virtual std::string GetSystemLanguage() = 0;
+
+  // Returns the first page of a PDF as a JPEG.
+  virtual void RenderPdfAsJpeg(
+      const std::vector<uint8_t>& pdf,
+      base::OnceCallback<void(const std::vector<uint8_t>&)> callback) = 0;
 };
 
 }  // namespace ash
diff --git a/ash/webui/camera_app_ui/resources/js/local_dev_overrides.ts b/ash/webui/camera_app_ui/resources/js/local_dev_overrides.ts
index 0d78776..175a65d 100644
--- a/ash/webui/camera_app_ui/resources/js/local_dev_overrides.ts
+++ b/ash/webui/camera_app_ui/resources/js/local_dev_overrides.ts
@@ -178,6 +178,10 @@
     return false;
   }
 
+  override async renderPdfAsImage(_pdf: Blob): Promise<Blob> {
+    return new Blob();
+  }
+
   /* eslint-enable @typescript-eslint/require-await */
 }
 
diff --git a/ash/webui/camera_app_ui/resources/js/mojo/chrome_helper.ts b/ash/webui/camera_app_ui/resources/js/mojo/chrome_helper.ts
index 45d7eb9..c0832e47 100644
--- a/ash/webui/camera_app_ui/resources/js/mojo/chrome_helper.ts
+++ b/ash/webui/camera_app_ui/resources/js/mojo/chrome_helper.ts
@@ -240,6 +240,8 @@
   abstract initScreenLockedMonitor(onChange: (isScreenLocked: boolean) => void):
       Promise<boolean>;
 
+  abstract renderPdfAsImage(pdf: Blob): Promise<Blob>;
+
   /**
    * Creates a new instance of ChromeHelper if it is not set. Returns the
    *     existing instance.
@@ -507,4 +509,11 @@
         monitorCallbackRouter.$.bindNewPipeAndPassRemote());
     return isScreenLocked;
   }
+
+  override async renderPdfAsImage(pdf: Blob): Promise<Blob> {
+    const buffer = new Uint8Array(await pdf.arrayBuffer());
+    const numArray = castToNumberArray(buffer);
+    const {jpegData} = await this.remote.renderPdfAsJpeg(numArray);
+    return new Blob([new Uint8Array(jpegData)], {type: MimeType.JPEG});
+  }
 }
diff --git a/ash/webui/camera_app_ui/resources/js/thumbnailer.ts b/ash/webui/camera_app_ui/resources/js/thumbnailer.ts
index 1bafc61..e872f31 100644
--- a/ash/webui/camera_app_ui/resources/js/thumbnailer.ts
+++ b/ash/webui/camera_app_ui/resources/js/thumbnailer.ts
@@ -3,6 +3,7 @@
 // found in the LICENSE file.
 
 import {assertInstanceof} from './assert.js';
+import {ChromeHelper} from './mojo/chrome_helper.js';
 import {
   EmptyThumbnailError,
   LoadError,
@@ -147,80 +148,6 @@
 }
 
 /**
- * Failed to find image in pdf error.
- */
-class NoImageInPdfError extends Error {
-  constructor(message = 'Failed to find image in pdf') {
-    super(message);
-    this.name = this.constructor.name;
-  }
-}
-
-/**
- * Gets image embedded in a PDF.
- *
- * @param blob Blob of PDF.
- */
-async function getImageFromPdf(blob: Blob): Promise<Blob> {
-  const buf = await blob.arrayBuffer();
-  const view = new Uint8Array(buf);
-  let i = 0;
-  /**
-   * Finds |patterns| in view starting from |i| and moves |i| to end of found
-   * pattern index.
-   *
-   * @return Returns begin of found pattern index or -1 for no further pattern
-   *     is found.
-   */
-  function findPattern(...patterns: number[]): number {
-    for (; i + patterns.length < view.length; i++) {
-      if (patterns.every((b, index) => b === view[i + index])) {
-        const ret = i;
-        i += patterns.length;
-        return ret;
-      }
-    }
-    return -1;
-  }
-  // Parse object contains /Subtype /Image name and field from pdf format:
-  // <</Name1 /Field1... \n/Name2... >>...<<...>>
-  // The jpeg stream will follow the target object with length in field of
-  // /Length.
-  while (i < view.length) {
-    const start = findPattern(0x3c, 0x3c);  // <<
-    if (start === -1) {
-      throw new NoImageInPdfError();
-    }
-    const end = findPattern(0x3e, 0x3e);  // >>
-    if (end === -1) {
-      throw new NoImageInPdfError();
-    }
-    const s = String.fromCharCode(...view.slice(start + 2, end));
-    const objs = s.split('\n');
-    let isImage = false;
-    let length = 0;
-    for (const obj of objs) {
-      const [name, field] = obj.split(' ');
-      switch (name) {
-        case '/Subtype':
-          isImage = field === '/Image';
-          break;
-        case '/Length':
-          length = Number(field);
-          break;
-        default:
-          // nothing to do.
-      }
-    }
-    if (isImage) {
-      i += ' stream\n'.length;
-      return new Blob([buf.slice(i, i + length)], {type: 'image/jpeg'});
-    }
-  }
-  throw new NoImageInPdfError();
-}
-
-/**
  * Throws when the input blob type is not supported by thumbnailer.
  */
 class InvalidBlobTypeError extends Error {
@@ -247,7 +174,7 @@
     case MimeType.MP4:
       return scaleVideo(blob, VIDEO_COVER_WIDTH);
     case MimeType.PDF:
-      return getImageFromPdf(blob);
+      return ChromeHelper.getInstance().renderPdfAsImage(blob);
     default:
       throw new InvalidBlobTypeError(blob.type);
   }
diff --git a/chrome/browser/ash/system_web_apps/apps/DEPS b/chrome/browser/ash/system_web_apps/apps/DEPS
index 071e437..0d8e6f0e 100644
--- a/chrome/browser/ash/system_web_apps/apps/DEPS
+++ b/chrome/browser/ash/system_web_apps/apps/DEPS
@@ -59,6 +59,8 @@
   "+chrome/browser/media/webrtc",
   "+chrome/browser/metrics",
   "+chrome/browser/notifications",
+  # TODO(b/335118703): Move out from this list.
+  "+chrome/browser/pdf",
   "+chrome/browser/platform_util.h",
   "+chrome/browser/policy",
   "+chrome/browser/profiles",
@@ -99,6 +101,8 @@
   "+chrome/common/url_constants.h",
   "+chrome/common/webui_url_constants.h",
   "+chrome/grit",
+  # TODO(b/335118703): Move out from this list.
+  "+chrome/services/pdf/public/mojom",
   "+chrome/test/base",
 ]
 
diff --git a/chrome/browser/ash/system_web_apps/apps/camera_app/chrome_camera_app_ui_delegate.cc b/chrome/browser/ash/system_web_apps/apps/camera_app/chrome_camera_app_ui_delegate.cc
index 2023ace..fb591a9 100644
--- a/chrome/browser/ash/system_web_apps/apps/camera_app/chrome_camera_app_ui_delegate.cc
+++ b/chrome/browser/ash/system_web_apps/apps/camera_app/chrome_camera_app_ui_delegate.cc
@@ -12,7 +12,9 @@
 #include "base/feature_list.h"
 #include "base/files/file_path.h"
 #include "base/files/file_util.h"
+#include "base/functional/bind.h"
 #include "base/logging.h"
+#include "base/memory/read_only_shared_memory_region.h"
 #include "base/system/sys_info.h"
 #include "base/task/bind_post_task.h"
 #include "base/task/sequenced_task_runner.h"
@@ -29,6 +31,7 @@
 #include "chrome/browser/media/webrtc/media_capture_devices_dispatcher.h"
 #include "chrome/browser/media/webrtc/media_device_salt_service_factory.h"
 #include "chrome/browser/metrics/chrome_metrics_service_accessor.h"
+#include "chrome/browser/pdf/pdf_service.h"
 #include "chrome/browser/profiles/profile.h"
 #include "chrome/browser/ui/ash/holding_space/holding_space_keyed_service.h"
 #include "chrome/browser/ui/ash/holding_space/holding_space_keyed_service_factory.h"
@@ -41,6 +44,8 @@
 #include "chrome/browser/web_applications/web_app_provider.h"
 #include "chrome/browser/web_applications/web_app_tab_helper.h"
 #include "chrome/common/pref_names.h"
+#include "chrome/services/pdf/public/mojom/pdf_service.mojom.h"
+#include "chrome/services/pdf/public/mojom/pdf_thumbnailer.mojom.h"
 #include "chromeos/constants/chromeos_features.h"
 #include "chromeos/constants/devicetype.h"
 #include "chromeos/ui/base/window_properties.h"
@@ -51,8 +56,16 @@
 #include "content/public/browser/render_frame_host.h"
 #include "content/public/browser/web_contents.h"
 #include "content/public/browser/web_ui_data_source.h"
+#include "mojo/public/cpp/bindings/pending_remote.h"
+#include "mojo/public/cpp/bindings/remote.h"
+#include "mojo/public/cpp/bindings/remote_set.h"
 #include "third_party/blink/public/mojom/mediastream/media_stream.mojom.h"
+#include "third_party/skia/include/core/SkBitmap.h"
+#include "third_party/skia/include/core/SkData.h"
+#include "third_party/skia/include/core/SkStream.h"
+#include "third_party/skia/include/encode/SkJpegEncoder.h"
 #include "ui/chromeos/styles/cros_styles.h"
+#include "ui/gfx/geometry/size.h"
 #include "ui/gfx/native_widget_types.h"
 #include "url/gurl.h"
 
@@ -78,6 +91,9 @@
 const int64_t kStorageLowThreshold = 128 * 1024 * 1024;           // 128MB
 const int64_t kStorageCriticallyLowThreshold = 32 * 1024 * 1024;  // 32MB
 
+// PDFs saved from CCA are always 72 dpi.
+constexpr int kPdfDpi = 72;
+
 }  // namespace
 
 // static
@@ -244,6 +260,79 @@
   }
 }
 
+ChromeCameraAppUIDelegate::PdfServiceManager::PdfServiceManager() {
+  pdf_thumbnailers_.set_disconnect_handler(
+      base::BindRepeating(&ChromeCameraAppUIDelegate::PdfServiceManager::
+                              ConsumeGotThumbnailCallback,
+                          weak_factory_.GetWeakPtr(), std::vector<uint8_t>()));
+}
+
+ChromeCameraAppUIDelegate::PdfServiceManager::~PdfServiceManager() = default;
+
+void ChromeCameraAppUIDelegate::PdfServiceManager::GetThumbnail(
+    const std::vector<uint8_t>& pdf,
+    base::OnceCallback<void(const std::vector<uint8_t>&)> callback) {
+  // TODO(b/329069826): To prevent the thumbnailer from adding a white
+  // background to the result, get the actual dimensions and limit them to the
+  // maximum supported dimensions (keeping the aspect ratio), rather than
+  // passing the maximum supported dimensions directly.
+  auto params = pdf::mojom::ThumbParams::New(
+      /*size_px=*/gfx::Size(pdf::mojom::PdfThumbnailer::kMaxWidthPixels,
+                            pdf::mojom::PdfThumbnailer::kMaxHeightPixels),
+      /*dpi=*/gfx::Size(kPdfDpi, kPdfDpi),
+      /*stretch_to_bounds=*/false, /*keep_aspect_ratio=*/true);
+  auto pdf_region = base::ReadOnlySharedMemoryRegion::Create(pdf.size());
+  if (!pdf_region.IsValid()) {
+    LOG(ERROR) << "Failed to allocate memory for PDF";
+    std::move(callback).Run({});
+    return;
+  }
+  memcpy(pdf_region.mapping.memory(), pdf.data(), pdf.size());
+
+  mojo::Remote<pdf::mojom::PdfService> pdf_service = LaunchPdfService();
+  mojo::PendingRemote<pdf::mojom::PdfThumbnailer> pdf_thumbnailer;
+  pdf_service->BindPdfThumbnailer(
+      pdf_thumbnailer.InitWithNewPipeAndPassReceiver());
+  mojo::RemoteSetElementId pdf_service_id =
+      pdf_services_.Add(std::move(pdf_service));
+  mojo::RemoteSetElementId pdf_thumbnailer_id =
+      pdf_thumbnailers_.Add(std::move(pdf_thumbnailer));
+  pdf_thumbnailer_callbacks[pdf_thumbnailer_id] = std::move(callback);
+  pdf_thumbnailers_.Get(pdf_thumbnailer_id)
+      ->GetThumbnail(
+          std::move(params), std::move(pdf_region.region),
+          base::BindOnce(
+              &ChromeCameraAppUIDelegate::PdfServiceManager::GotThumbnail,
+              weak_factory_.GetWeakPtr(), pdf_service_id, pdf_thumbnailer_id));
+}
+
+void ChromeCameraAppUIDelegate::PdfServiceManager::GotThumbnail(
+    mojo::RemoteSetElementId pdf_service_id,
+    mojo::RemoteSetElementId pdf_thumbnailer_id,
+    const SkBitmap& bitmap) {
+  SkDynamicMemoryWStream stream;
+  if (SkJpegEncoder::Encode(&stream, bitmap.pixmap(), {}) &&
+      stream.bytesWritten()) {
+    sk_sp<SkData> jpeg_data = stream.detachAsData();
+    ConsumeGotThumbnailCallback(
+        std::vector<uint8_t>(jpeg_data->bytes(),
+                             jpeg_data->bytes() + jpeg_data->size()),
+        pdf_thumbnailer_id);
+  } else {
+    LOG(ERROR) << "Failed to encode bitmap to JPEG";
+    ConsumeGotThumbnailCallback({}, pdf_thumbnailer_id);
+  }
+  pdf_thumbnailers_.Remove(pdf_thumbnailer_id);
+  pdf_services_.Remove(pdf_service_id);
+}
+
+void ChromeCameraAppUIDelegate::PdfServiceManager::ConsumeGotThumbnailCallback(
+    const std::vector<uint8_t>& thumbnail,
+    mojo::RemoteSetElementId id) {
+  std::move(pdf_thumbnailer_callbacks[id]).Run(thumbnail);
+  pdf_thumbnailer_callbacks.erase(id);
+}
+
 ChromeCameraAppUIDelegate::ChromeCameraAppUIDelegate(content::WebUI* web_ui)
     : web_ui_(web_ui),
       session_start_time_(base::Time::Now()),
@@ -575,6 +664,12 @@
   return accept_languages.substr(0, accept_languages.find(','));
 }
 
+void ChromeCameraAppUIDelegate::RenderPdfAsJpeg(
+    const std::vector<uint8_t>& pdf,
+    base::OnceCallback<void(const std::vector<uint8_t>&)> callback) {
+  pdf_service_manager_.GetThumbnail(pdf, std::move(callback));
+}
+
 ash::CameraAppUIDelegate::WifiConfig::WifiConfig() = default;
 
 ash::CameraAppUIDelegate::WifiConfig::WifiConfig(
diff --git a/chrome/browser/ash/system_web_apps/apps/camera_app/chrome_camera_app_ui_delegate.h b/chrome/browser/ash/system_web_apps/apps/camera_app/chrome_camera_app_ui_delegate.h
index 128d6be..ec4ec75 100644
--- a/chrome/browser/ash/system_web_apps/apps/camera_app/chrome_camera_app_ui_delegate.h
+++ b/chrome/browser/ash/system_web_apps/apps/camera_app/chrome_camera_app_ui_delegate.h
@@ -9,6 +9,7 @@
 
 #include "ash/public/cpp/holding_space/holding_space_client.h"
 #include "ash/webui/camera_app_ui/camera_app_ui_delegate.h"
+#include "base/containers/flat_map.h"
 #include "base/files/file_path_watcher.h"
 #include "base/functional/callback.h"
 #include "base/memory/raw_ptr.h"
@@ -17,8 +18,12 @@
 #include "base/time/time.h"
 #include "base/timer/timer.h"
 #include "chrome/browser/ui/webui/ash/system_web_dialog_delegate.h"
+#include "chrome/services/pdf/public/mojom/pdf_service.mojom.h"
+#include "chrome/services/pdf/public/mojom/pdf_thumbnailer.mojom.h"
 #include "content/public/browser/media_stream_request.h"
 #include "content/public/browser/web_ui.h"
+#include "mojo/public/cpp/bindings/remote.h"
+#include "mojo/public/cpp/bindings/remote_set.h"
 
 namespace content {
 struct MediaStreamRequest;
@@ -113,6 +118,32 @@
         weak_factory_{this};
   };
 
+  class PdfServiceManager {
+   public:
+    PdfServiceManager();
+    PdfServiceManager(const PdfServiceManager&) = delete;
+    PdfServiceManager& operator=(const PdfServiceManager&) = delete;
+    ~PdfServiceManager();
+
+    void GetThumbnail(
+        const std::vector<uint8_t>& pdf,
+        base::OnceCallback<void(const std::vector<uint8_t>&)> callback);
+
+   private:
+    void GotThumbnail(mojo::RemoteSetElementId pdf_service_id,
+                      mojo::RemoteSetElementId pdf_thumbnailer_id,
+                      const SkBitmap& bitmap);
+    void ConsumeGotThumbnailCallback(const std::vector<uint8_t>& thumbnail,
+                                     mojo::RemoteSetElementId id);
+
+    mojo::RemoteSet<pdf::mojom::PdfThumbnailer> pdf_thumbnailers_;
+    base::flat_map<mojo::RemoteSetElementId,
+                   base::OnceCallback<void(const std::vector<uint8_t>&)>>
+        pdf_thumbnailer_callbacks;
+    mojo::RemoteSet<pdf::mojom::PdfService> pdf_services_;
+    base::WeakPtrFactory<PdfServiceManager> weak_factory_{this};
+  };
+
   explicit ChromeCameraAppUIDelegate(content::WebUI* web_ui);
 
   ChromeCameraAppUIDelegate(const ChromeCameraAppUIDelegate&) = delete;
@@ -142,6 +173,9 @@
       content::BrowserContext* context) override;
   void OpenWifiDialog(WifiConfig wifi_config) override;
   std::string GetSystemLanguage() override;
+  void RenderPdfAsJpeg(
+      const std::vector<uint8_t>& pdf,
+      base::OnceCallback<void(const std::vector<uint8_t>&)> callback) override;
 
  private:
   base::FilePath GetMyFilesFolder();
@@ -168,6 +202,8 @@
   base::WeakPtr<ChromeCameraAppUIDelegate::StorageMonitor>
       storage_monitor_weak_ptr_;
 
+  PdfServiceManager pdf_service_manager_;
+
   // Weak pointer for this class |ChromeCameraAppUIDelegate|, used to run on
   // main thread (mojo thread).
   base::WeakPtrFactory<ChromeCameraAppUIDelegate> weak_factory_{this};