DEV Community

Atsushi
Atsushi

Posted on

I made a plugin that enables PDF export in GROWI.

GROWI, an open-source Wiki, provides a plugin feature that can be used to display or customize your data.

This time, I created a plugin for GROWI that adds a PDF export feature. Although PDF export can seem tedious, it turned out to be easier to implement than expected.

Plugin Behavior

Markdown is written as follows, using Remark Directive:

::pdf
Enter fullscreen mode Exit fullscreen mode

When this is written, a circular button appears in the upper-right corner of the screen. This button is also visible in the editor but does not function there. It only works when the page is displayed.

image.png

About the Code

The code is available at goofmint/growi-plugin-pdf-export under the MIT License.

Adding the Plugin

To use it, add it in the Plugin section of the GROWI admin panel. The URL is https://github.com/goofmint/growi-plugin-pdf-export.

Admin

About the Code

This plugin uses Remark Directive to convert ::pdf into a button (actually an a tag), and then processes PDF export within the component.

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);
    // Convert Remark Directive into a button
    options.remarkPlugins.push(remarkPlugin as any);
    // Handle PDF export
    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;
  };
};
Enter fullscreen mode Exit fullscreen mode

Remark Directive Processing

Remark Directive handles the process of displaying the button at the top of the screen. A Node is added to hChildren.

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 a floating button to the top-right
      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' },
              ],
            },
          ],
        },
      ];
    });
  };
};
Enter fullscreen mode Exit fullscreen mode

This process adds the following 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>
Enter fullscreen mode Exit fullscreen mode

Component Processing

Next, the component adds click handling to a.pdf-export.

export const pdfExport = (Tag: React.FunctionComponent<any>): React.FunctionComponent<any> => {
  return ({ children, ...props }) => {
    try {
      // If the class is not `pdf-export`, process as a normal `a` tag
      if (!props.className.split(' ').includes('pdf-export')) {
        return (<a {...props}>{children}</a>);
      }
      // Click event
      const onClick = async(e: MouseEvent) => {
      };
      // Add click event and return
      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>
    );
  };
};
Enter fullscreen mode Exit fullscreen mode

Handling Button Clicks

The following is the content of the onClick function. The implementation supports multiple pages, based on How to Capture DOM and Save It as PDF in JavaScript - Qiita.

The GROWI content is located within .wiki. The content is converted to a dataURI, and the page title is used as the filename.

e.preventDefault();
const title = document.title.replace(/ - .*/, '');
const element = document.querySelector('.wiki');
if (!element) {
  return;
}
// Convert to blob format
const blob = await toBlob(element as any as HTMLElement);
if (!blob) {
  return;
}
const dataUrl = await readBlobAsDataURL(blob);
Enter fullscreen mode Exit fullscreen mode

Next, variables for jsPDF are prepared.

// Create an instance of jsPDF
const pdf = new JSPDF({
  orientation: 'p',
  unit: 'px',
  format: 'a4',
  compress: true,
});
// Calculate PDF width, height, and scale to match the 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;
// Get background color
const computedStyle = window.getComputedStyle(document.body);
const backgroundColor = computedStyle.backgroundColor.match(/rgb\(([0-9]{1,3}), ([0-9]{1,3}), ([0-9]{1,3})\)/);
Enter fullscreen mode Exit fullscreen mode

Finally, the PDF is generated. To maintain readability, the document's background color is retrieved and set in the PDF.

// Calculate the number of pages required
const pages = Math.ceil(scaledHeight / pdfHeight);
// Generate PDF for each page
for (let i = 0; i < pages; i++) {
  // Set the page to be drawn
  pdf.setPage(i + 1);
  // Set background color
  pdf.setFillColor(parseInt(backgroundColor![1]), parseInt(backgroundColor![2]), parseInt(backgroundColor![3]));
  pdf.rect(0, 0, scaledWidth, scaledHeight, 'F');
  // Draw
  pdf.addImage(
    dataUrl,
    'JPEG',
    0,
    -pdfHeight * i,
    scaledWidth,
    scaledHeight,
  );
  // Add the next page
  pdf.addPage();
}
// Delete the unnecessary last page
pdf.deletePage(pdf.getNumberOfPages());
// Set the file name and save (download)
pdf.save(`${title === '/' ? 'Root' : title}.pdf`);
Enter fullscreen mode Exit fullscreen mode

Images and other content are embedded, and the page content is almost fully preserved in the PDF. Amazingly, jsPDF even embeds transparent text, even though the content is added as JPEG.

pdf.png

About the GROWI Community

If you have any questions or requests about using this plugin, feel free to reach out to the GROWI community. If feasible, I’ll try to accommodate your requests. There are also help channels available, so be sure to join!

Join GROWI Slack here

Conclusion

With GROWI plugins, you can freely extend the display functionality. If there’s a missing feature, feel free to add it. Customize your Wiki as you like!

OSS Development Wiki Tool GROWI | Comfortable Information Sharing for Everyone

Top comments (0)