Hands-on Guide to Taming the LLM: Blending Free-Flowing AI with Strict PDF Rules
    Deep Dives

    Hands-on Guide to Taming the LLM: Blending Free-Flowing AI with Strict PDF Rules

    A hands-on guide to hybrid automation, combining LLM creativity with deterministic rules to build a Streamlit-based employee profile generator producing reliable, pixel-perfect, production-ready PDFs documents.

    Abhishek Kumar
    Abhishek Kumar
    Jan 8, 2026

    Large Language Models (LLMs) are incredibly creative but often struggle with strict structural constraints. Conversely, traditional rule-based automation guarantees precision but lacks flair. In this hands-on guide to Hybrid Automation, we bridge this gap by building a robust Employee Profile Generator using Streamlit, OpenAI, and Python. We will demonstrate how constraining the probabilistic nature of GenAI with deterministic layout rules creates foolproof, production-ready tools. Join me as we transform a blank template into a dynamic, AI-powered personal page.

    Table of Contents

    • Grounding the stochastic beast
    • Finalizing the template
    • Mapping the page geography
    • Step-by-step guide: Building a profile generator
    • Analyzing the streamlit page
    • Reviewing the output

    Grounding the stochastic beast

    The probabilistic nature of LLMs, where the same prompt yields different results often creates apprehension regarding reliability in business workflows. We mitigate this by injecting determinism through strict prompt engineering, enforcing exact word counts and formatting constraints. However, the true safety net lies in coupling this AI output with rigid, rule-based logic like ReportLab's coordinate system. This hybrid approach ensures the document structure never breaks, allowing us to deploy creative AI within automated pipelines with total confidence.

    Finalizing the template

    The foundation of our automation is a static background PDF acting as the visual container. We have complete creative freedom to design this using tools like Canva, PowerPoint, or even AI image generators. However, the critical requirement is creating strategic negative space. As shown in the reference image, we must leave distinct blank areas reserved for dynamic content. These empty placeholders for text and images act as the designated landing zones where our Python script will precisely overlay the personalized data later.

    Mapping the page Geography

    Once the template is ready, we must map its geography. We can utilize free online PDF inspection tools to identify the precise (x, y) coordinates for every element. This step is vital for establishing "safe zones", specifically the left and right border limits, to ensure our text never spills over the edges. Furthermore, we measure the reserved space for the profile image to determine the required pixel dimensions and pinpoint the exact center coordinates for alignment.

    Step-by-step guide: Building a profile generator

    Directory Setup and Environment Installation

    /project_folder
    │   main.py
    │   .env
    │   template.pdf
    └── /fonts
        ├── BalsamiqSans-Regular.ttf
        └── Poppins-Regular.ttf
    
    # Terminal Commands
    python -m venv poster_venv
    source poster_venv/bin/activate
    pip install streamlit python-dotenv openai reportlab pypdf Pillow
    

    Here we establish the foundational environment for our project to ensure isolation and stability. We start by creating a dedicated virtual environment named poster_venv which keeps our dependencies separate from the global system. After activation we install the necessary libraries like Streamlit and Pillow using pip. You must also ensure that the fonts directory containing your specific TrueType files and the template PDF are placed correctly in the root folder. This setup guarantees that our application runs smoothly without version conflicts.

    Imports and Environment Configuration

    import streamlit as st
    import os
    from datetime import date
    from io import BytesIO
    
    # --- Library Imports for Image Processing ---
    from PIL import Image, ImageDraw, ImageOps
    
    # --- Library Imports for PDF generation ---
    from reportlab.pdfgen import canvas
    from reportlab.pdfbase import pdfmetrics
    from reportlab.pdfbase.ttfonts import TTFont
    from reportlab.platypus import Paragraph
    from reportlab.lib.styles import ParagraphStyle
    from reportlab.lib.enums import TA_JUSTIFY
    from reportlab.lib.utils import ImageReader
    from pypdf import PdfWriter, PdfReader
    
    # --- Library Imports for OpenAI ---
    from dotenv import load_dotenv
    from openai import OpenAI
    
    load_dotenv()
    client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
    st.set_page_config(page_title="Profile Generator", layout="centered")
    

    We start by bringing in necessary tools like Streamlit for the web interface and ReportLab for detailed PDF manipulation. We also load sensitive credentials safely using environment variables because hardcoding API keys is a major security risk that developers must avoid. The page configuration is set immediately to ensure the browser tab looks professional from the moment the app loads. Python manages these imports efficiently to ensure we only load the specific modules required for image processing and document generation.

    Date and Zodiac Logic Helpers

    def calculate_age(dob):
        today = date.today()
        return today.year - dob.year - ((today.month, today.day) < (dob.month, dob.day))
    
    def get_zodiac_sign(day, month):
        if (month == 3 and day >= 21) or (month == 4 and day <= 19): return "Aries"
        elif (month == 4 and day >= 20) or (month == 5 and day <= 20): return "Taurus"
        elif (month == 5 and day >= 21) or (month == 6 and day <= 20): return "Gemini"
        elif (month == 6 and day >= 21) or (month == 7 and day <= 22): return "Cancer"
        elif (month == 7 and day >= 23) or (month == 8 and day <= 22): return "Leo"
        elif (month == 8 and day >= 23) or (month == 9 and day <= 22): return "Virgo"
        elif (month == 9 and day >= 23) or (month == 10 and day <= 22): return "Libra"
        elif (month == 10 and day >= 23) or (month == 11 and day <= 21): return "Scorpio"
        elif (month == 11 and day >= 22) or (month == 12 and day <= 21): return "Sagittarius"
        elif (month == 12 and day >= 22) or (month == 1 and day <= 19): return "Capricorn"
        elif (month == 1 and day >= 20) or (month == 2 and day <= 18): return "Aquarius"
        else: return "Pisces"
    

    Here we define pure logic functions that handle date mathematics without relying on external APIs or complex libraries. Calculating age requires checking if the current birthday has occurred yet this year to ensure the integer value is accurate. The zodiac function maps specific day and month ranges to astrological signs using standard tropical astrology dates. This creates a personalized touch for the user profile without needing complex AI calls. It serves as a great example of where simple algorithmic logic is superior to machine learning.

    Image Processing Logic

    def process_circular_image(uploaded_file):
        try:
            img = Image.open(uploaded_file).convert("RGB")
            
            # Center Crop to Square
            width, height = img.size
            new_size = min(width, height)
            left = (width - new_size) / 2
            top = (height - new_size) / 2
            right = (width + new_size) / 2
            bottom = (height + new_size) / 2
            img = img.crop((left, top, right, bottom))
            
            # Resize and Mask
            img = img.resize((400, 400), Image.LANCZOS)
            mask = Image.new('L', (400, 400), 0)
            draw = ImageDraw.Draw(mask)
            draw.ellipse((0, 0, 400, 400), fill=255)
            output = ImageOps.fit(img, mask.size, centering=(0.5, 0.5))
            output.putalpha(mask)
            return output
        except Exception as e:
            st.error(f"Error processing image: {e}")
            return None
    

    This section handles the visual transformation of the user uploaded photo using the powerful Pillow library. We first crop the image into a perfect square from the center to avoid distorting the aspect ratio which often happens with raw uploads. Then we resize it to a standard four hundred pixels and apply a circular transparency mask. This ensures that every profile picture looks uniform and professional on the final PDF regardless of the original upload dimensions. Circular images are widely favored in modern UI design because they focus attention on the face.

    AI Text Generation

    def generate_ai_content(details):
        prompt_about = (
            f"Write a professional 'About me' section for a person with these details: "
            f"Current Role: {details['position']} at {details['company']}. "
            f"Qualification: {details['qualification']}. Experience: {details['experience']} years. "
            f"Skills: {details['skills']}. Location: {details['location']}. "
            f"Strictly keep the length between 42 and 46 words. Do not use newlines."
        )
        prompt_love = (
            f"Write a short creative sentence about 'Things I love' based on these hobbies: "
            f"{details['hobby1']}, {details['hobby2']} and this statement: '{details['love_line']}'. "
            f"Strictly keep the length between 10 and 15 words. Do not use newlines."
        )
        try:
            resp_about = client.chat.completions.create(
                model="chatgpt-4o-latest",
                messages=[{"role": "user", "content": prompt_about}]
            )
            resp_love = client.chat.completions.create(
                model="chatgpt-4o-latest",
                messages=[{"role": "user", "content": prompt_love}]
            )
            return resp_about.choices[0].message.content.strip(), resp_love.choices[0].message.content.strip()
        except Exception as e:
            st.error(f"Error connecting to OpenAI: {e}")
            return "", ""
    

    We utilize the OpenAI client here to generate creative text based on user inputs. The prompts are carefully engineered with strict word count constraints to ensure the text fits perfectly into the PDF layout without overflowing. We request the model to avoid newlines because single block paragraphs look better in the final design. This automation saves the user from having to write their own professional bio and creative interests. Prompt engineering is critical here to ensure the AI output is usable immediately without editing.

    PDF Canvas and Image Drawing

    def create_overlay_pdf(data, page_width, page_height):
        packet = BytesIO()
        c = canvas.Canvas(packet, pagesize=(page_width, page_height))
    
        try:
            pdfmetrics.registerFont(TTFont('Balsamiq', 'fonts/BalsamiqSans-Regular.ttf'))
            pdfmetrics.registerFont(TTFont('Poppins', 'fonts/Poppins-Regular.ttf'))
        except Exception as e:
            st.error(f"Font files not found in 'fonts/' folder. Error: {e}")
            return None
    
        if data.get('profile_image'):
            img_reader = ImageReader(data['profile_image'])
            # Center is (870, 1075), Diameter is 400
            c.drawImage(img_reader, 660, 878, width=400, height=400, mask='auto')
    

    We initialize a byte stream to hold our new PDF layer in memory instead of saving it to the disk which improves performance. The canvas is set to the exact dimensions of the template to ensure perfect alignment of all elements. We register the custom fonts immediately so they are available for use in subsequent drawing commands. If the user provided an image we draw it onto the canvas at precise coordinates calculated to center it perfectly. Using memory buffers rather than file systems is a best practice for web applications to handle concurrent users.

    Placing Text and Statistics

        name = data['name']
        name_len = len(name)
        
        if name_len <= 16:
            font_size = 120
            pos = (95, 1360)
        elif 16 < name_len < 20:
            font_size = 90
            pos = (95, 1370)
        else: 
            font_size = 75
            pos = (95, 1375)
            
        c.setFont("Balsamiq", font_size)
        c.drawString(pos[0], pos[1], name)
    
        c.setFont("Poppins", 43.6)
        c.drawString(95, 1280, f"DoB: {data['dob']}")
        c.drawString(95, 1210, f"Zodiac: {data['zodiac']}")
        c.drawString(95, 1140, f"Age: {data['age']}")
        c.drawString(95, 1070, f"Height: {data['height']} cm")
    

    This part handles the placement of the user name and their personal statistics like age and height. We use conditional logic to adjust the font size of the name based on its length so that long names do not run off the page. The statistics are placed at specific vertical coordinates using the Poppins font to ensure consistency. This ensures a clean and structured look for the data heavy part of the profile. Dynamic font sizing is a crucial feature for generating documents that accommodate variable user input.

    Justified Paragraph Rendering

        style_justified = ParagraphStyle(
            name='Justified', fontName='Poppins', fontSize=35.3,
            leading=45, alignment=TA_JUSTIFY
        )
    
        about_text = data['about_me']
        width_about = 1075 - 95
        p_about = Paragraph(about_text, style_justified)
        w, h = p_about.wrap(width_about, page_height) 
        y_about = 795 - h + 45
        p_about.drawOn(c, 95, y_about)
    
        love_text = data['things_i_love']
        width_love = 1075 - 510
        p_love = Paragraph(love_text, style_justified)
        w_l, h_l = p_love.wrap(width_love, page_height)
        y_love = 320 - h_l + 45
        p_love.drawOn(c, 510, y_love)
    
        c.save()
        packet.seek(0)
        return packet
    

    We switch from simple string drawing to the Platypus Paragraph object to handle block text effectively. This allows us to use justified alignment which makes the text look neat and professional like a newspaper column. We calculate the height of the text block dynamically to ensure the text flows correctly without overlapping other elements. This prevents the text from breaking the layout regardless of how many lines are generated. Justified text is often considered more formal and easier to read in printed documents.

    PDF Merging Function

    def merge_pdfs(overlay_pdf_stream):
        try:
            existing_pdf = PdfReader(open("template.pdf", "rb"))
            output = PdfWriter()
            
            new_pdf = PdfReader(overlay_pdf_stream)
            
            page = existing_pdf.pages[0]
            page.merge_page(new_pdf.pages[0])
            output.add_page(page)
            
            out_buffer = BytesIO()
            output.write(out_buffer)
            out_buffer.seek(0)
            return out_buffer
        except FileNotFoundError:
            st.error("Could not find 'template.pdf' in the directory.")
            return None
    

    This function acts as the final assembly line where we combine the original graphical template with our newly created text and image layer. We use the pypdf library to overlay the new content on top of the existing design seamlessly. The result is written to a bytes buffer which allows the file to be downloaded directly from the browser memory. This approach is highly efficient because it avoids creating temporary files on the server which saves disk space. It essentially acts like printing text onto a preprinted letterhead.

    Streamlit UI Setup

    st.title("📄 PDF Profile Generator")
    st.markdown("Fill in the details below to generate your customized PDF.")
    
    with st.form("user_input_form"):
        col1, col2 = st.columns(2)
        with col1:
            name = st.text_input("Name", max_chars=25, help="Max 25 characters")
            dob = st.date_input("Date of Birth", min_value=date(1950, 1, 1))
            height = st.number_input("Height (cm)", min_value=50, max_value=250, value=170)
            company = st.text_input("Current Company")
            position = st.text_input("Current Position")
            uploaded_image = st.file_uploader("Upload Profile Picture", type=['jpg', 'png', 'jpeg'])
        with col2:
            qualification = st.text_input("Highest Qualification")
            experience = st.number_input("Work Experience (Years)", min_value=0.0, step=0.1)
            skills = st.text_input("Technical Skills (comma separated)")
            location = st.text_input("Location")
        st.markdown("---")
        st.subheader("Personal Interests")
        h1 = st.text_input("Hobby 1")
        h2 = st.text_input("Hobby 2")
        love_line = st.text_input("Things I love (One line)")
        submitted = st.form_submit_button("Generate Profile")
    

    We build the frontend interface here using Streamlit columns to organize the input fields neatly into a grid layout. The form includes specific constraints like maximum character counts and date ranges to prevent user errors before they happen. We also added a file uploader that restricts inputs to image formats only to ensure compatibility with our processing logic. This structured input method ensures that we receive valid data before attempting any expensive API calls or processing. Forms are essential in Streamlit to batch inputs and prevent reloading the script on every keystroke.

    Execution and Validation

    if submitted:
        if not name or not company or not skills:
            st.warning("Please fill in all required fields.")
        else:
            with st.spinner("Processing data and generating content..."):
                processed_img = None
                if uploaded_image:
                    processed_img = process_circular_image(uploaded_image)
    
                age = calculate_age(dob)
                zodiac = get_zodiac_sign(dob.day, dob.month)
                
                details_for_ai = {
                    "company": company, "position": position, 
                    "qualification": qualification, "experience": experience,
                    "skills": skills, "location": location,
                    "hobby1": h1, "hobby2": h2, "love_line": love_line
                }
                about_me, things_love = generate_ai_content(details_for_ai)
                
                pdf_data = {
                    "name": name, "dob": dob.strftime("%Y-%m-%d"),
                    "age": age, "zodiac": zodiac, "height": height,
                    "about_me": about_me, "things_i_love": things_love,
                    "profile_image": processed_img
                }
                st.success("Content Generated!")
                try:
                    reader = PdfReader("template.pdf")
                    page = reader.pages[0]
                    overlay = create_overlay_pdf(pdf_data, float(page.mediabox.width), float(page.mediabox.height))
                    if overlay:
                        final_pdf = merge_pdfs(overlay)
                        if final_pdf:
                            st.download_button("⬇️ Download Completed PDF", final_pdf, f"{name}_profile.pdf", "application/pdf")
                except Exception as e:
                    st.error(f"An error occurred: {e}")
    

    The final section triggers the actual processing sequence once the user clicks the submit button. We perform a validation check to ensure all required fields are present before proceeding to avoid incomplete profiles. If valid we run the logic functions and the AI generation in sequence while showing a spinner to keep the user informed. Finally we dynamically read the template dimensions and generate the download button that delivers the personalized PDF file. This orchestrates the entire application flow from input to final deliverable.

    Analyzing the streamlit page

    We design the frontend to capture essential user data, ranging from basic demographics like Name and Height to professional details like Company and Skills. To streamline the experience, we program the system to automatically calculate Age and Zodiac signs directly from the Date of Birth. The creative heavy lifting, writing the 'About Me' and 'Things I Love' sections, is delegated to the LLM, which synthesizes the input data. As illustrated in the interface image, clicking 'Generate Profile' triggers the API calls and instantly renders the final PDF for download.

    Reviewing the output

    Upon examining the generated PDF, we observe that every element has landed exactly on its designated coordinates. The profile image is perfectly centered, and the text fields strictly adhere to the defined left and right boundaries. The justified formatting is clearly visible, giving the paragraphs a professional, clean look with no spillover. Most importantly, the content reflects the strict word and character limits we imposed, demonstrating that our rule-based constraints have successfully disciplined the AI's output.

    Final Words

    This project serves as a proof of concept, demonstrating how easily we can automate complex documents combining AI-generated text and dynamic imagery. The principles we used here i.e. coordinates, templates, and strict prompting are universal. We encourage you to experiment further by building generators for marketing brochures, branded social media posts, company letterheads, or even structured financial documents like invoices. The potential to scale personalized content creation is limitless once you harness this hybrid automation approach.

    References

    Github link for the project

    Reportlab documentation

    Pypdf documentation


    Member-only content

    Unlock this article and our entire library

    abhishek.kumar@aimmediahouse.com

    Abhishek Kumar

    Abhishek is an AI and analytics professional with deep expertise in machine learning and data science. With a background in EdTech, he transitioned from Physics education to AI, self-learning Python and ML. As Manager cum Assistant Professor at Miles Education and Manager - AI Research at AIM, he focuses on AI applications, data science, and analytics, driving innovation in education and technology.

    Get Credentialed

    More Articles

    Comments (0)

    Join the conversation

    Sign in to comment

    Loading comments...