How to build a PDF generator with Baserow and Vue

Banner image for article on how to build Baserow PDF Generator

This tutorial will guide you through building an application using Baserow as its foundation. Baserow is an open-source platform that empowers you to build scalable databases and applications without code.

What we’ll do

We will develop a PDF generator to easily convert your Baserow table data into a PDF file. This can be useful for sharing information with others in a clear and formatted way, or for keeping a record of your data offline.

Users input their Baserow API token generated in settings and table ID into the application. The application then sends an API request to retrieve each row of table data and generates a PDF file within the browser. As a result, the application does not transmit any sensitive data to other domains and does not require backend services for operation. Essentially, Baserow serves as our backend.

The interface will look like a piece of A4 paper. You can add, move, and change the size of different parts by dragging them around. This will show you how your PDF will look in the end. You’ll also be able to change the width of text boxes and adjust things like the size of the font, how far apart the lines are, and the color of the text.

Tools we’ll use

Prerequisites

To complete this tutorial, you’ll need the following:

  • Baserow account
  • NodeJS (the latest version is preferred)
  • Code editor of your choice

Getting started

Our tutorial can be broken down into three main steps:

  1. Connecting to the Baserow database and retrieving data
  2. Creating a canvas for field positioning and modifying its parameters
  3. Generating PDF files

Step 1. Authentication

1.1 Initialize the application

We’ll be using Vue.js as the main framework for our app. Firstly, we need to initialize our project using the official Vue project scaffolding tool.

  1. Ensure you have one of the latest versions of Node.js installed. Navigate to the directory where you plan to create your project and run the following command:

    npm create vue@latest baserow-pdf-generator
    

    Here, ‘baserow-pdf-generator’ is the name of our project, but you can use any name you prefer.

  2. You will be asked about several optional features that could be included in your project. You can select ‘No’ for almost each of them or choose whatever you like. I personally always choose ESLint for code quality, as I like my code to look nice and clean. Also, I’m going to use Pinia for state management. Again, both of them are not necessary, especially in such a small project. But I like to use them to keep all my applications in the same style.

  3. Navigate to a new project directory, install the necessary dependencies, and start the project. Execute each command in your terminal one by one:

    cd baserow-pdf-generator
    npm i
    npm run dev
    
  4. Our application is now up and running. You can access it in your browser by visiting the URL displayed in the terminal, typically it’s http://localhost:5173/

  5. You will see the Vue.js welcome page, which we need to clean up a bit. Open the project folder in any code editor and delete all files within the /components folder. Next, open the App.vue file and leave the script and template tags empty.

1.2 Authentification component

The first step that users of our application will see is the Authentication form. Since we need to fetch data from the Baserow database, users should have the ability to specify the table ID from which they want to generate PDFs. Another crucial piece of data is the Baserow API token – the primary “key” to access our data from Baserow. That’s it – our authentication form will consist of only two fields.

Let’s create a separate file for our form component, I’ll call it Token.vue. Import it in App.vue and call it in the <template> tag.

To make our app a bit more stylish, I’m going to use Vuetify– an open-source UI library for Vue. You can do the same – just install it by running npm i vuetify. Also, I’m using Vuelidate to easily validate our form. Both of these packages are optional.

Here’s how the TokenView.vue template tag looks like after adding a form with inputs:

<template>
  <form>
    <v-container>
      <v-row>
        <v-col cols="12" md="5">
          <v-text-field
            v-model="formState.apiKey"
            :error-messages="v$.apiKey.$errors.map((e) => e.$message)"
            label="API key"
            type="password"
            required
            @input="v$.apiKey.$touch"
            @blur="v$.apiKey.$touch"
          />
        </v-col>

        <v-col cols="12" md="7" class="d-flex ga-5">
          <v-text-field
            v-model="formState.tableId"
            :error-messages="v$.tableId.$errors.map((e) => e.$message)"
            label="Table ID"
            required
            @input="v$.tableId.$touch"
            @blur="v$.tableId.$touch"
          />

          <v-btn size="x-large" color="indigo-darken-2" @click="set">Connect</v-btn>
          <v-btn size="x-large" variant="outlined" color="danger" @click="clear">Clear</v-btn>
        </v-col>
      </v-row>
    </v-container>
  </form>
</template>

Let’s go to <script> now. First, let’s add the initial state and rules for validators:

<script setup>
	const initialState = {
	  apiKey: '',
	  tableId: ''
	}
	
	const formState = reactive({
	  ...initialState
	})
	
	const rules = {
	  apiKey: { required },
	  tableId: { required, numeric }
	}
	
	const v$ = useVuelidate(rules, formState)
</script>

The methods:

  • The Clear method is needed to clear inputs from filled symbols and reset errors.
  • The Set method checks if fields are valid. If yes, it calls the fetchData method that fetches our first data portion from Baserow – an array of field names.

To make working with all the data in our application a lot easier, I’m going to use the Pinia store (as was specified in 1.1).

After creating the store file, we can describe the method for field fetching:

    async getFields(formData) {
      try {
        this.error = null
        this.loading = true

        const response = await getFields(formData)

        this.fields = response
      } catch (error) {
        this.error = error.message
      } finally {
        this.loading = false
      }
    }

…and the getFields API method inside data.api.js:

async function getFields(credentials) {
  const apiKey = credentials?.apiKey
  const tableId = credentials?.tableId

  const response = await fetch(`${BASE_URL}fields/table/${tableId}/`, getHeaders(apiKey))

  const data = await response.json()
  if (data.error) {
    throw new Error(data.detail)
  }

  return data
}

As you can see, we use the default method fetch() to request data.

We can store credentials in the browser’s memory to avoid forcing users to enter them each time the application page is loaded. One possible solution is to use the browser’s cookies. I’m using the vue-cookies library for this.

After setting them in our app, we can utilize this library in our TokenView:

const saveCredentials = () => {
  if (!dataStore.error) {
    $cookies.set('credentials', formState, 0)
  }
}

const getCredentialsFromStore = () => {
  const credentials = $cookies.get('credentials')

  if (credentials) {
    formState.apiKey = credentials?.apiKey
    formState.tableId = credentials?.tableId

    return true
  }
}

onMounted(() => {
  if (getCredentialsFromStore()) {
    fetchData(formState)
  }
})

The saveCredentials method will save the inserted data in cookies, and getCredentialsFromStore will retrieve them from cookies immediately after the page is loaded (thanks to the onMounted hook).

That’s all for authentication.

Step 2. Canvas

Our next step is to create a canvas. This is the main visual part of our application, allowing users to drag field names onto it and modify their font properties.

2.1 The main view

Let’s begin by creating the main view file, MainView.vue. This can be immediately imported and used in App.vue right after <TokenView />.

MainView will consist of two parts: a Canvas and a side block, which includes a list of fields and a large green button for generating PDF files.

<template>
  <v-container>
    <v-row>
      <FieldConfigure v-if="dataStore.configurableId" />
    </v-row>
    <v-row>
      <v-col>
        <CanvasView ref="canvas" />
      </v-col>
      <v-col cols="3">
        <v-btn
          class="mb-2"
          color="success"
          size="x-large"
          block
          @click="generatePdf"
        >
          Generate PDF
        </v-btn>
        <FieldsView />
      </v-col>
    </v-row>
  </v-container>
</template>

As stated above, the Fields view is simply a list of fields that were fetched earlier. This component is as straightforward as it sounds:

<script setup>
import { computed } from 'vue'
import { useDataStore } from '@/stores/dataStore'
import FieldItem from '@/components/FieldItem.vue'

const dataStore = useDataStore()

const fields = computed(() => {
  return dataStore.fields
})
</script>

<template>
  <v-list>
    <FieldItem v-for="field in fields" :key="field.id" :field="field" />
  </v-list>
</template>

2.2 Draggable item

The CanvasView component is slightly more complex. I use the vue-draggable-resizable library to make our fields appear as resizable elements. While this library was designed for other purposes, its functionality is suitable for our requirements. We’ll create this draggable element as a separate component. The final file will look like this:

<script setup>
import { ref, onMounted, watch, nextTick } from 'vue'
import VueDraggableResizable from 'vue-draggable-resizable'
import { useDataStore } from '@/stores/dataStore'

const dataStore = useDataStore()

const props = defineProps({
  index: Number,
  options: Object
})

const element = ref()

const onDragStop = (x, y) => {
  emitUpdateField({ x, y })
}
const onResizeStop = (x, y, width, height) => {
  emitUpdateField({ x, y, width, height })
}

const emitUpdateField = async (values) => {
  await dataStore.draggableFields.splice(props.index, 1, { ...props.options, ...values })
}

const onActivated = () => {
  dataStore.setConfigurableId(props.options.id)
}

onMounted(() => {
  nextTick(() => {
    emitUpdateField({
      width: element.value.width,
      height: element.value.height
    })
  })
})
</script>

<template>
  <vue-draggable-resizable
    ref="element"
    :style="{
      fontSize: options.fontSize + 'px',
      color: options.color
    }"
    :parent="true"
    :x="options.x"
    :y="options.y"
    :w="options.width"
    :h="options.height"
    :grid="[options.grid, options.grid]"
    @dragStop="onDragStop"
    @resizeStop="onResizeStop"
    @activated="onActivated"
  >
    <span ref="elementInner" class="no-select">{{ options.title }}</span>
  </vue-draggable-resizable>
</template>

<style>
.no-select {
  -webkit-user-select: none;
  -ms-user-select: none;
  user-select: none;
}
</style>

Once a field is changed, its properties will be saved into the store by the emitUpdateField function.

As you can see, we used vue-draggable-resizable as our root and only element. Most of the parameters for it are passed as options props from the parent component (CanvasView). As soon as we interact with the element (@dragStop, @resizeStop) and change its values (width/height/position by x/y axis), it passes them back to the parent component (emitUpdateField). The last thing is “setting” in the dataStore which element we are interacting with at this moment (onActivated). We will need to add a new action to our data store for this:

setConfigurableId(id) {
  this.configurableId = id
}

2.3 Fields configuration

FieldConfigure.vue is a component consisting of a set of inputs. These inputs allow you to adjust properties of fields such as font size, line height, and more.

As mentioned, it’s simply a set of inputs, so the template will look like this:

<template>
  <v-col class="inputs-row d-flex gc-2">
    <v-text-field
      v-model="fontSize"
      label="Font Size"
      type="number"
      density="compact"
      dense
      outlined
      hide-spin-buttons
      hide-details
    >
      <template #prepend-inner>
        <v-btn icon variant="plain" size="x-small" @click="changeFontSize(-1)">
          <v-icon>mdi-minus</v-icon>
        </v-btn>
      </template>
      <template #append-inner>
        <v-btn icon variant="plain" size="x-small" @click="changeFontSize(1)">
          <v-icon>mdi-plus</v-icon>
        </v-btn>
      </template>
    </v-text-field>

    <v-text-field
      v-model="lineHeight"
      label="Line Height"
      type="number"
      density="compact"
      dense
      outlined
      hide-spin-buttons
      hide-details
    >
      <template #prepend-inner>
        <v-btn icon variant="plain" size="x-small" @click="changeLineHeight(-1)">
          <v-icon>mdi-minus</v-icon>
        </v-btn>
      </template>
      <template #append-inner>
        <v-btn icon variant="plain" size="x-small" @click="changeLineHeight(1)">
          <v-icon>mdi-plus</v-icon>
        </v-btn>
      </template>
    </v-text-field>
  </v-col>
</template>

We need the ID of the field that we are going to modify. Here, the configurableId parameter that we saved earlier is useful. Once any parameter of any field is changed, it will also be updated in the store.

const activeFieldIndex = computed(() =>
  dataStore.draggableFields.findIndex((field) => field.id === dataStore.configurableId)
)
let activeField = dataStore.draggableFields[activeFieldIndex.value]

const fontSize = ref()
const changeFontSize = (diff) => {
  fontSize.value = Number(fontSize.value) + diff
}
watch(fontSize, (newValue) => {
  dataStore.draggableFields[activeFieldIndex.value].fontSize = Number(newValue)
})

const lineHeight = ref()
const changeLineHeight = (diff) => {
  lineHeight.value = Number(lineHeight.value) + diff
}
watch(lineHeight, (newValue) => {
  dataStore.draggableFields[activeFieldIndex.value].lineHeight = Number(newValue)
})

const setInitialValues = () => {
  fontSize.value = activeField.fontSize
  lineHeight.value = activeField.lineHeight
}

2.4 Canvas view

Lastly, let’s focus on our main component CanvasView.vue. The first thing we need to do is generate an array of selected fields. To achieve this, add a method inside FieldItem.vue that sets the clicked item into the store:

...
const toggleSelection = () => {
  dataStore.toggleSelection(props.field.id)
}
...
@click="toggleSelection"
...

Now, we can iterate through the array of selected items inside the Canvas component:

<template>
  <div class="canvas bg-grey-lighten-2">
    <v-responsive
      class="canvas__inner"
      :style="{
        backgroundSize: '20px 20px, 20px 20px'
      }"
      :aspect-ratio="9 / 16"
    >
      <DraggableItem
        v-for="(field, index) in selectedFields"
        :key="index"
        :index="index"
        :options="field"
      />
    </v-responsive>
  </div>
</template>

<style>
@import 'vue-draggable-resizable/style.css';

.canvas {
  height: 842px; /* PDF's height and width */
  width: 595px;
  margin: auto;
}

.canvas__inner {
  font-family: Courier;
  background: linear-gradient(-90deg, rgba(0, 0, 0, 0.1) 1px, transparent 1px),
    linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px);
}

.draggable {
  cursor: grab;
}
</style>

Here, we’ve specified the aspect ratio of an A4 letter. However, you can set any format you prefer, or even allow the user to change it via input or multi select option.

The main method in the Canvas component generates a PDF, which we expose to the parent component. This way, we can call it by pressing a button from outside of the Canvas:

defineExpose({ generatePdf })

The method itself will also combine the third part of this tutorial, which is called:

Step 3. Generate PDF files

For file generation, we will take each row of data from the Baserow table, replace the fields with the actual values behind those field names, and assign predefined styles to those values.

Install the pdf-lib library to easily generate PDFs from HTML. To emulate this HTML, we can insert a root tag into the template and “hide” it from the user by adding a few lines of CSS:

<div ref="hidden" class="hidden"></div>

...

.hidden {
  position: absolute;
  top: -9999px;
  left: -9999px;
}

Finally, everything is ready for our main and final method:

const generatePdf = async () => {
  const rows = dataStore.rows

  rows.forEach(async (row) => {
    // Create PDF for each row
    const pdfDoc = await PDFDocument.create()
    const font = await pdfDoc.embedFont(StandardFonts.Courier)
    const page = pdfDoc.addPage()

    selectedFields.value.forEach(async (field) => {
      const { x, y, width, fontSize, lineHeight } = field
      const { height } = page.getSize()

      const rowText = row['field_' + field.id]

      // Emulate htmlElement to calculate block's height and split text into rows
      const textElement = document.createElement('p')

      textElement.style.fontFamily = 'Courier'
      textElement.style.display = 'inline-block'
      textElement.style.width = width + 'px'
      textElement.style.fontSize = fontSize + 'px'
      textElement.style.lineHeight = lineHeight + 'px'
      textElement.textContent = rowText

      hidden.value.append(textElement)
      textElement.innerHTML = wrapWords(textElement)

      const textLines = getLines(textElement)

      page.drawText(textLines, {
        x,
        y: height - y - fontSize + fontSize,
        lineHeight: lineHeight - lineHeight,
        size: fontSize,
        font
      })

      // Clear hidden block with temporary text
      hidden.value.textContent = ''
    })

    const pdfBytes = await pdfDoc.save()

    download(pdfBytes, `Baserow_${$cookies.get('credentials').tableId}_${row.id}.pdf`)
  })
}

As you can see, we are going through each row of data that was fetched and saved in dataStore. Then, for each row, we create a PDF document using PDFDocument.create(). After this, we are adding each selected field to the PDF, but style them in the emulated HTML. Finally, I’m using the downloadjs library to download the generated PDF file!

Of course, the application can be improved with a more fancy UI and cool new features such as changing not only the font size and line height but also the text style, font family, background color, etc., adding the ability to choose how to generate PDFs: each document for each row or put each row as a separate page inside a single file, adding multipage functionality, and so on.

The full source code of the application and a working demo can be found on GitHub.

Other useful resources

The following articles may also be helpful:

In case you’ve run into an issue while following this tutorial, feel free to reach out to ask for help in the Baserow community.