Webux Lab - Blog
Webux Lab Logo

Webux Lab

By Studio Webux

Search

By Tommy Gingras

Last update 2023-10-16

TauriTypescriptMarkdownPDF

Markdown to PDF in tauri app

I'm using this approach in my note application to print markdown document using tauri with a NodeJS sidecar.

Steps

  1. Create a sidecar
  2. Build the sidecar
  3. Setup everything

I will only cover the sidecar part.

Step One - Create the Sidecar

From the root of the project,

mkdir -p server/{scripts,src}
cd server

npm init -y

Create the script to build the binary:

scripts/build-server.mjs

import execa from "execa";
import fs from "node:fs";
import { oraPromise } from "ora";
import * as esbuild from "esbuild";
import alias from "esbuild-plugin-alias";
import path from "node:path";

/**
 * This function is used to rename the binary with the platform specific postfix.
 * When `tauri build` is ran, it looks for the binary name appended with the platform specific postfix.
 */

async function moveBinaries() {
  let extension = "";

  if (process.platform === "win32") {
    extension = ".exe";
  }

  const rustInfo = (await execa("rustc", ["-vV"])).stdout;
  const targetTriple = /host: (\S+)/g.exec(rustInfo)[1];

  if (!targetTriple) {
    console.error("Failed to determine platform target triple");
  }

  fs.renameSync(
    `../src-tauri/binaries/app${extension}`,
    `../src-tauri/binaries/app-${targetTriple}${extension}`
  );
}

/**
 * This function is used to create bundle for server. `Pkg` is not supporting es module resolution
 * that we need to create typesafety within tRPC
 */
async function createBundle() {
  await esbuild.build({
    entryPoints: ["./src/index.ts"],
    bundle: true,
    outfile: "./dist/server.js",
    platform: "node",
    external: ["highlight.js"],
    plugins: [
      alias({
        "yargs/yargs": path.resolve(
          `node_modules/@puppeteer/browsers/node_modules/yargs/yargs.mjs`
        ),
      }),
    ],
  });

  fs.copyFileSync("node_modules/vm2/lib/bridge.js", "dist/bridge.js");
  fs.copyFileSync(
    "node_modules/vm2/lib/setup-sandbox.js",
    "dist/setup-sandbox.js"
  );
  fs.copyFileSync("node_modules/md-to-pdf/markdown.css", "dist/markdown.css");
}

/**
 * This function is used to create single executable from server file and nodejs
 */
async function createServerPackage() {
  return execa("node_modules/.bin/pkg", [
    "package.json",
    "--public",
    "--debug",
    "--output",
    "../src-tauri/binaries/app",
  ]);
}

async function main() {
  try {
    await createBundle();
    await createServerPackage();
    await moveBinaries();
  } catch (e) {
    throw e;
  }
}

oraPromise(main, {
  text: "Building server...\n",
  successText: "Done\n",
  failText: "Cannot build server",
});

This script contains everything needed to build and package the requirements in order to get a working sidecar.

Create the Server to interact with puppeteer:

src/index.ts

import cors from "cors";
import express, { Application, Request, Response } from "express";
import { mdToPdf } from "md-to-pdf";
import path from "path";
import bodyParser from "body-parser";

const app: Application = express();
const PORT: number = 3000;

app.use(cors());
app.use(bodyParser.json({ limit: "30mb" }));

app.use("/", async (req: Request, res: Response): Promise<void> => {
  try {
    const pdf = await mdToPdf(
      { content: req.body.content },
      {
        launch_options: { headless: "new" },
        port: 3333,
        stylesheet: [path.join(__dirname, "markdown.css")],
      }
    );
    const buffer = pdf.content.buffer;

    res.status(200).json({
      message: buffer.byteLength,
      payload: Buffer.from(buffer).toString("base64"),
    });
  } catch (e) {
    res.status(500).json({ message: e.message });
  }
});

app.listen(PORT, (): void => {
  console.log("SERVER IS UP ON PORT:", PORT);
});

This server listens on port 3333 and receive a Markdown document has input. Then using md-to-pdf it generates a PDF Document and send it backs to the frontend using base64 format.


The package.json:

Notice the specific versions.

{
  "name": "sidecar",
  "private": true,
  "version": "1.0.0",
  "author": "Studio Webux",
  "description": "Sidecar to process markdown to PDF Easily",
  "type": "module",
  "bin": "./dist/server.js",
  "scripts": {
    "server:build": "node ./scripts/build-server.mjs"
  },
  "devDependencies": {
    "esbuild-plugin-alias": "^0.2.1",
    "esbuild": "0.14.54",
    "execa": "5.1.1",
    "ora": "6.1.2",
    "pkg": "5.8.0"
  },
  "dependencies": {
    "body-parser": "^1.20.2",
    "cors": "^2.8.5",
    "express": "^4.18.2",
    "md-to-pdf": "^5.2.4"
  },
  "pkg": {
    "assets": [
      "dist/bridge.js",
      "dist/setup-sandbox.js",
      "dist/markdown.css",
      "node_modules/highlight.js/**"
    ],
    "outputPath": "dist"
  }
}

This is the only solution I was able to get a working environment with. (I didn't test Windows, because it simply does not work at all, the issue I have is the shortcuts don't work, there is a github issue opened about it for a while now.)


Step 2 - Build the Sidecar

npm run server:build

This command will build and generate a NodeJS binary containing everything needed to run the server.

The binary file is saved inside src-tauri/binaries/app-MACHINE_ARCH. The suffix of the file name is quite important. You will have to build that binary for all distribution.

I'm using Github actions to achieve a working environment and a matrice to build for all OSes. You can use the official tauri documentation to make it work. I'll review my setup and probably post another article with the pipeline.

===

Step 3 - Setup Everything

To use this binary in tauri.

  1. Edit the src-tauri/tauri.conf.json
{
  // ...
  "tauri": {
    "allowlist": {
      "shell": {
        "all": false,
        "open": true,
        "sidecar": true,
        "scope": [
          {
            "name": "binaries/app",
            "sidecar": true
          }
        ]
      }
    },
    "bundle": {
      // ...
      "externalBin": ["binaries/app"]
      // ...
    }
  }
  // ...
}
  1. To use the server in the frontend

Snippet of the code I use to achieve this:

import { writeBinaryFile } from "@tauri-apps/api/fs";
import { TauriEvent, listen } from "@tauri-apps/api/event";
import { documentDir, join, sep } from "@tauri-apps/api/path";
import { Child, Command } from "@tauri-apps/api/shell";
import { Body, fetch as tauriFetch } from "@tauri-apps/api/http";

export async function saveBinaryFile(
  filepath = "",
  content: ArrayBuffer
): Promise<void> {
  const path = await normalize(filepath);
  return writeBinaryFile(path, content, { dir: BaseDirectory.Document });
}

export function base64ToArrayBuffer(base64: string): ArrayBuffer {
  if (!base64) throw new Error("Received an empty base64");
  var binaryString = atob(base64);
  var bytes = new Uint8Array(binaryString.length);
  for (var i = 0; i < binaryString.length; i++) {
    bytes[i] = binaryString.charCodeAt(i);
  }
  return bytes.buffer;
}

// ----

const event = new CustomEvent("export", { detail: { status: "Starting" } });
document.dispatchEvent(event);
let child: Child | undefined = undefined;

try {
  /**
   * Running NodeJS process as a sidecar
   */
  const cmd = Command.sidecar("binaries/app");

  child = await cmd.spawn();
  listen(TauriEvent.WINDOW_DESTROYED, function () {
    child?.kill();
  });

  cmd.on("error", (err) => {
    child?.kill();
    console.error(err);
    return resolve(err);
  });
  cmd.stderr.on("data", (data) => {
    child?.kill();
    return resolve(data);
  });

  // Waiting for the server to be ready
  cmd.stdout.on("data", async (d) => {
    if (d === "SERVER IS UP ON PORT: 3000") {
      try {
        event.detail.status = "Preparing Images";
        document.dispatchEvent(event);

        // Your functions to prepare the markdown `content`
        let content = "# Hello World";

        let res: { data: { message: string; payload: string } };

        // Call the express endpoint
        res = await tauriFetch("http://localhost:3000", {
          method: "POST",
          body: Body.json({ content }),
        });

        const filepathToSavePDF = await join(
          "path_to_save_the_pdf",
          `${filename}.pdf`
        );

        await saveBinaryFile(
          filepathToSavePDF,
          base64ToArrayBuffer(res.data.payload as string)
        );

        event.detail.status = "File Saved";
        document.dispatchEvent(event);

        await child?.kill();
        return resolve(true);
      } catch (e: any) {
        await child?.kill();
        return resolve(e.message);
      }
    }
  });
} catch (e: any) {
  console.error("ERROR", e);
  event.detail.status = e.message;
  document.dispatchEvent(event);
}

these snippets are used to notify the client about was is going on.

event.detail.status = "File Saved";
document.dispatchEvent(event);

The code waits to get the server up and running, then get the content, send it to the backend and save the base64 content in a local file.

You might have issues when saving, be sure to use a valid directory and that tauri allows everything.

Something like that is required to access the Documents directory:

{
  // ...
  "tauri": {
    "allowlist": {
      "fs": {
        "readFile": true,
        "writeFile": true,
        "readDir": true,
        "createDir": true,
        "exists": true,
        "scope": ["$DOCUMENT/**", "$DOCUMENT/*", "$DOCUMENT"]
      }
    }
  }
  // ...
}

That's pretty much it !

Hopes it gives some guidances about how to use NodeJS Sidecars with tauri. I did similar thing using puppeteer and AWS SDK. The idea is always the same.