4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

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

プラグインの動作

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

::pdf

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

image.png

コードについて

コードはgoofmint/growi-plugin-pdf-exportにて公開しています。ライセンスはMIT Licenseになります。

プラグインを追加する

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

Admin

コードについて

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

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を追加します。

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を追加しています。

<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 に対してクリック処理を追加します。

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を参考にして、複数ページに対応しています。

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

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向けの変数を用意します。

// 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の背景色に設定しています。

// 標示に利用するページ数を計算する
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

GROWIコミュニティについて

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

GROWI Slackへの参加はこちらから

まとめ

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

OSS開発wikiツールのGROWI | 快適な情報共有を、全ての人へ

4
2
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
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?