0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Chrome拡張機能】PDFの「完全」ダークモード化は可能か?[#1]

Posted at

はじめに

先日、Open Hack U 2025 KANAZAWAというLINEヤフーさんが開催している学生ハッカソンへ参加してきました。
詳細は以下のWebサイトに掲載されていますので詳細はこちらをご覧ください。

そこで発表した作品について、今回は作品の概要とその具体的な仕組みを、細かく紐解きながら説明していきたいと思います。

今回、短期間で作る必要があったため、かなりの部分をAIに頼って書きました。そのため、コードの詳細理解をするためにこの記事を書くこととしました。

Chromeウェブストアにて拡張機能が公開されました!
是非ご活用の上、ご指摘等していただけますと幸いです。
https://chromewebstore.google.com/detail/invert-pdf-viewer/klndmcomjnjmcappeiibgklmlhdcihhe?hl=ja

今回使用したコードについては以下のGitHubにまとめてありますのでそちらもぜひご覧ください。
https://github.com/Sekainokanata/PDFDARK

なお、コードの詳細説明を一つの記事で書くのは無理があるので、今回は

  • システムの発想
  • ファイル構造
  • viewerフォルダ外のコード

の3点について説明していこうと思います。
それ以外のファイルのコード説明は作成次第項目の場所にURLを貼り付けますのでしばらくお待ちください。

使った言語や技術等+著作権表示

HTML,CSS,JavaScript,PDF.js,OpenCV.js

=== PDF.js ===
Copyright © Mozilla Foundation and contributors.
Licensed under the Apache License, Version 2.0.
See http://www.apache.org/licenses/LICENSE-2.0 for the full license text.

=== OpenCV.js ===
Copyright © 2000–2025 OpenCV team.
Licensed under the Apache License, Version 2.0.
See http://www.apache.org/licenses/LICENSE-2.0 and https://opencv.org/license/ for details.

作成したアプリケーションについて

今回作成したアプリケーションは、PDFの完全ダークモード化を実現しようとするChrome拡張機能です。現状、今までのPDFリーダーはダークモード設定にしても画像の色まで反転してしまっていました。

そこで、新たに写真が反転しないようにした、新たなPDFリーダーを作成しようと考えました。

しかし、PDFのままではどこに画像があって、どこにテキストがあるかを判別するのが難しいというのが現状です。

そこで、PDF→SVGへ変換することによってテキストと画像の位置が割り出せるのではないかと考えました。つまり、今回作ったのは正確に言うと SVGビュワーということになります。SVGであれば、PDFのベクター情報を落とすことなく表示できるため、PDFビュワー「もどき」としては最適であると考えました。

SVGにするとテキストと画像の位置が分かるとは?

まず、こちらをご覧ください。

以下に用いてる画像はUI改善前(ハッカソンで出した際のもの)です。
後述するui.jsではより洗練されたUIになっています。

image.png

この画像は、拡張機能を有効にした状態で要素を覗いてみたものです(つまり、SVGの中身を覗いているものに相当します)。このように、テキスト部分に「svg:text」タグが付いており...

image.png

画像部分には「svg:image」タグが付いています。
これによってテキストと画像の位置の取得が簡単にできます。

ダーク化システムの構造

この拡張機能は、以下のような仕組みとなっています。

image.png

テキストベースPDF(普通にWord等からPDFへ変換したタイプのPDF)の場合、SVGへ変換します。すると、上記の通り「image」タグと「text」タグを抽出することが出来ます。背景色と「text」を反転させたうえで、「image」は彩度等から判断して、グラフであれば反転せず、写真であれば反転するという処理を行っています。

また、ハッカソンの際にだした作品では、画像ベースPDF(ページ丸ごと画像一枚で構成されているPDF)の場合、SVGをPNG変換して、それを写真領域の抽出ができる機械学習モデルに入力して、その出力として得られる座標の内側(写真があると推測された領域)のみを反転させないという仕組みにしていました。

ただし、結局推測にかなり時間がかかるのと、精度を向上させるのが難しかった為、別の手法を取ることにしました。

具体的にはOpenCVをもちいて画像の中から写真を見つけ出すという手法です。詳しくは後述します。これによって、きわめて精度が高くかつ素早く写真領域を特定することが可能になりました。

システム解説~ファイルを読み解こう~

まず、全体のファイル構成を以下に示します。

PDFDARK/
 ├ pdfjs/
 │ └ pdf.js
 │ └ pdf.worker.js
 │ └ cmaps/
 │   └ *.bcmap(多数のファイル)
 ├ sandbox/
 │ └ opencv.html
 │ └ opencv.js
 ├ vender/
 │ └ opencv/
 │   └ opencv.js
 │   └ opencv_js.wasm
 ├ viewer/
 │ └ main.js
 │ └ renderer.js
 │ └ scroll.js
 │ └ toolbar.js
 │ └ ui.js
 ├ background.js
 ├ icon48.png
 ├ manifest.json
 ├ viewer-run.js 
 ├ viewer.html
 ├ popup.html
 ├ popup.js
 ├ popup.css
 └ style.css

では、まず外側の各ファイルの働きから見ていきましょう。
ただし、css及びmanifest.jsonについては解説を省きます。時間があればのちに別の記事で詳しく解説をしようと考えております。

1.プロジェクトルート直下のファイル

viewer.html

プログラムは以下の通りになっています。

<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <!-- モバイル等でのページズームを抑止し、内部ズームに一本化 -->
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
  <title>シロクロクローバ</title>
  <!--<link rel="stylesheet" href="viewer.css">-->
  <link rel="stylesheet" href="/style.css">
</head>
<body>
  
  <div id="container">Loading PDF…</div>

  <!-- 1) pdf.js 本体 -->
  <script src="pdfjs/pdf.js"></script>

  <!-- 2) 分割した viewer スクリプト群 -->
  <script src="viewer/scroll.js"></script>
  <script src="viewer/ui.js"></script>
  <script src="viewer/renderer.js"></script>
  <script src="viewer/toolbar.js"></script>
  <script src="viewer/main.js"></script>
  <script src="viewer-run.js"></script>
</body>
</html>

head部では、そのまま「文字セット」の指定、タイトルの設定、処理中の際の表示、そしてCSSファイルの読み込みを行っています。

次に、body部では各種JSを読み込んでいます。
ここで大事なのが、「OpenCV.jsはここで読み込んではいけない」ということです。

これはChrome拡張機能に課されたManifest V3によるものです。具体的な理由は不明ですが、おそらくOpenCV.jsによる動的コード生成が悪さをしているような気がします。どちらにせよ、OpenCVに関しての処理はsandbox側で行う必要があります。

viewer-run.js

以下のようなコードになっています。

// viewer-run.js
// worker のパスを拡張内のファイルに合わせる
pdfjsLib.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL('pdfjs/pdf.worker.js');

// 拡張機能の有効/無効状態をチェック
async function checkExtensionEnabled() {
  try {
    const result = await chrome.storage.local.get(['pdfViewerEnabled']);
    return result.pdfViewerEnabled !== false; // デフォルトはON
  } catch (e) {
    console.warn('Could not check extension state:', e);
    return true; // エラー時はデフォルトON
  }
}

document.addEventListener('DOMContentLoaded', async () => {
  // 拡張機能が無効の場合、元のPDF URLにリダイレクト
  const enabled = await checkExtensionEnabled();
  if (!enabled) {
    const params = new URLSearchParams(location.search);
    const originalPdfUrl = params.get('file');
    if (originalPdfUrl) {
      console.log('PDF Dark Viewer is disabled. Redirecting to original PDF:', originalPdfUrl);
      // 元のPDF URLにリダイレクト
      location.replace(originalPdfUrl);
      return; // startViewer を実行しない
    }
  }
  
  if (typeof startViewer === 'function') {
    startViewer().catch(e => {
      console.error('startViewer error', e);
      document.getElementById('container').textContent = 'Error: ' + e.message;
    });
  } else {
    console.error('startViewer is not defined (viewer.js が読み込まれていない可能性あり)');
  }
});

拡張機能自体をクリックするとポップアップ(下図)によって拡張機能のON/OFFを切り替えられるようにする為、有効か無効かの状態チェックを行っています。

image.png

ページ再読み込み時、有効状態であれば自動的に拡張機能が提供するビュワーに切り替わりますが、無効状態の場合はそうなりません。

ですので、無効状態の場合はもとのPDFファイルのURLへリダイレクトし直し、後述するmain.jsのstartViewerを実行しないようにすることで拡張機能のON/OFFを切り替えられるようにしています。

DOM要素へすべてアクセス出来るようになってから、JS等依存ファイルがきちんと読み込まれているか確認しています。ここで、startViewerは非同期関数なので.catch()でエラーの補足を行っています。

popup.html

拡張機能をクリックした際に出てくる、先ほど貼った画像のようなポップアップを表示する為のものです。

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="/popup.css">
</head>
<body>
  <div class="header">
    <img src="images/darkmode.png" alt="Icon">
    <h1>PDF Dark Viewer</h1>
  </div>
  
  <div class="toggle-section">
    <div class="toggle-container" id="toggleBtn">
      <span class="toggle-label">自動PDF変換</span>
      <div class="toggle-switch" id="toggleSwitch"></div>
    </div>
    <div class="status-text" id="statusText">読み込み中...</div>
  </div>
  
  <div class="info">
    <strong>ON:</strong> PDFを自動的にダークモードビューアーで開きます<br>
    <strong>OFF:</strong> 通常のPDFビューアーで開きます
  </div>
  
  <script src="popup.js"></script>
</body>
</html>

"toggle-section"で、トグルボタンの実装を行っています。
また、ON/OFFの際の説明も追記しています。

popup.js

コードは以下のようになっています。

// popup.js - ポップアップメニューのロジック

// 現在の有効/無効状態を取得
async function getExtensionState() {
  const result = await chrome.storage.local.get(['pdfViewerEnabled']);
  // デフォルトはON
  return result.pdfViewerEnabled !== false;
}

// 状態を保存
async function setExtensionState(enabled) {
  await chrome.storage.local.set({ pdfViewerEnabled: enabled });
}

// UI更新
function updateUI(enabled) {
  const toggleSwitch = document.getElementById('toggleSwitch');
  const statusText = document.getElementById('statusText');
  
  if (enabled) {
    toggleSwitch.classList.add('active');
    statusText.textContent = '有効 - PDFを自動変換します';
    statusText.className = 'status-text enabled';
  } else {
    toggleSwitch.classList.remove('active');
    statusText.textContent = '無効 - 通常のPDFビューアーを使用します';
    statusText.className = 'status-text disabled';
  }
}

// トグル処理
async function toggleExtension() {
  const currentState = await getExtensionState();
  const newState = !currentState;
  await setExtensionState(newState);
  updateUI(newState);
  
  // バックグラウンドスクリプトに通知(アイコン更新用)
  chrome.runtime.sendMessage({ 
    action: 'stateChanged', 
    enabled: newState 
  });
}

// 初期化
document.addEventListener('DOMContentLoaded', async () => {
  const enabled = await getExtensionState();
  updateUI(enabled);
  
  // クリックイベント
  document.getElementById('toggleBtn').addEventListener('click', toggleExtension);
});

いろいろとはありますが、主にトグルボタンを切り替えた際に、UIを更新したり、background.js等へ情報をわたすような役割をしています。

background.js

ここでは、つねにバックグラウンドで行うべき処理を書いています。

// 拡張機能の有効/無効状態を取得
async function isExtensionEnabled() {
  const result = await chrome.storage.local.get(['pdfViewerEnabled']);
  return result.pdfViewerEnabled !== false; // デフォルトはON
}

// アイコンの状態を更新
async function updateIcon() {
  const enabled = await isExtensionEnabled();
  const iconPath = enabled ? "images/darkmode.png" : "images/darkmode.png"; // 必要に応じてOFF用アイコンを別途作成可能
  const title = enabled ? "PDF Dark Viewer (有効)" : "PDF Dark Viewer (無効)";
  
  chrome.action.setIcon({ path: iconPath });
  chrome.action.setTitle({ title: title });
}

chrome.runtime.onInstalled.addListener(async () => {
  // 初期状態を設定(デフォルトON)
  const result = await chrome.storage.local.get(['pdfViewerEnabled']);
  if (result.pdfViewerEnabled === undefined) {
    await chrome.storage.local.set({ pdfViewerEnabled: true });
  }
  
  await updateIcon();
});

// ポップアップからの状態変更メッセージを受信
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === 'stateChanged') {
    updateIcon();
  }
});


// タブの情報(URLなど)が更新されたときに発火するイベントリスナー
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
  if (changeInfo.status !== 'complete') return; // complete 以外は無視

  const url = tab.url;
  if (!url) return; // url が undefined の場合は何もしない

  if (!url.match(/\.pdf(\?|$)/i)) {
    return; // PDFじゃなければ無視
  }

  // 拡張機能が有効な場合のみビューアーで開く
  const enabled = await isExtensionEnabled();
  if (!enabled) {
    console.log('PDF Dark Viewer is disabled. Skipping auto-conversion.');
    return;
  }

  const viewerUrl = chrome.runtime.getURL("viewer.html") + "?file=" + encodeURIComponent(url);
  chrome.tabs.update(tabId, { url: viewerUrl });
});

機能はコードのコメントに書いてあることが全てなのですが、ここでなにか疑問に思った方もいらっしゃるのではないでしょうか?

そう。viewer-run.jsと処理内容被っとるやん、と。
確かに、拡張機能が有効状態かどうか(ポップアップウインドウで切り替えられる奴)をチェックする構造が重複しています。





...君のような勘のいいガキは嫌いだよ


※Qiita執筆時にこの矛盾があることを気づき、現在ではURLパラメータで有効or無効状態を渡すようにしています。

2.viewerフォルダ内のJSファイル

さて、ここからはコード自体がとっても長くなるため、最初にコード全体の様子を解説してから内部に迫りたいと思います。

main.js

制作中...

renderer.js

制作中...

scroll.js

制作中...

toolbar.js

制作中...

ui.js

制作中...

3.sandboxフォルダ内のJSファイル

opencv.html

制作中...

opencv.js

制作中...

特に苦労した点/良かった点

ハッカソンまでは、写真領域の判定に機械学習モデルを用いようとしていました。
しかし、そもそも学習が大変だし、Windows環境だとなかなか難しく...正直ハッカソンまでには何とか動きはしたものの、実用的な精度までは結局間に合いませんでした。

また、Manifest V3の制限がかなりきつく、sandbox回避を使ったり、manifest.jsonでの権限設定でなにがダメかを一つずつ試したり...

さらに、スクールバーの実装等にもかなり苦労しました。
Chrome標準のPDF Readerに近い実装を行ったのですが...改めてよく考えられたコードであるなと実感させられました。本システムでは、とりあえず画面中央を基準として拡大縮小するように設定していますが、Chrome標準の方はマウス座標を参照しており、より好きな場所が拡大しやすくなっていました。

しかし、前回のハッカソンとは違い、期間中に主要機能自体は問題なく動かすことの出来るレベルまで持って行けたので、そこに関しては満足しています。また、一応とはいえ自分たちで作ったプログラムを世の中に公開できたことがなによりもの幸せで、成長を感じました。

最後に

本ソフトウェアは、PDF.jsおよびOpenCV.jsのオープンソースライブラリを利用しています。これらのライブラリの開発者に深く感謝いたします。
また、友人のsatoyaa氏には、このアプリケーションの開発において多大な助言とサポートをいただきました。心より感謝申し上げます。

そして最後に、ハッカソンを開催してくださったLINEヤフー社様、サポートしてくださった社員の方々や審査員の方々にも厚くお礼申し上げます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?