Forum Discussion

Phil_Wingfield's avatar
Phil_Wingfield
Community Member
4 months ago

Auto-Generate PDF Certificates in Storyline (No HTML Edits Needed)

UPDATE August 20, 2025: I uploaded the new version of the file I've been using. It adds a layer for the learner to double-check their name, then blocks them from editing the name again if they download the certificate, in order to prevent multiple people from generating the certificate.

 

I recently developed a way to generate a certificate PDF in Storyline without having to edit the published files. That means it works even in Storyline Preview and Review 360, and it's simple enough to use as a team-wide template.

I drew inspiration from Devlin Peck’s tutorial, but updated the code to follow current JavaScript standards and removed the need to modify output files manually. You’ll find the full code and a sample .story file at the bottom of this post. If you like it or have any suggestions for improvement, let me know!

You can also see an example version of it in action here, using a simple certificate template from Canva: https://philwingfield.com/wp-content/certificate-generator/story.html

Table of Contents:

  • What You’ll Need
  • Define Your Storyline Variables
  • Certificate Background Image
  • Changing Orientation to Portrait
  • Placing Text on the Certificate
  • Naming the PDF File
  • Triggering the Script in Storyline
  • Troubleshooting
  • TL;DR
  • Full Code
  • .story File (attached to post)

 

What You’ll Need

  • Storyline (obviously!)
  • A background image for your certificate (.jpg or .png, set to A4 size)

Recommended:

  • Some sort of code reader such as Visual Studio Code
  • Somewhere to upload the certificate file for testing, such as AWS (though there are instructions for local storage below under the Certificate Background Image header)

Define Your Storyline Variables

You’ll need to set up the Storyline variables that will be pulled into the certificate.

Here are the variables I used:

Storyline Variable

Purpose

UserName

Learner’s name (typed in by the user)

CHANGECourseName

Course name (manually set per course)

CHANGECreditHours

Number of credit hours (manually set)

⚠️ Variable names are case-sensitive. userName ≠ UserName.

 

I made this as a template for our team, so the variables beginning with CHANGE are the ones they will change manually when using the template in a course by changing the values of these variables:

 

The current date is handled inside the JavaScript, so you don’t need to create a date variable in Storyline.

 

Here’s how the script pulls in those values:

This line gets us ready to pull the variables from storyline, and the next few lines redefine the Storyline variables as JavaScript variables:

const player = GetPlayer();

 

And then this line pulls the value of the UserName variable from Storyline and stores it in a new JavaScript constant called name (a constant is a type of variable that does not change later)

 

const name = player.GetVar(“UserName”);

 

So then we do that with all the variables:

const player = GetPlayer(); 
const name = player.GetVar("UserName"); 
const course = player.GetVar("CHANGECourseName"); 
const hours = String(player.GetVar("CHANGECreditHours"));

 

If you need to pull in other variables, follow the same pattern.

There is another variable, date, which is defined in the code itself in lines 20-24 and returns the current date. It does not need to be input to Storyline.

You could also pull in quiz data if you like. In Storyline, make a number variable on the last slide (I'll call it score) and make a trigger to set score equal to the internal variable Quiz1.ScorePercent when the timeline starts. Then you could use something like this code after line 10:

const score = player.GetVar("score")+"%";

The +"%" part will append a % sign to the end, so a learner answering 4/5 correctly will display as 80% instead of just 80. Don't forget to tell jsPDF where to draw it during the Placing Text on the Certificate section below!

Certificate Background Image

You’ll need a background image for your certificate — It should be sized as A4 landscape (297mm x 210mm)***. Make sure to leave space for where the name, course name, date, and number of credit hours can go.

Note that jsPDF will compress the background image, so I would recommend doubling the photo size to 594x420mm if you want the higher resolution, or even tripling it. It will look less blurry when it is scaled down.

Here’s an example background you can view (hosted via S3):
📎 certificate_example.png

(Note: I may not maintain this file indefinitely.)

 

To load the image in the code, host it somewhere with public access (like AWS with a direct link), and reference the URL like this on line 2:

const CERT_BG_IMG_URL = "https://yourhost.com/your_image.png";

Alternatively, you can reference an image placed locally in the story_content folder after publishing and reference it as “/story_content/your_image_name.jpg”, but that will not work in Preview or Review 360.

 

***The defaults for a standard 8.5x11" are 216mmx280mm. jsPDF will convert your image to the correct size as defined in line 70, so the certificate may be slightly distorted if you don't use A4 sizing.

Changing Orientation to Portrait

There are 2 steps if you want to change your image to portrait instead of landscape.

  • In line 62: remove orientation: 'landscape' so it is just curly brackets: {}
    • The default for jsPDF is portrait A4, so we only need to define it as landscape if we want it that way.
  • In line 70: change the size of the imported image. Swap the 297 and 210 in that line (the x and y lengths) so it reads (img, 0, 0, 210, 297)
    • This will force whatever image you import to be 210mm wide and 297mm high, so make sure you change line 2 from the URL for my certificate to whatever certificate you have, or else it will be distorted.
    • Make sure that your portrait image is portrait A4 as well. 

🚧 CORS Warning

If you’re hosting the image online, make sure CORS is enabled in the hosting service so it can load locally and in preview mode.

For AWS:
🔗 Enable CORS in Amazon S3

If your PDF shows text but no background image, this may be why. The script is written to fall back and still display text if the image fails to load.

 

Placing Text on the Certificate

You’ll manually position the text using jsPDF’s .text() function. Here's an example:

doc.setFont("times", "bold"); 
doc.setFontSize(30); 
doc.text(name, doc.internal.pageSize.width / 2, 130, { align: 'center' });

.setFont() – sets the font and weight. There are 14 accepted fonts from jsPDF, and you can see the parameters in the image below, taken from the jsPDF documentation:

If you want to import custom fonts, Devlin Peck once again has a good tutorial: https://www.devlinpeck.com/content/jspdf-custom-font

 

.setFontSize() – sets the font size

.text() – 4 fields separated by commas which let you define the text (in this case it is the variable “name,” which was defined earlier in line 14), the x coordinate from the left, the y coordinate from the top, and then the alignment in reference to that coordinate.

Note that the x coordinate in the example above uses a formula to place it in the exact center of the document from left to right. The y coordinate, 130, means it is 130mm from the top.

To determine where your text should go:

  • Open your certificate background in Canva, GIMP, or another editor
  • Use guides or measurement tools to identify the X/Y positions. For example, in this screenshot, I had a line drawn in canva, opened the position editor, and could see the y coordinate at 129.97mm for the end where the name would go. So, it is entered as 130 in the text() function
  • Use those values in the .text() calls in your script

You can also preview your placement by:

  1. Pressing F12 to open the console in preview, review 360, or a published course.
  1. Copying and pasting your entire code into the console. Then you can paste again and quickly adjust the x/y coordinates until you get it fine-tuned. This is also a good way to check for console errors if the download is not happening (copying and pasting the console error message into google is how I discovered the CORS issue from above).

 

Naming the PDF File

Line 54 defines the filename for the downloaded certificate:

doc.save(`${name}_${course}_certificate.pdf`);

The ${} symbols simply allow you to call a variable, in this case name and course.

If you wanted to make it a static name, you could replace that with something like:

doc.save("Course Certificate.pdf");

 

Triggering the Script in Storyline

Use a Storyline trigger like this:

 

 

Make sure this happens after the learner has entered their name, or the UserName variable won’t have any value.

In my example file, the js script is triggered when the certificate generation layer is opened and the learner clicks the "Continue to Download" button. I also have another js script set to execute when the timeline begins on the base layer (it makes the variable update as the user types their name, rather than after they click off of the field). Make sure you are editing the one tied to the download button. If the download button won't appear for some reason, then just set the button's initial state to normal instead of hidden.

 

A.I. Troubleshooting

As of 2025, generative AI like chatGPT and deepseek excels at catching things like syntax errors (e.g., missing a bracket), cleaning up code, or giving solutions to a very targeted question asking how to arrive at a very particular result. It can be an exercise in frustration if you don't have a clear idea of what you want the result to be, and you often have to help it troubleshoot yourself if something doesn't work. However, it is an excellent tool, especially for people like me who have a hobbyist-level understanding of JavaScript.

Here are some suggested question types for how to use generative AI to troubleshoot - I find it works best when you ask the entire question at once, otherwise it won't wait and will just start generating:

 

- "Why isn't [wanted action] happening when I run this code: [paste entire script]?"

- "This script below gives me the date format as MM/DD/YYYY. How can I change it to the full written month name, like July 4, 2025? [paste the part of the script that generates the date variable]"

- "The script isn't running at all. What's the issue with this code? [paste entire script]"

- "How can I make a record of each time someone generates a certificate?"

- "I want to change the color of the course title. How can I do that? [paste section that draws the text]"

- "Explain line by line in detail what this section of code you gave me does [paste the code that it gave you]"

 

Just keep in mind that generative AI will confirm what you ask and will often have on blinders about your issue. When you go to someone experienced and explain what you're doing to solve a problem, that person will often be able to give you a completely different way to approach it that's simpler and more elegant. Generative AI will almost never do that. Instead, it will dig into the method that you were trying and attempt to make THAT work, even if it is overly complicated or will never actually function.

TL;DR

  • Set up your Storyline variables
  • Add a background image to your certificate
  • Use jsPDF to place Storyline variables over it
  • Trigger the script
  • No need to modify published files
  • Works in Preview and Review 360 as well as published outputs
  • Generative AI is a great troubleshooter (to an extent)

Full Code

// --- CONFIGURABLE SETTINGS --- //
const CERT_BG_IMG_URL = "https://philwingfield.com/wp-content/open-content/certificate_example.png"; // Background image URL (A4 landscape: 297mm x 210mm)

// --- LOAD jsPDF LIBRARY DYNAMICALLY --- //
const script = document.createElement('script');
script.src = "https://cdnjs.cloudflare.com/ajax/libs/jspdf/3.0.1/jspdf.umd.min.js";

/**
 * Main logic to generate the certificate PDF after jsPDF loads.
 */
script.onload = () => {

  // --- GET VARIABLES FROM STORYLINE --- //
  const player = GetPlayer();
  const name = player.GetVar("UserName");
  const course = player.GetVar("CHANGECourseName");
  const hours = String(player.GetVar("CHANGECreditHours"));

  // --- FORMAT CURRENT DATE --- //
  const now = new Date();
  const dd = String(now.getDate());
  const mm = String(now.getMonth() + 1);
  const yyyy = now.getFullYear();
  const formattedDate = `${mm}/${dd}/${yyyy}`;

  /**
   * Draws the certificate text on the PDF.
   * @param {jsPDF} doc - The jsPDF document instance.
   */
  function drawText(doc) {

    // User Name
    doc.setFont("times", "bold");
    doc.setFontSize(30);
    doc.text(name, doc.internal.pageSize.width / 2, 130, { align: 'center' });

    // Credit Hours
    doc.setFont("times", "normal");
    doc.setFontSize(20);
    doc.text(hours, 233, 150, { align: 'left' });

    // Course Name
    doc.setFontSize(30);
    doc.setTextColor(0, 0, 0);
    doc.setFont("times", "bold");
    doc.text(course, doc.internal.pageSize.width / 2, 88, { align: 'center' });

    // Date
    doc.setFont("times", "normal");
    doc.setFontSize(24);
    doc.text(formattedDate, 210.64, 176.89, { align: 'left' });

    // Save PDF
    doc.save(`${name}_${course}_certificate.pdf`); // format of the saved document name
  }

  /**
   * Generates the PDF, adds background image, and overlays text.
   */
  async function generatePDF() {
    const { jsPDF } = window.jspdf;
    const doc = new jsPDF({ orientation: 'landscape'});

    // Load background image
    const img = new Image();
    img.src = CERT_BG_IMG_URL;
    img.crossOrigin = "anonymous";

    img.onload = function () {
      doc.addImage(img, 0, 0, 297, 210); // Defines position and size in mm or image
      drawText(doc);
    };

    // Skips background image loading if not available; check for CORS permissions
    img.onerror = function () {
      console.warn("Failed to load certificate background image. Continuing without it.");
      drawText(doc); // Proceed without background
    };
  }

  generatePDF();
};

// --- APPEND SCRIPT TO LOAD jsPDF --- //
document.head.appendChild(script);

 

13 Replies

  • I have managed to upload a new image with the certificate I have, but my certificate is in portrait position :/ I edited line 2 of the java code:  "Background image URL (A4 portrait: 210mm x 297mm)" but it did not make a difference. The certificate does not look right. Anything else you think I should adjust?

    • Phil_Wingfield's avatar
      Phil_Wingfield
      Community Member

      Karenuqkolav1uq​ 

      It sounds to me like you are editing the comment in line 2, which won't affect how the program runs. In javascript, anything following // is a comment and will not be considered code by the program reading it. 

      The 2 edits you make should instead be:

      • In line 62: remove orientation: 'landscape' so it is just curly brackets: {}
        • The default for jsPDF is portrait A4, so we only need to define it as landscape if we want that way.
      • The other thing is to change the size of the imported image in line 70. Swap the 297 and 210 in that line (the x and y lengths) so it reads (img, 0, 0, 210, 297)
        • This will force whatever image you import to be 210mm wide and 297mm high, so make sure you change line 2 from the URL for my certificate to whatever certificate you have or else it will be distorted.

       

      Let me know if this works! I see I didn't add instructions for making it portrait into my original post, so I'll edit it in.

      • Karenuqkolav1uq's avatar
        Karenuqkolav1uq
        Community Member

        Thank you so much Phil_Wingfield​ for answering my question. I have done the changes you told me, but I do not why when I test it the "download certificate" button does not appear 😭. I have done it several times as I really do not understand how this is affecting the appearance of the button. I have not altered any other section of the JavaScript. Line 2 with the link to the image, line 62 curly brackets and line 70 swap the numbers. Triggers have not been changed either. Here are some images. Any ideas of what the problem is?

        Thank you!

         

         

         

  • Wow Storyline, what a complicated process. Articulate - Have you considered building certificates into the tool?

  • Wow this is great, and I second that it would be ideal for this to be built into Storyline for ease of use.

    For those of us in countries using different date formats, I think a small adjustment to line 24 in the code should reformat the date on the certificate for us:

    current: const formattedDate = `${mm}/${dd}/${yyyy}`;
    adjusted: const formattedDate = `${dd}/${mm}/${yyyy}`;

    • Phil_Wingfield's avatar
      Phil_Wingfield
      Community Member

      JessicaHoskin​  Definitely! That would work great and is probably what I would do.

      You could also do something like this, which I just looked up how to do out of curiosity:

      const now = new Date();
      const options = { year: 'numeric', month: 'numeric', day: 'numeric' };
      const formattedDate = now.toLocaleDateString('en-US', options);

      In line 3, the 'en-US' argument shows that it's English, in the United States and would return today as "8/21/2025". You could change it to another region by googling your language and country's JavaScript language and region tag. For example, changing it to 'es-ES' (spanish-spain) would return "21/8/2025".

      You could get more variation by changing month in line 2 to 'long' instead of 'numeric.' en-US would return "August 21, 2025", but es-ES or es-US would return "21 de agosto de 2025".