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…)
- Part 1: Printing Static Layout Content (Tickets, PVC ID Card, etc…)
- Part 2: Printing Semi Dynamic Layout Content (Invoices, Bills, etc…)
- Part 3: Printing Full Dynamic Layout Content (Reports, Data Grids, etc…)
- Part 4.1: Generate PDF Using Chrome.exe in ASP.NET Web Forms
- Part 4.2: Generate PDF Using Puppeteer Sharp in ASP.NET Web Forms
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:
- Static Header — Company logo, invoice number, date, customer info
- Dynamic Body — Item rows that vary in quantity
- 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:
| Page | Header | Items Section | Footer |
|---|---|---|---|
| Page 1 | Full header with company & customer details | Items 1-5 | “Continued on next page…” |
| Page 2 | Simplified header (invoice no, page number) | Items 6-10 | “Continued on next page…” |
| Page 3 | Simplified header | Items 11-15 | “Continued on next page…” |
| … | … | … | … |
| Last Page | Simplified header | Remaining items | Full 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:
- Company logo & details
- Company address & contact
- Table header
- Item rows (~18mm each)
- Fits approximately 5 items
- Amount in words
- Subtotal, tax, total
- Notes & signatures
- Bank details
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 footerURL 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" → ~30mmThe 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:
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:
- Print a test page with known items of varying description lengths
- Measure actual heights with a ruler
- 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 valuesSummary
In this part, we covered:
- The Multi-Page Challenge — Understanding why invoices need intelligent pagination
- Data Structure — Separate models for invoice header and line items
- Number to Words — Converting currency amounts to English text
- Pagination Logic — Simple fixed-count and advanced height-based approaches
- Generator Engine — Building HTML with page-aware rendering (footer only on last page)
- 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/
