GROWIでPDFエクスポートを実現するプラグインを作った

[オープンソースのWikiであるGROWI](https://growi.org/ja/)にはプラグイン機能が用意されています。自社のデータを表示したり、表示をカスタマイズするのに利用できます。

今回は、GROWIプラグインとしてPDFエクスポート機能を追加するプラグインを作りました。PDFエクスポートは面倒だと思っていたのですが、予想よりも手軽に実現できるようになっています。

## プラグインの動作

Markdownは以下のように記述します。Remark Directiveを利用しています。

```markdown
::pdf
```

この記述があると、画面右上に丸いボタンが表示されます。このボタンはエディタでも表示されますが、機能しません。ページ表示時のみ機能します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/f1f54169-2092-2fda-efcf-d2e6c16cde25.png)

## コードについて

コードは[goofmint/growi\-plugin\-pdf\-export](https://github.com/goofmint/growi-plugin-pdf-export)にて公開しています。ライセンスはMIT Licenseになります。

## プラグインを追加する

利用する際には、GROWIの管理画面の `プラグイン` にて追加してください。URLは `https://github.com/goofmint/growi-plugin-pdf-export` です。

![Admin](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/f5b3e2d3-ccf3-d536-d59e-7301ea1b2f7e.jpeg)

## コードについて

このプラグインでは、Remark Directiveを使って `::pdf` をボタン（実際にはaタグ）にし、その後コンポーネントでPDFをエクスポートする処理を行っています。

```tsx
const activate = (): void => {
  if (growiFacade == null || growiFacade.markdownRenderer == null) {
    return;
  }
  const { optionsGenerators } = growiFacade.markdownRenderer;
  const originalCustomViewOptions = optionsGenerators.customGenerateViewOptions;
  optionsGenerators.customGenerateViewOptions = (...args) => {
    const options = originalCustomViewOptions ? originalCustomViewOptions(...args) : optionsGenerators.generateViewOptions(...args);
    // Remark Directiveをボタンに変換する処理
    options.remarkPlugins.push(remarkPlugin as any);
    // PDFをエクスポートする処理
    const { a } = options.components;
    options.components.a = pdfExport(a);
    return options;
  };

  // For preview
  const originalGeneratePreviewOptions = optionsGenerators.customGeneratePreviewOptions;
  optionsGenerators.customGeneratePreviewOptions = (...args) => {
    const preview = originalGeneratePreviewOptions ? originalGeneratePreviewOptions(...args) : optionsGenerators.generatePreviewOptions(...args);
    preview.remarkPlugins.push(remarkPlugin as any);
    const { a } = preview.components;
    preview.components.a = pdfExport(a);
    return preview;
  };
};
```

### Remark Directive版の処理

Remark Directiveでは、画面上部にボタンを標示する処理を行います。 `hChildren` にNodeを追加します。

```tsx
export const remarkPlugin: Plugin = () => {
  return (tree: Node) => {
    visit(tree, 'leafDirective', (node: Node) => {
      const n = node as unknown as GrowiNode;
      if (n.name !== 'pdf') return;
      const data = n.data || (n.data = {});
      // add float button to the right top
      data.hChildren = [
        {
          tagName: 'div',
          type: 'element',
          properties: { className: 'pdf-export-float-button' },
          children: [
            {
              tagName: 'a',
              type: 'element',
              properties: { className: 'material-symbols-outlined me-1 grw-page-control-dropdown-icon pdf-export' },
              children: [
                { type: 'text', value: 'cloud_download' },
              ],
            },
          ],
        },
      ];
    });
  };
};
```

つまり、この処理では以下のDOMを追加しています。

```html
<div class="pdf-export-float-button">
  <a class="material-symbols-outlined me-1 grw-page-control-dropdown-icon pdf-export">
    cloud_download
  </a>
</div>
```

### コンポーネントとしての処理

続いてコンポーネントでは、 `a.pdf-export` に対してクリック処理を追加します。 

```jsx
export const pdfExport = (Tag: React.FunctionComponent<any>): React.FunctionComponent<any> => {
  return ({ children, ...props }) => {
    try {
      // pdf-exportクラスがない場合は、通常のaタグとして処理
      if (!props.className.split(' ').includes('pdf-export')) {
        return (<a {...props}>{children}</a>);
      }
      // クリックイベント
      const onClick = async(e: MouseEvent) => {
      };
      // クリックイベントを追加して返す
      return (
        <a {...props} onClick={onClick}>{children}</a>
      );
    }
    catch (err) {
      // console.error(err);
    }
    // Return the original component if an error occurs
    return (
      <a {...props}>{children}</a>
    );
  };
};
```

### ボタンをクリックした際の処理

以下は `onClick` 関数の内容です。実装は[DOMをキャプチャしてPDFで保存する方法 \#JavaScript \- Qiita](https://qiita.com/mori_goq/items/8ed0f600c487dfbcceb2)を参考にして、複数ページに対応しています。

GROWIの表示内容は `.wiki` 内にあるので、その内容を dataURI に変換します。また、ページのタイトルを取得して、ファイル名にします。

```jsx
e.preventDefault();
const title = document.title.replace(/ - .*/, '');
const element = document.querySelector('.wiki');
if (!element) {
  return;
}
// blob形式に変換
const blob = await toBlob(element as any as HTMLElement);
if (!blob) {
  return;
}
const dataUrl = await readBlobAsDataURL(blob);
```

続いてjsPDF向けの変数を用意します。

```jsx
// jsPDFのインスタンスを生成
const pdf = new JSPDF({
  orientation: 'p',
  unit: 'px',
  format: 'a4',
  compress: true,
});
// PDFの幅と高さ、DOMに合わせたスケールを計算
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
const scale = pdfWidth / element.clientWidth;
const scaledWidth = element.clientWidth * scale;
const scaledHeight = element.clientHeight * scale;
// 背景色を取得
const computedStyle = window.getComputedStyle(document.body);
const backgroundColor = computedStyle.backgroundColor.match(/rgb\(([0-9]{1,3}), ([0-9]{1,3}), ([0-9]{1,3})\)/);
```

そして、PDFを生成します。HTMLの内容がそのままPDFになるので、PDF側でも同じ背景色にしないとテキストが読みにくくなります。そのため、ドキュメントの背景色を取得して、PDFの背景色に設定しています。

```jsx
// 標示に利用するページ数を計算する
const pages = Math.ceil(scaledHeight / pdfHeight);
// ページ数分のPDFを生成
for (let i = 0; i < pages; i++) {
  // 描画するページを設定
  pdf.setPage(i + 1);
  // 背景色を設定
  pdf.setFillColor(parseInt(backgroundColor![1]), parseInt(backgroundColor![2]), parseInt(backgroundColor![3]));
  pdf.rect(0, 0, scaledWidth, scaledHeight, 'F');
  // 描画
  pdf.addImage(
    dataUrl,
    'JPEG',
    0,
    -pdfHeight * i,
    scaledWidth,
    scaledHeight,
  );
  // 次のページを追加
  pdf.addPage();
}
// 最後のページは不要なので削除
pdf.deletePage(pdf.getNumberOfPages());
// ファイル名を設定して保存（ダウンロード）
pdf.save(`${title === '/' ? 'Root' : title}.pdf`);
```

画像なども埋め込まれており、ページの内容がほぼそのままPDFとして取得できます。しかもJPEGとして追加しているのに、透明テキストまで埋め込まれます。jsPDF、すごい！

![pdf.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/887e5649-5f0b-0fe6-ae67-8ae86ab5f04a.png)

## GROWIコミュニティについて

プラグインの使い方や要望などがあれば、ぜひGROWIコミュニティにお寄せください。実現できそうなものがあれば、なるべく対応します。他にもヘルプチャンネルなどもありますので、ぜひ参加してください！

[GROWI Slackへの参加はこちらから](https://communityinviter.com/apps/wsgrowi/invite)

## まとめ

GROWIプラグインを使うと、表示を自由に拡張できます。足りない機能があれば、どんどん追加できます。ぜひ、自分のWikiをカスタマイズしましょう。

[OSS開発wikiツールのGROWI | 快適な情報共有を、全ての人へ](https://growi.org/ja/)