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 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 || ' | ';
END IF;
v_html := v_html || v_email || ' | ' || v_phone;
IF v_linkedin IS NOT NULL THEN
v_html := v_html || ' | ' || 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:
| Page | Name | Purpose |
|---|---|---|
| 1 | Home | Navigation page |
| 2 | Resume Builder | Main form |
| 3 | Download Portfolio | File streaming |
Step 4 - Build Resume Builder Page
Create Page 2 with a form.
Form Fields
| Item | Type |
|---|---|
| P2_FULL_NAME | Text Field |
| P2_EMAIL | Text Field |
| P2_PHONE | Text Field |
| P2_DOB | Date Picker |
| P2_ADDRESS | Textarea |
| P2_LINKEDIN_URL | Text Field |
| P2_CURRENT_COMPANY | Text Field |
| P2_CURRENT_ROLE | Text Field |
| P2_CURRENT_FROM | Date Picker |
| P2_OBJECTIVE | Textarea |
| P2_RESUME_ID | Hidden Item |
Set Date Format Mask:
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
2️⃣ Skills Grid
Columns:
-
SKILL_ID
-
RESUME_ID
-
SKILL_NAME
-
PROFICIENCY
Proficiency LOV:
Beginner
Intermediate
ExpertSQL Query:
SELECT SKILL_ID, RESUME_ID, SKILL_NAME, PROFICIENCY FROM RESUME_SKILLS WHERE RESUME_ID = :P2_RESUME_ID3️⃣ 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_IDImportant Grid Settings
For Primary Key Columns:
Type = Hidden
Primary Key = Yes
Query Only = YesThis avoids APEXDEV.IG.NO_PK errors.
Step 6 - Add Generate Portfolio Button
Create a button:
Button Name: BTN_GENERATE_PDFLabel: Generate & Download Portfolio PDFButton Action: Submit Page
Step 7 - Create Page Process to Save and Generate Data
Process Name:
Generate and Download PortfolioPL/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: DOWNLOADServer-side ConditionWhen Button Pressed: BTN_GENERATE_PDFStep 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
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
MinimalThe 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 InformationThis 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_UTILandWPG_DOCLOADto generate and stream the file directly to the browser. This approach keeps thearchitecture 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
Post a Comment