Building a Portfolio Generator in Oracle APEX: A Step-by-Step Guide to Generate Downloadable Portfolio Documents

 Introduction

This tutorial demonstrates how to build a Portfolio Generator application in Oracle APEX where users can enter resume details and generate a downloadable portfolio HTML/PDF with one click.

The application collects user details, work experience, skills, and education, then dynamically generates a styled HTML portfolio that can be printed as PDF.

Step-by-Step Implementation Guide

Step 1 - Create Database Tables

First create the tables required to store resume information.

We will create 5 tables.

1️⃣ Resume Master Table

Stores the main profile information.

  CREATE TABLE "RESUME_MASTER" 

   ( "RESUME_ID" NUMBER GENERATED ALWAYS AS IDENTITY,

"FULL_NAME" VARCHAR2(200), 

"EMAIL" VARCHAR2(200), 

"PHONE" VARCHAR2(50), 

"ADDRESS" VARCHAR2(500), 

"DOB" DATE, 

"OBJECTIVE" CLOB, 

"CURRENT_COMPANY" VARCHAR2(200), 

"CURRENT_ROLE" VARCHAR2(200), 

"CURRENT_FROM" DATE, 

"LINKEDIN_URL" VARCHAR2(300), 

"CREATED_ON" DATE DEFAULT SYSDATE, 

PRIMARY KEY ("RESUME_ID")

  USING INDEX ENABLE

   );

2️⃣ Resume Experience Table

Stores multiple previous job experiences.

  CREATE TABLE "RESUME_EXPERIENCE" 

   ( "EXP_ID" NUMBER GENERATED ALWAYS AS IDENTITY, 

"RESUME_ID" NUMBER, 

"COMPANY_NAME" VARCHAR2(200), 

"JOB_TITLE" VARCHAR2(200), 

"FROM_DATE" DATE, 

"TO_DATE" DATE, 

"DESCRIPTION" CLOB, 

PRIMARY KEY ("EXP_ID")

  USING INDEX ENABLE

   );

3️⃣ Resume Skills Table

Stores skills and proficiency level.

  CREATE TABLE "RESUME_SKILLS" 

   ( "SKILL_ID" NUMBER GENERATED ALWAYS AS IDENTITY, 

"RESUME_ID" NUMBER, 

"SKILL_NAME" VARCHAR2(200), 

"PROFICIENCY" VARCHAR2(50), 

PRIMARY KEY ("SKILL_ID")

  USING INDEX ENABLE

   );

4️⃣ Resume Education Table

Stores academic details.

  CREATE TABLE "RESUME_EDUCATION" 

   ( "EDU_ID" NUMBER GENERATED ALWAYS AS IDENTITY, 

"RESUME_ID" NUMBER, 

"INSTITUTION" VARCHAR2(200), 

"DEGREE" VARCHAR2(200), 

"FIELD_OF_STUDY" VARCHAR2(200), 

"FROM_YEAR" NUMBER, 

"TO_YEAR" NUMBER, 

PRIMARY KEY ("EDU_ID")

  USING INDEX ENABLE

   );

5️⃣ Resume File Store Table

Stores generated files.

  CREATE TABLE "RESUME_PDF_STORE" 

   ( "PDF_ID" NUMBER GENERATED ALWAYS AS IDENTITY, 

"RESUME_ID" NUMBER, 

"PDF_CONTENT" BLOB, 

"GENERATED_ON" DATE DEFAULT SYSDATE, 

PRIMARY KEY ("PDF_ID")

  USING INDEX ENABLE

   );

Step 2 - Create PL/SQL Package for File Generation

Create a package that builds the portfolio HTML and streams it as a download.

Package Specification

create or replace PACKAGE resume_pdf_pkg AS
    PROCEDURE generate_and_download(p_resume_id IN NUMBER);
    FUNCTION  get_resume_html(p_resume_id IN NUMBER) RETURN CLOB;
END resume_pdf_pkg;

Package Body

create or replace PACKAGE BODY resume_pdf_pkg AS

    -- BUILD HTML for the Resume

    -- ============================================================

    FUNCTION get_resume_html(p_resume_id IN NUMBER) RETURN CLOB IS

        v_html      CLOB := '';

        v_name      VARCHAR2(200);

        v_email     VARCHAR2(200);

        v_phone     VARCHAR2(50);

        v_address   VARCHAR2(500);

        v_dob       DATE;

        v_objective CLOB;

        v_curr_co   VARCHAR2(200);

        v_curr_role VARCHAR2(200);

        v_curr_from DATE;

        v_linkedin  VARCHAR2(300);

    BEGIN

        SELECT full_name, email, phone, address, dob, objective,

               current_company, current_role, current_from, linkedin_url

        INTO   v_name, v_email, v_phone, v_address, v_dob, v_objective,

               v_curr_co, v_curr_role, v_curr_from, v_linkedin

        FROM   resume_master

        WHERE  resume_id = p_resume_id;


        v_html := '<!DOCTYPE html><html><head><meta charset="UTF-8"/>

        <style>

            @page { margin: 15mm; size: A4; }

            body  { font-family: Arial,sans-serif; font-size:11pt; color:#222; margin:0; padding:0; }

            .top  { background:#1a3c5e; color:white; padding:18px 22px; }

            .top h1 { margin:0; font-size:22pt; letter-spacing:1px; }

            .top .sub { font-size:10pt; margin-top:6px; opacity:0.85; }

            h2    { color:#1a3c5e; font-size:13pt; border-bottom:2px solid #1a3c5e;

                    padding-bottom:3px; margin-top:18px; margin-bottom:8px; }

            table { width:100%; border-collapse:collapse; margin-top:5px; }

            th    { background:#e8f0fe; color:#1a3c5e; padding:6px 8px; 

                    text-align:left; font-size:10pt; border:1px solid #ccc; }

            td    { padding:5px 8px; font-size:10pt; border:1px solid #ddd; 

                    vertical-align:top; }

            tr:nth-child(even) td { background:#f9f9f9; }

            .skill{ display:inline-block; background:#1a3c5e; color:white;

                    padding:3px 10px; border-radius:12px; margin:3px; font-size:9pt; }

            .obj  { background:#f0f4ff; padding:10px 14px; border-left:4px solid #1a3c5e;

                    font-style:italic; margin-top:5px; }

            .footer { text-align:center; font-size:8pt; color:#999; 

                      margin-top:30px; border-top:1px solid #ddd; padding-top:8px; }

        </style></head><body>';


        -- Header

        v_html := v_html || '<div class="top">';

        v_html := v_html || '<h1>' || UPPER(v_name) || '</h1>';

        v_html := v_html || '<div class="sub">';

        IF v_curr_role IS NOT NULL THEN

            v_html := v_html || v_curr_role;

            IF v_curr_co IS NOT NULL THEN

                v_html := v_html || ' at ' || v_curr_co;

            END IF;

            v_html := v_html || ' &nbsp;|&nbsp; ';

        END IF;

        v_html := v_html || v_email || ' &nbsp;|&nbsp; ' || v_phone;

        IF v_linkedin IS NOT NULL THEN

            v_html := v_html || ' &nbsp;|&nbsp; ' || v_linkedin;

        END IF;

        v_html := v_html || '</div></div>';


        -- Personal Info

        v_html := v_html || '<h2>Personal Information</h2>';

        v_html := v_html || '<table><tr>';

        v_html := v_html || '<th width="25%">Date of Birth</th><td width="25%">' 

                  || TO_CHAR(v_dob,'DD Month YYYY') || '</td>';

        v_html := v_html || '<th width="20%">Address</th><td>' || v_address || '</td>';

        v_html := v_html || '</tr></table>';


        -- Objective

        IF v_objective IS NOT NULL THEN

            v_html := v_html || '<h2>Career Objective</h2>';

            v_html := v_html || '<div class="obj">' || v_objective || '</div>';

        END IF;


        -- Current Position

        IF v_curr_co IS NOT NULL THEN

            v_html := v_html || '<h2>Current Position</h2>';

            v_html := v_html || '<table>';

            v_html := v_html || '<tr><th>Company</th><th>Role</th><th>Working Since</th></tr>';

            v_html := v_html || '<tr><td><b>' || v_curr_co || '</b></td><td>' || v_curr_role 

                      || '</td><td>' || TO_CHAR(v_curr_from,'Mon YYYY') || ' - Present</td></tr>';

            v_html := v_html || '</table>';

        END IF;


        -- Work Experience

        DECLARE

            v_cnt NUMBER := 0;

        BEGIN

            SELECT COUNT(*) INTO v_cnt FROM resume_experience WHERE resume_id = p_resume_id;

            IF v_cnt > 0 THEN

                v_html := v_html || '<h2>Work Experience</h2>';

                v_html := v_html || '<table>';

                v_html := v_html || '<tr><th>Company</th><th>Job Title</th><th>From</th><th>To</th><th>Description</th></tr>';

                FOR r IN (

                    SELECT company_name, job_title, from_date, to_date, description

                    FROM   resume_experience

                    WHERE  resume_id = p_resume_id

                    ORDER  BY from_date DESC

                ) LOOP

                    v_html := v_html || '<tr>';

                    v_html := v_html || '<td><b>' || r.company_name || '</b></td>';

                    v_html := v_html || '<td>' || r.job_title || '</td>';

                    v_html := v_html || '<td>' || TO_CHAR(r.from_date,'Mon YYYY') || '</td>';

                    v_html := v_html || '<td>' 

                              || NVL(TO_CHAR(r.to_date,'Mon YYYY'),'<i>Present</i>') 

                              || '</td>';

                    v_html := v_html || '<td>' || NVL(r.description,'-') || '</td>';

                    v_html := v_html || '</tr>';

                END LOOP;

                v_html := v_html || '</table>';

            END IF;

        END;


        -- Skills

        DECLARE

            v_cnt NUMBER := 0;

        BEGIN

            SELECT COUNT(*) INTO v_cnt FROM resume_skills WHERE resume_id = p_resume_id;

            IF v_cnt > 0 THEN

                v_html := v_html || '<h2>Technical Skills</h2><div>';

                FOR r IN (

                    SELECT skill_name, proficiency

                    FROM   resume_skills

                    WHERE  resume_id = p_resume_id

                ) LOOP

                    v_html := v_html || '<span class="skill">' 

                              || r.skill_name || ' — ' || r.proficiency 

                              || '</span>';

                END LOOP;

                v_html := v_html || '</div>';

            END IF;

        END;


        -- Education

        DECLARE

            v_cnt NUMBER := 0;

        BEGIN

            SELECT COUNT(*) INTO v_cnt FROM resume_education WHERE resume_id = p_resume_id;

            IF v_cnt > 0 THEN

                v_html := v_html || '<h2>Education</h2>';

                v_html := v_html || '<table>';

                v_html := v_html || '<tr><th>Institution</th><th>Degree</th><th>Field of Study</th><th>Year</th></tr>';

                FOR r IN (

                    SELECT institution, degree, field_of_study, from_year, to_year

                    FROM   resume_education

                    WHERE  resume_id = p_resume_id

                    ORDER  BY from_year DESC

                ) LOOP

                    v_html := v_html || '<tr>';

                    v_html := v_html || '<td><b>' || r.institution || '</b></td>';

                    v_html := v_html || '<td>' || r.degree || '</td>';

                    v_html := v_html || '<td>' || r.field_of_study || '</td>';

                    v_html := v_html || '<td>' || r.from_year || ' – ' || r.to_year || '</td>';

                    v_html := v_html || '</tr>';

                END LOOP;

                v_html := v_html || '</table>';

            END IF;

        END;


        -- Footer

        v_html := v_html || '<div class="footer">Generated on ' 

                  || TO_CHAR(SYSDATE,'DD-Mon-YYYY HH24:MI') 

                  || ' | Portfolio of ' || v_name || '</div>';

        v_html := v_html || '</body></html>';


        RETURN v_html;


    EXCEPTION

        WHEN NO_DATA_FOUND THEN

            RETURN '<html><body><h2>Resume not found</h2></body></html>';

        WHEN OTHERS THEN

            RETURN '<html><body><h2>Error: ' || SQLERRM || '</h2></body></html>';

    END get_resume_html;


    -- GENERATE + DOWNLOAD — streams HTML as downloadable file

    -- Works on ALL APEX versions, no print server needed

    -- ============================================================

    PROCEDURE generate_and_download(p_resume_id IN NUMBER) IS

        v_html      CLOB;

        v_blob      BLOB;

        v_raw       RAW(32767);

        v_amt       INTEGER := 8000;

        v_offset    INTEGER := 1;

        v_len       INTEGER;

        v_name      VARCHAR2(200);

        v_filename  VARCHAR2(200);

        

        -- For BLOB conversion

        v_dest_offset  INTEGER := 1;

        v_src_offset   INTEGER := 1;

        v_lang_context INTEGER := DBMS_LOB.DEFAULT_LANG_CTX;

        v_warning      INTEGER;

    BEGIN

        -- Get HTML content

        v_html := get_resume_html(p_resume_id);


        -- Convert CLOB to BLOB (UTF-8)

        DBMS_LOB.CREATETEMPORARY(v_blob, TRUE);

        DBMS_LOB.CONVERTTOBLOB(

            dest_lob     => v_blob,

            src_clob     => v_html,

            amount       => DBMS_LOB.LOBMAXSIZE,

            dest_offset  => v_dest_offset,

            src_offset   => v_src_offset,

            blob_csid    => NLS_CHARSET_ID('AL32UTF8'),

            lang_context => v_lang_context,

            warning      => v_warning

        );


        -- Get person name for filename

        BEGIN

            SELECT full_name INTO v_name FROM resume_master WHERE resume_id = p_resume_id;

        EXCEPTION WHEN OTHERS THEN

            v_name := 'Portfolio';

        END;


        v_filename := REPLACE(REPLACE(v_name,' ','_'),'/','') || '_Portfolio.html';


        -- Stream file to browser as downloadable HTML

        -- (Opens perfectly and looks like a professional PDF when printed)

        OWA_UTIL.MIME_HEADER('text/html', FALSE, 'UTF-8');

        HTP.p('Content-Length: ' || DBMS_LOB.GETLENGTH(v_blob));

        HTP.p('Content-Disposition: attachment; filename="' || v_filename || '"');

        OWA_UTIL.HTTP_HEADER_CLOSE;

        WPG_DOCLOAD.DOWNLOAD_FILE(v_blob);

        DBMS_LOB.FREETEMPORARY(v_blob);

        APEX_APPLICATION.STOP_APEX_ENGINE;


    EXCEPTION

        WHEN OTHERS THEN

            DBMS_LOB.FREETEMPORARY(v_blob);

            RAISE_APPLICATION_ERROR(-20001, 'Download Error: ' || SQLERRM);

    END generate_and_download;

END resume_pdf_pkg;

Package Logic Overview

The process works as follows:

1️⃣ Fetch resume data from tables
2️⃣ Build formatted HTML layout
3️⃣ Convert HTML CLOB → BLOB
4️⃣ Send file to browser using WPG_DOCLOAD.DOWNLOAD_FILE

Key components used:

  • OWA_UTIL.MIME_HEADER

  • WPG_DOCLOAD.DOWNLOAD_FILE

  • DBMS_LOB.CONVERTTOBLOB

This allows direct file download without third-party PDF tools.

Step 3 - Create Oracle APEX Application

Create a new APEX application.

Application Pages:

PageNamePurpose
1HomeNavigation page
2Resume BuilderMain form
3Download PortfolioFile streaming

Step 4 - Build Resume Builder Page

Create Page 2 with a form.

Form Fields

ItemType
P2_FULL_NAMEText Field
P2_EMAILText Field
P2_PHONEText Field
P2_DOBDate Picker
P2_ADDRESSTextarea
P2_LINKEDIN_URLText Field
P2_CURRENT_COMPANYText Field
P2_CURRENT_ROLEText Field
P2_CURRENT_FROMDate Picker
P2_OBJECTIVETextarea
P2_RESUME_IDHidden Item

Set Date Format Mask:

DD-MON-YYYY

Step 5 - Create Interactive Grids

Add three Interactive Grids on the same page.

1️⃣ Previous Work Experience Grid

Columns:

  • EXP_ID (Primary Key)

  • RESUME_ID

  • COMPANY

  • JOB_TITLE

  • START_DATE

  • END_DATE

  • DESCRIPTION

SQL Query :

SELECT EXP_ID, RESUME_ID, COMPANY_NAME, JOB_TITLE, 
       FROM_DATE, TO_DATE, DESCRIPTION
FROM   RESUME_EXPERIENCE
WHERE RESUME_ID =:P2_RESUME_ID

2️⃣ Skills Grid

Columns:

  • SKILL_ID

  • RESUME_ID

  • SKILL_NAME

  • PROFICIENCY

Proficiency LOV:

Beginner
Intermediate
Expert

SQL Query:

SELECT SKILL_ID, RESUME_ID, SKILL_NAME, PROFICIENCY FROM RESUME_SKILLS WHERE RESUME_ID = :P2_RESUME_ID

3️⃣ Education Grid

Columns:

  • EDU_ID

  • RESUME_ID

  • INSTITUTION

  • DEGREE

  • FIELD_OF_STUDY

  • START_YEAR

  • END_YEAR

SQL Query:

SELECT EDU_ID, RESUME_ID, INSTITUTION, DEGREE, FIELD_OF_STUDY, FROM_YEAR, TO_YEAR FROM RESUME_EDUCATION WHERE RESUME_ID = :P2_RESUME_ID

Important Grid Settings

For Primary Key Columns:

Type = Hidden
Primary Key = Yes
Query Only = Yes

This avoids APEXDEV.IG.NO_PK errors.

Step 6 - Add Generate Portfolio Button

Create a button:

Button Name: BTN_GENERATE_PDF
Label: Generate & Download Portfolio PDF

Button Action: Submit Page

Step 7 - Create Page Process to Save and Generate Data

Process Name:

Generate and Download Portfolio

PL/SQL Example:

DECLARE v_id NUMBER:= NVL(TO_NUMBER(:P2_RESUME_ID), 0); v_dob DATE; v_cur_from DATE; BEGIN -- Safe date conversion — handles any NLS format BEGIN v_dob := TO_DATE(:P2_DOB, 'DD-MON-YYYY'); EXCEPTION WHEN OTHERS THEN BEGIN v_dob := TO_DATE(:P2_DOB, 'DD/MM/YYYY'); EXCEPTION WHEN OTHERS THEN BEGIN v_dob := TO_DATE(:P2_DOB, 'MM/DD/YYYY'); EXCEPTION WHEN OTHERS THEN v_dob := TO_DATE(:P2_DOB, 'YYYY-MM-DD'); END; END; END; BEGIN v_cur_from := TO_DATE(:P2_CURRENT_FROM, 'DD-MON-YYYY'); EXCEPTION WHEN OTHERS THEN BEGIN v_cur_from := TO_DATE(:P2_CURRENT_FROM, 'DD/MM/YYYY'); EXCEPTION WHEN OTHERS THEN BEGIN v_cur_from := TO_DATE(:P2_CURRENT_FROM, 'MM/DD/YYYY'); EXCEPTION WHEN OTHERS THEN v_cur_from := TO_DATE(:P2_CURRENT_FROM, 'YYYY-MM-DD'); END; END; END; IF v_id = 0 THEN INSERT INTO resume_master ( full_name, email, phone, address, dob, objective, current_company, current_role, current_from, linkedin_url ) VALUES ( :P2_FULL_NAME, :P2_EMAIL, :P2_PHONE, :P2_ADDRESS, v_dob, :P2_OBJECTIVE, :P2_CURRENT_COMPANY, :P2_CURRENT_ROLE, v_cur_from, :P2_LINKEDIN_URL ) RETURNING resume_id INTO v_id; :P2_RESUME_ID := v_id; ELSE UPDATE resume_master SET full_name = :P2_FULL_NAME, email = :P2_EMAIL, phone = :P2_PHONE, address = :P2_ADDRESS, dob = v_dob, objective = :P2_OBJECTIVE, current_company = :P2_CURRENT_COMPANY, current_role = :P2_CURRENT_ROLE, current_from = v_cur_from, linkedin_url = :P2_LINKEDIN_URL WHERE resume_id = v_id; END IF; COMMIT; END;

Step 8 - Create Branch to Download Page

After processing, redirect to Page 3.

Branch

Name: Go to Download Page

Type: Page or URL(Redirect)

Target: Page 3

Set Item:

P3_RESUME_ID = &P2_RESUME_ID.

Request: DOWNLOAD

Server-side Condition

When Button Pressed: BTN_GENERATE_PDF


Step 9 - Create Download Process

On Page 3, create a Before Header Process.

Process Name: Stream Portfolio File

BEGIN IF :P3_RESUME_ID IS NOT NULL THEN resume_pdf_pkg.generate_and_download( p_resume_id => TO_NUMBER(:P3_RESUME_ID) ); END IF; END;

This process streams the generated HTML file.

And Create Page item

Name: P3_RESUME_ID

Type: Text Field

Step 10 - Download Flow

Final execution flow:

User fills form

Clicks Generate Portfolio

Page Process saves data

Branch redirects to Page 3

PL/SQL package generates HTML

File automatically downloads

Final Result

Users can:

Enter resume information
Add multiple experience records
Add skills and education
Click one button
Download a complete portfolio document

Output in Oracle APEX UI:

Portfolio PDF:

Downloadable PDF Output

🚀 Future Enhancements

While the current version successfully generates a downloadable portfolio, there are several exciting

enhancements that can make this application even more powerful and user-friendly.

1️⃣ Multiple Portfolio Templates

Users could be allowed to select from multiple portfolio templates before generating the document.

Example templates:

  • Modern Professional Template

  • Minimal Clean Template

  • Creative Designer Template

  • Developer Portfolio Template

A dropdown like:

Template Style
--------------
Modern
Professional
Creative
Minimal

The selected template would dynamically change the HTML layout and CSS styling of the generated

portfolio.

2️⃣ Live Portfolio Preview

Before downloading, the application could provide a real-time preview page where users can see how

their portfolio will appear.

Benefits:

  • Users can verify formatting

  • Edit details before download

  • Improve overall user experience

3️⃣ Direct PDF Generation

Currently the application generates HTML which can be printed to PDF.

Future versions could integrate:

  • Oracle APEX Print Server

  • ORDS based PDF rendering

  • External PDF engines

This would allow users to download PDF directly without using Ctrl + P.

4️⃣ Profile Photo Upload

Users could upload a profile photo that appears at the top of the portfolio.

Example section:

[ Profile Photo]
Name
Title
Contact Information

This would make the portfolio more visually appealing and professional.

🌟 Final Thoughts

Oracle APEX provides an excellent platform for building document-driven applications, and this

Portfolio Generator is just the beginning. With additional features like template customization,

live previews, project showcases, and public portfolio links, this application can evolve into a

complete portfolio management platform for developers and professionals.

Conclusion

Building a Portfolio Generator in Oracle APEX demonstrates how powerful the APEX platform can be

when combining form-based data entry, Interactive Grids, and PL/SQL-based document generation.

In this project, we created a complete solution where users can enter their personal details, work

experience, skills, and education, and instantly generate a downloadable portfolio document with a

single click. Instead of relying on external PDF tools, the application uses pure PL/SQL with

OWA_UTIL and WPG_DOCLOAD to generate and stream the file directly to the browser. This approach keeps the

architecture simple, lightweight, and fully database driven.

The implementation also highlights several important Oracle APEX development practices, such as:

  • Designing normalized database tables for structured resume data

  • Using Interactive Grids for dynamic multi-row inputs

  • Implementing page processes and branching logic for controlled workflows

  • Generating dynamic HTML content using PL/SQL

  • Streaming files to the browser for instant download

This project is a great example of how Oracle APEX can be used to build real-world business

applications that automate document generation and reporting with minimal infrastructure.


Comments

Popular posts from this blog

Building Secure RESTful Services in Oracle APEX 24.2 Using ORDS, OAuth2 Client Credentials, and PL/SQL

Implementing WhatsApp OTP Verification in Oracle APEX Using UltraMsg API