Part 2: Printing Semi Dynamic Layout Content (Invoices, Bills, etc…)

This is part of the series of introducing Vanilla ASP.NET Web Forms Architecture. Read more about this at the main menu: Main Article: [Introducing Vanilla ASP.NET Web Forms Architecture]

Part 2: Printing Semi Dynamic Layout Content (Invoices, Bills, etc…)

Introduction

In Part 1, we covered printing static layout content where the layout is fixed and predictable — like cinema tickets or ID cards. In this part, we’ll tackle semi-dynamic layouts — documents that have both fixed header/footer sections AND a variable-length body section that grows based on data.

The classic examples are:

  • Invoices
  • Bills / Receipts
  • Purchase Orders
  • Quotations
  • Delivery Orders

These documents share a common structure:

  1. Static Header — Company logo, invoice number, date, customer info
  2. Dynamic Body — Item rows that vary in quantity
  3. Static Footer — Totals, payment terms, bank details, signatures

The challenge? The item rows can be 1 item or 100 items, and we need the layout to handle both elegantly while maintaining proper page breaks for printing.

The Multi-Page Invoice Challenge

Here’s the real-world problem: What happens when an invoice has 30 items but the items section can only fit 5 items per page?

You can’t just dump all items onto one page — the footer (totals, bank details, signatures) would get pushed off the page or overlap with the items.

The Solution: Intelligent Pagination

The invoice needs to be split across multiple pages with proper structure:

PageHeaderItems SectionFooter
Page 1Full header with company & customer detailsItems 1-5“Continued on next page…”
Page 2Simplified header (invoice no, page number)Items 6-10“Continued on next page…”
Page 3Simplified headerItems 11-15“Continued on next page…”
Last PageSimplified headerRemaining itemsFull footer with totals, bank details, signatures

The key insight: Only the last page shows the totals and footer. All other pages show a continuation notice.

Visual Design – Semi Dynamic Layout (Invoice)

Example: A standard commercial invoice

Works on: A4/Letter paper printers

Imagine we have an invoice that will be printed on A4 paper, which will look something like this:

The invoice consists of:

  • Header Section: Company branding, invoice details, customer information
  • Items Table: Dynamic rows for purchased items (the semi-dynamic part)
  • Footer Section: Subtotal, tax, total amount, amount in words, payment terms, bank details, signatures

Data Models

Prepare the C# Class objects for the invoice data:

public class Invoice
{
    public int Id { get; set; }
    public string InvoiceNo { get; set; }
    public DateTime InvoiceDate { get; set; }
    public DateTime DueDate { get; set; }
    public string CustomerName { get; set; }
    public string CustomerAddress { get; set; }
    public string CustomerPhone { get; set; }
    public string CustomerEmail { get; set; }
    public decimal Subtotal { get; set; }
    public decimal TaxRate { get; set; }
    public decimal TaxAmount { get; set; }
    public decimal TotalAmount { get; set; }
    public string TotalAmountInWords { get; set; }
    public string PaymentTerms { get; set; }
    public string Notes { get; set; }
    public List<InvoiceItem> Items { get; set; }
}

public class InvoiceItem
{
    public int LineNo { get; set; }
    public string ItemCode { get; set; }
    public string ItemName { get; set; }
    public string Description { get; set; }
    public decimal Quantity { get; set; }
    public string Unit { get; set; }
    public decimal UnitPrice { get; set; }
    public decimal Subtotal { get; set; }
}

Number to Words Converter

A utility class to convert the total amount to English words:

public static class NumberToWords
{
    private static readonly string[] ones = {
        "", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine",
        "Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen",
        "Seventeen", "Eighteen", "Nineteen"
    };

    private static readonly string[] tens = {
        "", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"
    };

    public static string Convert(decimal amount)
    {
        if (amount == 0) return "Zero";

        long dollars = (long)Math.Floor(amount);
        int cents = (int)((amount - dollars) * 100);

        string result = ConvertWholeNumber(dollars);

        if (cents > 0)
        {
            result += $" and {cents:D2}/100";
        }

        return result + " Only";
    }

    private static string ConvertWholeNumber(long number)
    {
        if (number == 0) return "";
        if (number < 0) return "Negative " + ConvertWholeNumber(Math.Abs(number));

        string words = "";

        if (number / 1000000000 > 0)
        {
            words += ConvertWholeNumber(number / 1000000000) + " Billion ";
            number %= 1000000000;
        }

        if (number / 1000000 > 0)
        {
            words += ConvertWholeNumber(number / 1000000) + " Million ";
            number %= 1000000;
        }

        if (number / 1000 > 0)
        {
            words += ConvertWholeNumber(number / 1000) + " Thousand ";
            number %= 1000;
        }

        if (number / 100 > 0)
        {
            words += ConvertWholeNumber(number / 100) + " Hundred ";
            number %= 100;
        }

        if (number > 0)
        {
            if (number < 20)
                words += ones[number];
            else
            {
                words += tens[number / 10];
                if (number % 10 > 0)
                    words += "-" + ones[number % 10];
            }
        }

        return words.Trim();
    }
}

Understanding the Page Layout Dimensions

Before we dive into the pagination logic, let’s understand the page layout based on actual measurements:

Layout analysis:

A4 Invoice Layout 297mm height
Top Margin 15mm
Header Section 35mm
  • Company logo & details
  • Company address & contact
Items Section 90mm fixed
  • Table header
  • Item rows (~18mm each)
  • Fits approximately 5 items
Footer Section 70mm
  • Amount in words
  • Subtotal, tax, total
  • Notes & signatures
  • Bank details
297mm
Margin (15mm)
Header (35mm) / Footer (70mm)
Customer Details (58mm)
Items (90mm fixed)

Total Content Height: 35 + 58 + 90 + 70 = 253mm
(fits within A4’s 297mm with margins)

With 90mm for items and ~18mm per item row, we can fit approximately 5 items per page.

The Pagination Logic

The key to handling multi-page invoices is pre-calculating how many pages we need and which items go on each page.

/// <summary>
/// Split items into pages based on items per page limit
/// </summary>
public static List<List<InvoiceItem>> PaginateItems(List<InvoiceItem> items, int itemsPerPage = 5)
{
    var pages = new List<List<InvoiceItem>>();

    for (int i = 0; i < items.Count; i += itemsPerPage)
    {
        var pageItems = items.Skip(i).Take(itemsPerPage).ToList();
        pages.Add(pageItems);
    }

    return pages;
}

Example: 23 items with 5 items per page

Page 1: Items 1-5   (5 items)
Page 2: Items 6-10  (5 items)
Page 3: Items 11-15 (5 items)
Page 4: Items 16-20 (5 items)
Page 5: Items 21-23 (3 items) ← Last page, show footer

URL API Routing

Preparing the URL API Routing:

// Example of URL API format:

// Query String
// https://myweb.com/apiInvoice?invoice_id=123&autoprint=1

// MVC alike URL
// https://myweb.com/apiInvoice/123/1

// MVC alike URL Routing

using System.Web.Routing;

public class Global : System.Web.HttpApplication
{
    protected void Application_Start(object sender, EventArgs e)
    {
        // route the main api page
        RouteTable.Routes.MapPageRoute("apiInv1", "apiInvoice", "~/apiInvoice.aspx");

        // route all other URL patterns

        // default auto-print = true
        RouteTable.Routes.MapPageRoute("apiInv2", "apiInvoice/{invoice_id}", "~/apiInvoice.aspx");
        RouteTable.Routes.MapPageRoute("apiInv3", "apiInvoice/{invoice_id}/", "~/apiInvoice.aspx");

        // manually define auto-print
        RouteTable.Routes.MapPageRoute("apiInv4", "apiInvoice/{invoice_id}/{autoprint}", "~/apiInvoice.aspx");
        RouteTable.Routes.MapPageRoute("apiInv5", "apiInvoice/{invoice_id}/{autoprint}/", "~/apiInvoice.aspx");
    }
}

Invoice Generator Engine

The Invoice Generator Engine handles pagination and renders each page appropriately:

public class engineInvoice
{
    // Configuration: Items per page
    const int ITEMS_PER_PAGE = 5;

    public static string GenerateInvoice(int invoice_id, int autoprint)
    {
        // get the invoice data from database
        Invoice invoice = Database.GetInvoice(invoice_id);

        // invalid invoice_id
        if (invoice == null)
        {
            return null;
        }

        // no items in invoice
        if (invoice.Items == null || invoice.Items.Count == 0)
        {
            string errorHtml = ReportNoInvoice();
            return errorHtml;
        }

        // ======================================
        // PAGINATION: Split items into pages
        // ======================================
        var itemPages = PaginateItems(invoice.Items, ITEMS_PER_PAGE);
        int totalPages = itemPages.Count;

        StringBuilder sb = new StringBuilder();

        // render the HTML header
        sb.Append($@"
<!DOCTYPE html>
<html lang='en'>
<head>
    <meta charset='UTF-8'>
    <meta name='viewport' content='width=device-width, initial-scale=1.0'>
    <title>Invoice {invoice.InvoiceNo}</title>
    <style>

        /* CSS will be attached at the end of this article */

    </style>
</head>
<body>
        ");

        // perform auto-print
        if (autoprint == 1)
        {
            sb.Append("<script>window.onload = ()=> { window.print(); };</script>");
        }

        // ======================================
        // Generate each page
        // ======================================
        for (int pageNum = 0; pageNum < totalPages; pageNum++)
        {
            bool isFirstPage = (pageNum == 0);
            bool isLastPage = (pageNum == totalPages - 1);
            var pageItems = itemPages[pageNum];

            // Page break before (except first page)
            if (!isFirstPage)
            {
                sb.Append("<div style='page-break-before: always;'></div>");
            }

            // Render page content
            sb.Append("<div class='invoice'>");

            // Header (on every page)
            RenderHeader(sb, invoice);

            // Customer & Invoice Info (on every page, with page number)
            RenderInvoiceDetails(sb, invoice, pageNum + 1, totalPages);

            // Items section for this page
            RenderItemsSection(sb, pageItems);

            // Footer sections only on last page
            if (isLastPage)
            {
                RenderTotalsSection(sb, invoice);
                RenderFooterSection(sb, invoice);
                RenderBankDetails(sb);
            }
            else
            {
                // Continuation note on non-last pages
                sb.Append(@"
    <div class='continuation-note'>
        Continued on next page...
    </div>
                ");
            }

            sb.Append("</div>"); // close .invoice
        }

        // render the footer
        sb.Append("</body></html>");

        return sb.ToString();
    }

    /// <summary>
    /// Split items into pages
    /// </summary>
    static List<List<InvoiceItem>> PaginateItems(List<InvoiceItem> items, int itemsPerPage)
    {
        var pages = new List<List<InvoiceItem>>();

        for (int i = 0; i < items.Count; i += itemsPerPage)
        {
            var pageItems = items.Skip(i).Take(itemsPerPage).ToList();
            pages.Add(pageItems);
        }

        return pages;
    }

    /// <summary>
    /// Render company header
    /// </summary>
    static void RenderHeader(StringBuilder sb, Invoice invoice)
    {
        sb.Append($@"
    <div class='invoice-header'>
        <div class='company-info'>
            <div class='company-logo'>LOGO</div>
            <div class='company-details'>
                <div class='company-name'>STELLAR DYNAMICS INC</div>
                <div class='company-reg'>Registration No: SD-98765-X</div>
                <div class='company-address'>
                    742 Innovation Boulevard, Tower A<br/>
                    Neo City, 10001<br/>
                    Novaterra
                </div>
                <div class='company-contact'>
                    Tel: +99 5-5555 1234 | Email: sales@stellardynamics.nov
                </div>
            </div>
        </div>
        <div class='invoice-title'>
            <h1>INVOICE</h1>
        </div>
    </div>
        ");
    }

    /// <summary>
    /// Render invoice details and customer info with page number
    /// </summary>
    static void RenderInvoiceDetails(StringBuilder sb, Invoice invoice, int currentPage, int totalPages)
    {
        sb.Append($@"
    <div class='invoice-details-section'>
        <div class='customer-info'>
            <div class='section-title'>Bill To:</div>
            <div class='customer-name'>{invoice.CustomerName}</div>
            <div class='customer-address'>{invoice.CustomerAddress}</div>
            <div class='customer-contact'>
                {(string.IsNullOrEmpty(invoice.CustomerPhone) ? "" : $"Tel: {invoice.CustomerPhone}<br/>")}
                {(string.IsNullOrEmpty(invoice.CustomerEmail) ? "" : $"Email: {invoice.CustomerEmail}")}
            </div>
        </div>
        <div class='invoice-info'>
            <table class='info-table'>
                <tr>
                    <td class='info-label'>Invoice No:</td>
                    <td class='info-value'>{invoice.InvoiceNo}</td>
                </tr>
                <tr>
                    <td class='info-label'>Invoice Date:</td>
                    <td class='info-value'>{invoice.InvoiceDate:dd MMM yyyy}</td>
                </tr>
                <tr>
                    <td class='info-label'>Due Date:</td>
                    <td class='info-value'>{invoice.DueDate:dd MMM yyyy}</td>
                </tr>
                <tr>
                    <td class='info-label'>Payment Terms:</td>
                    <td class='info-value'>{invoice.PaymentTerms}</td>
                </tr>
                <tr>
                    <td class='info-label'>Page:</td>
                    <td class='info-value'>{currentPage} of {totalPages}</td>
                </tr>
            </table>
        </div>
    </div>
        ");
    }

    /// <summary>
    /// Render items table for current page
    /// </summary>
    static void RenderItemsSection(StringBuilder sb, List<InvoiceItem> items)
    {
        sb.Append(@"
    <div class='items-section'>
        <table class='items-table'>
            <thead>
                <tr>
                    <th class='col-no'>No</th>
                    <th class='col-code'>Item Code</th>
                    <th class='col-desc'>Description</th>
                    <th class='col-qty'>Qty</th>
                    <th class='col-unit'>Unit</th>
                    <th class='col-price'>Unit Price</th>
                    <th class='col-total'>Amount</th>
                </tr>
            </thead>
            <tbody>
        ");

        foreach (var item in items)
        {
            sb.Append($@"
                <tr>
                    <td class='col-no'>{item.LineNo}</td>
                    <td class='col-code'>{item.ItemCode}</td>
                    <td class='col-desc'>
                        <div class='item-name'>{item.ItemName}</div>
                        {(string.IsNullOrEmpty(item.Description) ? "" : $"<div class='item-desc'>{item.Description}</div>")}
                    </td>
                    <td class='col-qty'>{item.Quantity:#,##0.##}</td>
                    <td class='col-unit'>{item.Unit}</td>
                    <td class='col-price'>{item.UnitPrice:#,##0.00}</td>
                    <td class='col-total'>{item.Subtotal:#,##0.00}</td>
                </tr>
            ");
        }

        sb.Append(@"
            </tbody>
        </table>
    </div>
        ");
    }

    /// <summary>
    /// Render totals section (only on last page)
    /// </summary>
    static void RenderTotalsSection(StringBuilder sb, Invoice invoice)
    {
        sb.Append($@"
    <div class='totals-section'>
        <div class='amount-words'>
            <div class='words-label'>Amount in Words:</div>
            <div class='words-value'>{invoice.TotalAmountInWords}</div>
        </div>
        <div class='totals-table-container'>
            <table class='totals-table'>
                <tr>
                    <td class='totals-label'>Subtotal:</td>
                    <td class='totals-value'>{invoice.Subtotal:#,##0.00}</td>
                </tr>
                <tr>
                    <td class='totals-label'>Tax ({invoice.TaxRate}%):</td>
                    <td class='totals-value'>{invoice.TaxAmount:#,##0.00}</td>
                </tr>
                <tr class='grand-total'>
                    <td class='totals-label'>Total Amount:</td>
                    <td class='totals-value'>{invoice.TotalAmount:#,##0.00}</td>
                </tr>
            </table>
        </div>
    </div>
        ");
    }

    /// <summary>
    /// Render footer section with notes and signatures (only on last page)
    /// </summary>
    static void RenderFooterSection(StringBuilder sb, Invoice invoice)
    {
        sb.Append($@"
    <div class='footer-section'>
        <div class='notes-section'>
            <div class='notes-title'>Notes:</div>
            <div class='notes-content'>{(string.IsNullOrEmpty(invoice.Notes) ? "Thank you for your business!" : invoice.Notes)}</div>
        </div>
        <div class='signature-section'>
            <div class='signature-box'>
                <div class='signature-line'></div>
                <div class='signature-title'>Authorized Signature</div>
            </div>
            <div class='signature-box'>
                <div class='signature-line'></div>
                <div class='signature-title'>Customer Signature</div>
            </div>
        </div>
    </div>
        ");
    }

    /// <summary>
    /// Render bank details (only on last page)
    /// </summary>
    static void RenderBankDetails(StringBuilder sb)
    {
        sb.Append(@"
    <div class='bank-details'>
        Bank Details for Payment:<br/>
        Bank: Nevotran Bank | Account Name: STELLAR DYNAMICS INC | Account No: 1234-5678-9012
    </div>
        ");
    }

    // No invoice found
    static string ReportNoInvoice()
    {
        string errorHtml = @"
<!DOCTYPE html>
<html lang='en'>
<head>
    <meta charset='UTF-8'>
    <meta name='viewport' content='width=device-width, initial-scale=1.0'>
    <title>Invoice Not Found</title>
</head>
<body>
    <script>
        window.onload = function() {
            alert('Invoice not found');
            if (window.parent && window.parent !== window) {
                window.parent.postMessage('no-invoice-found', '*');
            }
        };
    </script>
    <div style='display: none;'>Invoice not found</div>
</body>
</html>";

        return errorHtml;
    }
}

Advanced: Dynamic Height Calculation

The simple approach above uses a fixed “5 items per page” rule. But what if item descriptions vary in length? One item might have a short description (1 line) while another has a detailed spec (4 lines).

The Problem:

Item 1: "Widget A" (1 line) → ~18mm
Item 2: "Complex Widget B with extended specifications, 
         warranty information, and detailed installation 
         instructions that wrap to multiple lines" → ~30mm

The Solution: Calculate actual row heights before pagination

public class InvoicePaginationEngine
{
    // Configuration Constants (in millimeters)
    const double MAX_ITEMS_HEIGHT_MM = 90.0;
    const double TABLE_HEADER_HEIGHT_MM = 8.0;
    const double BASE_ROW_HEIGHT_MM = 10.0;
    const double DESC_LINE_HEIGHT_MM = 4.0;
    const double ROW_PADDING_MM = 4.0;
    const int CHARS_PER_DESC_LINE = 50;

    /// <summary>
    /// Calculate the estimated height of a single item row
    /// </summary>
    static double CalculateItemHeight(InvoiceItem item)
    {
        // Start with base height (item name line)
        double height = BASE_ROW_HEIGHT_MM;

        // Calculate description lines if description exists
        if (!string.IsNullOrEmpty(item.Description))
        {
            int descLines = CalculateTextLines(item.Description, CHARS_PER_DESC_LINE);
            height += descLines * DESC_LINE_HEIGHT_MM;
        }

        // Add padding
        height += ROW_PADDING_MM;

        return height;
    }

    /// <summary>
    /// Calculate how many lines a text will wrap to
    /// </summary>
    static int CalculateTextLines(string text, int charsPerLine)
    {
        if (string.IsNullOrEmpty(text)) return 0;

        int lines = (int)Math.Ceiling((double)text.Length / charsPerLine);
        int explicitBreaks = text.Count(c => c == '\n');

        return Math.Max(lines, explicitBreaks + 1);
    }

    /// <summary>
    /// Split items into pages based on calculated heights
    /// </summary>
    public static List<List<InvoiceItem>> PaginateItemsByHeight(List<InvoiceItem> items)
    {
        var pages = new List<List<InvoiceItem>>();
        var currentPage = new List<InvoiceItem>();

        double availableHeight = MAX_ITEMS_HEIGHT_MM - TABLE_HEADER_HEIGHT_MM;
        double currentPageHeight = 0;

        foreach (var item in items)
        {
            double itemHeight = CalculateItemHeight(item);

            if (currentPageHeight + itemHeight <= availableHeight)
            {
                currentPage.Add(item);
                currentPageHeight += itemHeight;
            }
            else
            {
                if (currentPage.Count > 0)
                {
                    pages.Add(currentPage);
                }

                currentPage = new List<InvoiceItem> { item };
                currentPageHeight = itemHeight;
            }
        }

        if (currentPage.Count > 0)
        {
            pages.Add(currentPage);
        }

        return pages;
    }
}

Visual explanation of height-based pagination:

Page Break Logic
Page 1 (90mm available)
Table Header 8mm
Item 1 – Short desc
18mm
Item 2 – Short desc
18mm
Item 3 – Long description   that wraps to   multiple lines
26mm
Item 4 – Short desc
18mm
88mm total (fits!)
Item 5 won’t fit! → Move to Page 2
Page 2 (90mm available)
Table Header 8mm
Item 5 – Short desc
18mm
Item 6 – Very long desc   line 2   line 3
30mm
Item 7 – Short desc
18mm

Backend API Page

The Backend API page, create a blank ASP.NET Web Forms:

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="apiInvoice.aspx.cs" Inherits="myweb.apiInvoice" %>

Delete all the frontend markup and leave only the first line page directive.

The code behind:

protected void Page_Load(object sender, EventArgs e)
{
    // user authentication verification
    if (Session["login_user"] == null) 
    {
        // Option 1: 404 Page not found
        Response.StatusCode = 404;
        return;

        // Option 2: 401 Unauthorized
        Response.StatusCode = 401;
        return;
    }

    int invoice_id = 0;

    // auto-print by default
    int auto_print = 1;

    // ===========================
    // Obtain Request Data
    // ===========================

    // Method 1: Query String & Form Post
    if (!int.TryParse(Request["invoice_id"], out invoice_id) || invoice_id <= 0)
    {
        // 400 Bad Request
        Response.StatusCode = 400;
        return;
    }

    if (Request["autoprint"] != null) 
    {
        int.TryParse(Request["autoprint"] + "", out auto_print);
    }

    // Method 2: JSON body request
    string jsonBody;
    using (var reader = new StreamReader(Request.InputStream, Encoding.UTF8))
    {
        jsonBody = reader.ReadToEnd();
    }
    var invoiceRequest = JsonSerializer.Deserialize<InvoiceRequest>(jsonBody);
    invoice_id = invoiceRequest.InvoiceId;
    auto_print = invoiceRequest.AutoPrint;

    // ==================================
    // Generate the Invoice HTML document
    // ==================================

    var html = engineInvoice.GenerateInvoice(invoice_id, auto_print);

    if (html == null)
    {
        // 400 Bad Request
        Response.StatusCode = 400;
        return;
    }

    // write the HTML page output to the client's browser
    Response.Write(html);
}

Frontend Print Request

Auto-Print, No Preview Needed

<button type="button" onclick="printInvoice();">Print Invoice</button>

<!-- hidden iframe -->
<iframe id="frameprint" src="" style="height:0; width:0; border:none; overflow:hidden;"></iframe>

<script>

    // Global state
    const urlApi = "/apiInvoice";
    let auto_print = 1;
    let invoice_id = 123;
    let frameprint = document.querySelector("#frameprint");

    function printInvoice() {
        let url = `/apiInvoice?invoice_id=${invoice_id}&autoprint=${auto_print}`;
        frameprint.src = url;
    }

    // Listening any message from the iframe
    document.addEventListener('DOMContentLoaded', function() {
        window.addEventListener('message', handleIframeMessages);
    });

    function handleIframeMessages(event) {
        // Verify origin for security
        if (event.origin !== window.location.origin) return;

        switch(event.data) {
            case 'no-invoice-found':
                showNotification('Invoice not found', 'error');
                enableFormControls(true);
                document.querySelector("#frameprint").src = '';
                break;

            default:
                console.log('Unknown message from iframe:', event.data);
        }
    }

</script>

Print Preview, No Auto Print

Show the print preview in iframe:

<style>
.div-print-preview {
    position: fixed;
    top: 20px;
    left: 20px;
    right: 20px;
    bottom: 20px;
    border: 2px solid gray;
    padding: 10px;
    display: none;
    z-index: 999999;
    background: white;
}

.div-print-preview-button {
    background: #35b1d4;
    color: white;
    display: inline-block;
    padding: 10px 20px;
    border: none;
    cursor: pointer;
}

.div-print-preview-iframe {
    position: absolute;
    top: 60px;
    left: 10px;
    right: 10px;
    bottom: 10px;
    width: auto;
    height: auto;
    border: 1px solid gray;
}
</style>

<button type="button" onclick="showPrintPreview();">Print Preview</button>
<div id="divPrintPreview" class="div-print-preview">
    <button type="button" class="div-print-preview-button" onclick="printIframe();">Print</button>
    <button type="button" class="div-print-preview-button" onclick="closePrintPreview();">Close</button>
    <iframe id="frameprint" class="div-print-preview-iframe"/>
</div>

<script>

// Global state
const urlApi = "/apiInvoice";
let auto_print = 0;     // don't auto print 
let invoice_id = 123;
let divPrintPreview = document.querySelector("#divPrintPreview");
let frameprint = document.querySelector("#frameprint");

function showPrintPreview() {
  let url = `/apiInvoice?invoice_id=${invoice_id}&autoprint=${auto_print}`;
  frameprint.src = url;
  divPrintPreview.style.display = "block"; // display the preview container
}

function printIframe() {
  frameprint.contentWindow.print();  // print only the iframe content
}

function closePrintPreview() {
  divPrintPreview.style.display = "none"; // hide the preview container  
  frameprint.src = ""; // clear the preview
}

</script>

The CSS for The Invoice

body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    font-size: 12px;
    line-height: 1.4;
    color: #333;
    background: #f5f5f5;
    padding: 0;
    margin: 0;
}

.invoice {
    padding-top: 15mm;
    width: 180mm;
    background: white;
    margin: 0 auto;
    box-shadow: 0 0 10px rgba(0,0,0,0.1);
}

/* Header Section */
.invoice-header {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    border-bottom: 3px solid #2c5aa0;
    padding-bottom: 15px;
    margin-bottom: 20px;
}

.company-info {
    display: flex;
    gap: 15px;
}

.company-logo {
    width: 80px;
    height: 60px;
    border: 2px dashed #999;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 14px;
    color: #666;
    background: #f0f0f0;
}

.company-name {
    font-size: 20px;
    font-weight: bold;
    color: #2c5aa0;
    margin-bottom: 5px;
}

.company-reg {
    font-size: 10px;
    color: #666;
    margin-bottom: 5px;
}

.company-address {
    font-size: 11px;
    color: #444;
    margin-bottom: 5px;
}

.company-contact {
    font-size: 10px;
    color: #666;
}

.invoice-title h1 {
    font-size: 32px;
    color: #2c5aa0;
    letter-spacing: 2px;
}

/* Invoice Details Section */
.invoice-details-section {
    display: flex;
    justify-content: space-between;
    margin-bottom: 25px;
    gap: 20px;
}

.customer-info {
    flex: 1;
    background: #f8f9fa;
    padding: 15px;
    border-radius: 5px;
    border-left: 4px solid #2c5aa0;
}

.section-title {
    font-size: 10px;
    text-transform: uppercase;
    color: #666;
    margin-bottom: 8px;
    letter-spacing: 1px;
}

.customer-name {
    font-size: 14px;
    font-weight: bold;
    color: #333;
    margin-bottom: 5px;
}

.customer-address {
    font-size: 11px;
    color: #444;
    margin-bottom: 5px;
    white-space: pre-line;
}

.customer-contact {
    font-size: 10px;
    color: #666;
}

.invoice-info {
    width: 250px;
}

.info-table {
    width: 100%;
    border-collapse: collapse;
}

.info-table td {
    padding: 6px 8px;
    border: 1px solid #ddd;
}

.info-label {
    background: #f8f9fa;
    font-weight: 600;
    color: #555;
    width: 100px;
}

.info-value {
    font-weight: bold;
    color: #333;
}

/* Items Table */
.items-section {
    margin-bottom: 5mm;
    min-height: 90mm;
}

.items-table {
    width: 100%;
    border-collapse: collapse;
    font-size: 11px;
}

.items-table thead {
    display: table-header-group;
}

.items-table th {
    background: #2c5aa0;
    color: white;
    padding: 10px 8px;
    text-align: left;
    font-weight: 600;
    text-transform: uppercase;
    font-size: 10px;
    letter-spacing: 0.5px;
}

.items-table td {
    padding: 10px 8px;
    border-bottom: 1px solid #eee;
    vertical-align: top;
}

.items-table tbody tr:nth-child(even) {
    background: #fafafa;
}

.items-table tbody tr:hover {
    background: #f0f7ff;
}

/* Column Widths */
.col-no { width: 40px; text-align: center; }
.col-code { width: 80px; }
.col-desc { width: auto; }
.col-qty { width: 60px; text-align: right; }
.col-unit { width: 50px; text-align: center; }
.col-price { width: 90px; text-align: right; }
.col-total { width: 100px; text-align: right; font-weight: 600; }

.item-name {
    font-weight: 600;
    color: #333;
}

.item-desc {
    font-size: 10px;
    color: #666;
    margin-top: 3px;
}

/* Totals Section */
.totals-section {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    margin-bottom: 25px;
    padding-top: 15px;
    border-top: 2px solid #2c5aa0;
}

.amount-words {
    flex: 1;
    padding-right: 30px;
}

.words-label {
    font-size: 10px;
    color: #666;
    text-transform: uppercase;
    margin-bottom: 5px;
}

.words-value {
    font-size: 12px;
    font-weight: 600;
    color: #333;
    font-style: italic;
    background: #f8f9fa;
    padding: 10px;
    border-radius: 5px;
}

.totals-table-container {
    width: 250px;
}

.totals-table {
    width: 100%;
    border-collapse: collapse;
}

.totals-table td {
    padding: 8px 10px;
    border: 1px solid #ddd;
}

.totals-label {
    text-align: right;
    color: #555;
    background: #f8f9fa;
}

.totals-value {
    text-align: right;
    font-weight: 600;
    width: 100px;
}

.grand-total td {
    background: #2c5aa0 !important;
    color: white !important;
    font-size: 14px;
    font-weight: bold;
}

.grand-total .totals-label {
    background: #2c5aa0 !important;
    color: white !important;
}

/* Footer Section */
.footer-section {
    display: flex;
    justify-content: space-between;
    margin-bottom: 20px;
    gap: 30px;
}

.notes-section {
    flex: 1;
}

.notes-title {
    font-size: 10px;
    text-transform: uppercase;
    color: #666;
    margin-bottom: 5px;
}

.notes-content {
    font-size: 11px;
    color: #444;
    background: #fffbf0;
    padding: 10px;
    border-radius: 5px;
    border-left: 3px solid #f0ad4e;
}

.signature-section {
    display: flex;
    gap: 40px;
}

.signature-box {
    text-align: center;
    width: 150px;
}

.signature-line {
    border-bottom: 1px solid #333;
    height: 50px;
    margin-bottom: 5px;
}

.signature-title {
    font-size: 10px;
    color: #666;
}

/* Bank Details */
.bank-details {
    background: #f8f9fa;
    padding: 10px 15px;
    border-radius: 5px;
    border: 1px dashed #ccc;
    margin-top: 20px;
}

.bank-title {
    font-size: 10px;
    text-transform: uppercase;
    color: #666;
    margin-bottom: 5px;
}

.bank-info {
    font-size: 11px;
    color: #333;
}

/* Continuation Note */
.continuation-note {
    text-align: right;
    font-style: italic;
    color: #666;
    padding: 10px;
    margin-top: 20px;
    border-top: 1px dashed #ccc;
}

/* Print Styles */
@media print {
    body {
        background: white;
    }

    .invoice {
        box-shadow: none;
        margin: 0;
        padding: 10mm;
        width: 100%;
    }

    .items-table thead {
        display: table-header-group;
    }

    .items-table tr {
        break-inside: avoid;
        page-break-inside: avoid;
    }

    .totals-section {
        break-inside: avoid;
        page-break-inside: avoid;
    }

    .footer-section {
        break-inside: avoid;
        page-break-inside: avoid;
    }
}

@page {
    size: A4;
    margin: 0;

    @top-center { content: none; }
    @bottom-center { content: none; }
}

Fine-Tuning Tips

The height estimation is an approximation. To get accurate values for your specific design:

  1. Print a test page with known items of varying description lengths
  2. Measure actual heights with a ruler
  3. Adjust constants until pagination matches reality
// If items overflow, DECREASE these values:
const double BASE_ROW_HEIGHT_MM = 10.0;  // Try 12.0
const int CHARS_PER_DESC_LINE = 50;       // Try 45

// If too much empty space, INCREASE these values

Summary

In this part, we covered:

  1. The Multi-Page Challenge — Understanding why invoices need intelligent pagination
  2. Data Structure — Separate models for invoice header and line items
  3. Number to Words — Converting currency amounts to English text
  4. Pagination Logic — Simple fixed-count and advanced height-based approaches
  5. Generator Engine — Building HTML with page-aware rendering (footer only on last page)
  6. Professional Styling — Clean, print-friendly CSS with proper page breaks

The key difference from Part 1 (static layouts) is the pagination handling for the dynamic item rows. While the header and footer remain fixed, the items table must be intelligently split across pages while ensuring the totals and footer only appear on the final page.

In Part 3, we’ll explore fully dynamic layouts for reports and data grids where even the columns and structure can change based on the data.

Feature Photo by Leeloo The First:
https://www.pexels.com/photo/a-notebook-and-pen-near-the-laptop-and-documents-on-the-table-8962476/