ElectronでHTMLコンテンツをPDF出力する方法

## 概要

**McGwire Markdown**は**HTMLコンテンツをPDFとして出力する機能を備えた**Electron製マークダウンエディタです。

https://github.com/itsuki-maru/McGwire-Markdown

このPDF出力は**Electron（Chromium）が持つ標準機能のみで実現可能**です。

今回はこの「**ElectronでHTMLコンテンツをPDFする機能**」の実装方法をアウトプットします。

## 実装手順

### 実行環境

今回の環境は次のとおりです。

| 環境 | 内容 |
| ---- | ---- |
| OS | Ubuntu Desktop 22.04 |
| Node.js | v18.16.0 |
| Electron | 25.3.2 |

### 1. プロジェクトの作成

まずは`npm`を使用して新規プロジェクトを作成しましょう。

```shell
mkdir electron-app && cd electron-app
npm init -y
npm install electron --save-dev
```

次のようなプロジェクトディレクトリが出来上がりましたら、`package.json`の編集に進みます。

```
/electron-app
  ├─node_modules
  ├─package.json
  └─package-lock.json
```

### 2. package.jsonの編集

次の設定項目を変更します。

- エントリーポイントを`index.js`から`./src/main.js`に変更
- `"test": "echo \"Error: no test specified\" && exit 1"`を削除
- 起動スクリプトを`electron .`とするために`"start": "electron ."`を追記

```diff_javascript
{
  "name": "electron-app",
  "version": "1.0.0",
  "description": "",
-    "main": "index.js",
+    "main": "./src/main.js",
  "scripts": {
-   "test": "echo \"Error: no test specified\" && exit 1"
+   "start": "electron ."
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "electron": "^25.3.2"
  }
}
```

### 3. 各ファイルの作成

続いて、`src`というディレクトリを作成し、その中に**必要な各ファイルを作成**していきましょう。

```
/electron-app
  ├─node_modules
  ├─package.json
  ├─package-lock.json
  └─src 
     ├─main.js     # メインプロセス（Node.js側）
     ├─renderer.js # UIにボタンイベントを設定するなどを行う（画面側）
     ├─preload.js  # メインプロセスとレンダラープロセスを中継するファイル
     ├─index.html  # メイン画面に表示するHTML
     └─topdf.html  # PDFに出力するHTML
```

#### main.js

**Electronの読み込み、設定、起動**を行う「**メインプロセス**」となるファイルです。

**Node.js**の機能を使用するため、セキュリティ対策として、**UI側（画面側）からメインプロセスの機能を呼び出す**ためには`ipcMain`を定義します。

今回はPDF出力を行う`outputPDF`という関数を`ipcMain.handle("outputPDF", outputPDF);`のように定義します。

なお、ウィンドウ作成といった**基本的なElectronの操作説明は割愛しますが、なるべくコメントを記載するようにしました**ので、参考にしてください。

```javascript
const path = require("path");
const fs = require("fs");
const os = require("os");
const electron = require("electron");
const { BrowserWindow, ipcMain } = electron;

// Electronのappインスタンスを定義
const app = electron.app;
let mainWindow;

/** メインウィンドウを作成する関数 */
function createWindow() {
    const settings = {
        width: 800,
        height: 500,
        webPreferences: {
            preload: path.join(app.getAppPath(), "./src/preload.js"),
        }
    }
    mainWindow = new BrowserWindow(settings);
    mainWindow.loadFile("./src/index.html");
    mainWindow.setMenuBarVisibility(false);
    mainWindow.show();

    mainWindow.on("closed", () => {
        mainWindow = null;
        app.quit();
    });
};

// 初期化完了時にメインウィンドウを起動
app.on("ready", createWindow);

// 全ウィンドウがクローズ時にappを終了
app.on("window-all-closed", () => {
    // macOS以外のOS
    if (process.platform !== "darwin") {
        app.quit();
    }
});


// index.html（レンダラープロセス）から呼び出し可能な関数を設定
ipcMain.handle("outputPDF", outputPDF);


/** PDFを出力する関数
 * 
 *  topdf.htmlというHTMLファイルの内容をPDFに出力します。 
 *  出力にはwebContents.printToPDFを使用します。
 * 
 *  処理を簡素化していますが、html文字列を引数として受け取り、一時的なHTMLファイルを
 *  作成してPDFで出力といった処理も当然ながら可能です。
 *  注意点として、ウィンドウを非表示で作成していますので、完了時にcloseする処理が本来は必要です。
 */
async function outputPDF(event) {
    // 出力先を定義
    let basePath = path.join(os.homedir(), "Desktop");
    let outputFilename = "output-html-topdf.pdf";
    outputPath = path.join(basePath, outputFilename);

    // 出力用ウィンドウの設定
    let setting = {
        width: 950,
        height: 700,
        show: false,
        webPreference: {
        }
    }
    let outputPDFWindow = new BrowserWindow(setting);
    outputPDFWindow.loadFile("./src/topdf.html");

    // 非表示でPDF出力用のoutputPDFWindowを起動して、ロードが完了したらPDFとして出力
    outputPDFWindow.webContents.on("did-finish-load", () => {
        // 出力時のサイズなどを設定
        outputPDFWindow.webContents.printToPDF({
            scale: 0.8,
            pageSize: "A4",
            printBackground: true,
            margins: {
                bottom: 1,
            }
        }).then(data => {
            try {
                // OK.
                fs.writeFileSync(outputPath, data);
                console.log("PDF Output Ok.")

            } catch (error) {
                // Error.
                console.error("An error occurred while writing the file: ", error);
            }
        }).catch((error) => {
            console.log(error);
        });
    });
    return "PDF Output function end.";
};
```

`outputPDF`関数の仕様は次のようになっています。

- `topdf.html`というHTMLファイルの内容をPDFに出力します。 
- デスクトップに`output-html-topdf.pdf`というPDFファイルを作成します。
- 出力には`webContents.printToPDF`を使用します。

https://www.electronjs.org/ja/docs/latest/api/web-contents

注意点として、**ウィンドウを非表示で作成していますので、完了時にcloseする処理が本来は必要です**。

また、今回は処理を簡素化していますが、例えば、

- **outputPDF関数がhtml文字列を引数として受け取る**
- **一時的なHTMLファイルを作成**
- **PDFで出力**

といった処理も当然ながら可能です。

上記については、**McGwire Markdown**で実装していますので参考として頂けたらと思います。

https://github.com/itsuki-maru/McGwire-Markdown

#### index.html

**メインウィンドウとなるHTMLファイル**です。特筆すべき点はありませんが、`outputPDF`関数を発火させるボタンを設置しています。

```html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Sample Electron</title>
</head>
<body>
    <h1>PDF出力サンプル</h1>
    <button class="btn-function" id="btnPDFOutput">PDF出力</button>
</body>
<script src="./renderer.js"></script>
</html>
```

#### renderer.js

**メインウィンドウであるindex.htmlに読み込ませるファイル**です。

主に次のことを行っています。

- **index.htmlで配置したボタンにイベントを定義**
- **メインプロセスの関数を呼び出す処理を定義**
    - **直接呼び出すことはできない**ので、後述する`preload.js`を経由して呼び出します。

```javascript
/** ボタンイベント */
document.querySelector("#btnPDFOutput").addEventListener("click", () => {
    outputPDF();
});

/** preload.jsを経由してmain.jsの関数を呼び出す関数 */
async function outputPDF() {
    const result = await window.myApp.outputPDF();
    console.log(result);
};
```

#### preload.js

**メインプロセス（main.js）とレンダラープロセス（renderer.js）の橋渡し**を行うファイルです。

このファイルによって、`main.js`で定義した`ipcMain.handle("outputPDF", outputPDF);`を**レンダラープロセス（renderer.js）から呼び出すことができます**。

```javascript
const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld("myApp", {
  async outputPDF() {
    const result = await ipcRenderer.invoke("outputPDF");
    return result;
  },
});
```

#### topdf.html

**出力するPDFの内容**となるファイルです。

**特に内容に意味はありませんので、そのままコピー**してください。

```html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Export PDF</title>
</head>
<body>
    <h1>Export PDF</h1>
    <svg width="400" height="400" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
        <!-- Penguin's body -->
        <ellipse cx="100" cy="160" rx="80" ry="90" style="fill:#133463" />
    
        <!-- Penguin's head -->
        <ellipse cx="100" cy="85" rx="60" ry="40" style="fill:#133463" />
    
        <!-- Penguin's belly -->
        <ellipse cx="100" cy="175" rx="60" ry="65" style="fill:white" />
    
        <!-- Penguin's eyes -->
        <circle cx="80" cy="75" r="10" style="fill:white" />
        <circle cx="120" cy="75" r="10" style="fill:white" />
    
        <!-- Penguin's pupils -->
        <circle cx="80" cy="75" r="5" style="fill:black" />
        <circle cx="120" cy="75" r="5" style="fill:black" />
    
        <!-- Penguin's beak -->
        <polygon points="100,105 90,95 110,95" style="fill:orange" />
    
        <!-- Penguin's feet -->
        <polygon points="70,255 60,275 80,275" style="fill:orange" />
        <polygon points="130,255 120,275 140,275" style="fill:orange" />
    
        <!-- Penguin's left arm -->
        <polygon points="20,140 60,110 60,170" style="fill:#133463" />
    
        <!-- Pencil in right hand -->
        <rect x="140" y="75" width="20" height="190" style="fill:green" />
    
        <!-- Pencil tip wood -->
        <polygon points="140,75 160,75 150,60" style="fill:#DEB887" />
    
        <!-- Pencil tip graphite -->
        <polygon points="148,65 152,65 150,60" style="fill:black" />
    
        <!-- Penguin's right arm, covering pencil -->
        <polygon points="180,140 140,110 140,170" style="fill:#133463" />
    </svg>    
</body>
</html>
```

## 起動

### 1. 起動コマンド実行

上記のファイルが整いましたら、Electronアプリケーションを起動します。

**プロジェクトフォルダ（electron-app）で、次のコマンドを実行しましょう**。

```shell
electron .
```

次のようなウィンドウが表示されれば起動完了です。

![qiita-electron-html-to-pdf_01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2457409/b9e3155d-d149-86fd-fcf9-46b408be0d3a.png)


### 2. PDF出力

**PDF出力**ボタンを押すとデスクトップ上に`output-html-topdf.pdf`というファイルが出力されます。

![qiita-electron-html-to-pdf_02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2457409/b0c518b0-a427-fe7d-12ef-348f9c50cc4a.png)

開いて、中身が`topdf.html`で定義したものとなっていれば完了です。

![qiita-electron-html-to-pdf_03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2457409/f9e9edb3-7849-51c7-6d89-4a15033743d6.png)


お疲れ様でした。

## 関連記事

https://qiita.com/maru_dev/items/90278de9290fe981d62c