Part of the Olevsoft toolset — used in production by
IMS2, a warehouse management system
(live demo, login admin / USR).
See also UIKit (UI components) and
IMSD (desktop distribution).
PForm2 is a report engine that turns an HTML template and data into a print-ready document. Templates are designed in the visual Editor2 — you see a table, place fields, format cells, and get a finished report.
┌──────────────┐
│ Editor2 │ ← you design the template here
│ (browser) │
└──────┬───────┘
│ saves
▼
┌──────────────┐
│ Template │ ← .html file with blocks and fields
│ (.html) │
└──────┬───────┘
│ + data from database
▼
┌──────────────┐
│ ReportEngine │ ← PHP engine renders the report
│ (PHP) │
└──────┬───────┘
│
▼
┌──────────────┐
│ Output │ ← printable HTML page
│ (HTML) │
└──────────────┘
A report consists of blocks. Each block is a group of table rows that prints at a specific moment. Similar to bands in FastReport.
The engine does not impose any naming rules — you can name blocks anything you want. However, these names are commonly used by convention:
| Name | Typical usage |
|---|---|
header |
Report/document header |
colheads |
Column headers |
data |
Data rows |
endReport |
Footer with totals |
pagefooter |
Bottom of page |
serial |
Sub-detail rows |
groupheader / groupfooter |
Group breaks |
These are just conventions — the engine treats all block names equally. Your PHP code decides which block to render and when.
The first column (column A) in every template is special — it contains block names. This column is never printed. It tells the engine where each block starts and ends.
Column A Column B Column C
──────────────────────────────────────────────────
{header}
INVOICE Date: {$date}
Customer: {$customer}
{/}
──────────────────────────────────────────────────
{data}
{$item_name} {$price:f2}
{/}
──────────────────────────────────────────────────
{blockname}— starts a block{/}— ends a block- Everything between them belongs to the block
Important! Each block is a separate
<table>in the HTML file. This means each block can have different column count and widths.
Fields are placeholders that get replaced with actual data values. They are written as {$field_name}.
{$customer_name} → "Acme Corporation"
{$total:f2} → "1,234.50"
{$__rownum__} → "1", "2", "3", ...
{$field_name:flag1,flag2,...}
Flags are added after the colon, separated by commas.
| Flag | Example | Result | Description |
|---|---|---|---|
f2 |
{$price:f2} |
12.50 |
2 decimal places |
f4 |
{$qty:f4} |
3.0000 |
4 decimal places |
f0 |
{$count:f0} |
150 |
No decimals |
| Flag | Effect |
|---|---|
z |
Suppress zeros — empty string instead of "0" |
s |
Suppress repeats — show value only when it changes |
nowrap |
Never break this field's text mid-word — wraps output in <span style="white-space:nowrap"> |
Example:
{$group_name:s} — prints group name only on first row
{$qty:z,f2} — "15.00" or "" (not "0.00")
{$title:nowrap} — long header text stays on one line
nowrap exists because block tables (setBlockTables(true)) force
word-break:break-all on every <td> so long values don't overflow narrow
columns — fine for body text, but it can split short labels (report titles,
codes) mid-character on tight columns. Use nowrap on the specific fields
that must stay intact; {$__rownum__} gets this behavior automatically
without the flag.
| Flag | Effect |
|---|---|
upper |
UPPERCASE |
lower |
lowercase |
max20 |
Truncate to 20 characters |
date |
Format as date (DD.MM.YYYY) |
datetime |
Format as date+time |
These fields are filled automatically.
| Field | Value |
|---|---|
{$__rownum__} |
Row number (1, 2, 3...) |
{$__page__} |
Page number |
{$__date__} |
Current date |
{$__time__} |
Current time |
Accumulators automatically sum up field values as data rows are printed. When you need a total at the bottom of the report — accumulators do it for you.
In the {data} block, add SUM(N) flag to the field. N is the accumulator level (1, 2, 3...).
{$qty:SUM(1),f2} — accumulates qty into level 1
{$amount:SUM(2),f2} — accumulates amount into levels 1 and 2
SUM(2)means: accumulate into level 1 AND level 2 simultaneously.
In the {endReport} or footer block, use {$SUM($field,N)} to output the accumulated value.
{$SUM($qty,1):f2} — outputs total qty, resets level 1
{$SUM($amount,1):f2} — outputs total amount, resets level 1
Important! After outputting, the accumulator is automatically reset to zero.
{data}
# Item Qty Amount
{$item} {$qty:SUM(1),f2} {$amount:SUM(1),f2}
{/}
{endReport}
TOTAL: {$SUM($qty,1):f2} {$SUM($amount,1):f2}
{/}
If data is: Laptop (2, $1998), Mouse (10, $290), Monitor (3, $1497):
Result:
# Item Qty Amount
1 Laptop 2.00 1998.00
2 Mouse 10.00 290.00
3 Monitor 3.00 1497.00
TOTAL: 15.00 3785.00
Use multiple levels for subtotals within groups. Level 1 resets at group breaks, level 2 keeps the grand total.
{data}
{$category:s} {$item} {$amount:SUM(2),f2}
{/}
{groupfooter}
Subtotal: {$SUM($amount,1):f2}
{/}
{endReport}
Grand total: {$SUM($amount,2):f2}
{/}
$gen->setPageSize(40); // 40 data rows per pageWhen data exceeds the page size, the engine automatically starts a new page.
To repeat column headers on each new page:
$gen->onStartPage('colheads');Now colheads block will be printed at the top of every page.
$gen->newPage();Start servers first:
ims
# Or: ~/Desktop/start_servers.shOpen in browser: http://localhost:8001/editor2/index.html
You can open a specific template by adding ?file= parameter to the URL:
http://localhost:8001/editor2/index.html?file=ims2/reports/company_invoice/company_invoice.html
The path is relative to the base directory (/home/evge/Desktop/). The template loads automatically when the page opens.
Open editor with a file panel showing all templates in a directory:
http://localhost:8001/editor2/index.html?dir=ims2/reports
A panel appears on the left with all .html files. Click any file to load it. You can combine both parameters:
http://localhost:8001/editor2/index.html?dir=ims2/reports&file=ims2/reports/company_invoice/company_invoice.html
- Click "Add Block" in the toolbar
- Enter the block name (e.g.
header) - The block appears as a separate grid with its own columns
Repeat for each block you need: header, colheads, data, endReport.
- Click on a cell
- Type text or a field placeholder:
{$customer_name} - Press Enter to confirm or Escape to cancel
Tip: You can start typing immediately — just click a cell and type. No need to press Enter first.
Use the formatting toolbar above the grid:
- Bold (Ctrl+B) / Italic (Ctrl+I)
- Font size — select from dropdown (8-24px)
- Alignment — left, center, right
- Text color / Background color — pick from palette
- Borders — 15 types (top, bottom, left, right, all, none, etc.) with thickness (thin, medium, thick)
Drag the column border in the header row left or right.
Note: Each block has independent columns. If you need the same widths across blocks, use Link Blocks.
- Click "View" button
- Paste test data in JSON format into the text area
- See the rendered report
- Click "Save"
- Enter file path (e.g.
reports/invoice/invoice.html) - The file is saved to the server
| Action | How |
|---|---|
| Insert row below | Shift+Enter |
| Delete row | Alt+Delete |
| Move row up/down | Alt+Shift+Up/Down |
| Action | How |
|---|---|
| Move block up/down | Alt+Up/Down |
| Link blocks (sync widths) | Link Blocks button |
| Action | How |
|---|---|
| Bold | Ctrl+B |
| Italic | Ctrl+I |
| Copy format to row | Fmt → Row button |
| Action | How |
|---|---|
| Undo (60 levels) | Ctrl+Z |
| Redo | Ctrl+Y |
| Key | Action |
|---|---|
| Arrow keys | Move between cells |
| Tab / Shift+Tab | Next/previous cell (cross-block) |
| Enter / F2 | Start editing |
| Escape | Cancel editing |
| Delete / Backspace | Clear cell |
The dashed vertical line shows the page width boundary:
- A4 portrait — 740px
- A4 landscape — 1050px
<?php
require_once '/path/to/PForm2/ReportEngine/src/ReportEngine.php';
// 1. Create engine and load template
$gen = new ReportEngine();
$gen->read(__DIR__ . '/my_report.html');
$gen->setBlockTables(true);
// 2. Open document
echo $gen->startDocument();
// 3. Print header
echo $gen->renderBlock('header', [
'title' => 'Monthly Report',
'date' => date('d.m.Y'),
]);
// 4. Print data rows
foreach ($data as $row) {
$gen->rowNumber++;
$gen->print('data', $row);
}
// 5. Print footer with totals
echo $gen->renderBlock('endReport', []);
// 6. Close document
echo $gen->endDocument();$gen = new ReportEngine();
$gen->read(__DIR__ . '/invoice.html');
$gen->setBlockTables(true);
$gen->setPageSize(40); // 40 rows per page
if ($gen->hasBlock('colheads'))
$gen->onStartPage('colheads'); // repeat on each page
echo $gen->startDocument();
echo $gen->renderBlock('header', $headerData);
echo $gen->renderBlock('colheads', []);
foreach ($items as $item) {
$gen->rowNumber++;
$gen->print('data', $item); // auto page break + colheads repeat
}
echo $gen->renderBlock('endReport', []);
echo $gen->endDocument();// Register sub-detail handler
$gen->onSub('data', 'serialhdr', 'serial', function($parentRow) use ($conn) {
// return detail rows for this parent
$stmt = $conn->prepare("SELECT * FROM serials WHERE item_id = ?");
$stmt->execute([$parentRow['item_id']]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
});
// Now print('data', ...) will automatically render serial rows after each itemforeach ($data as $row) {
// Check if group changed
if ($gen->checkBreak(1, $row['category'])) {
// Print group footer for previous group
echo $gen->renderBlock('groupfooter', []);
// Print group header for new group
echo $gen->renderBlock('groupheader', ['category' => $row['category']]);
}
$gen->rowNumber++;
$gen->print('data', $row);
}
// Don't forget the last group footer
echo $gen->renderBlock('groupfooter', []);Every report PHP script should output a complete HTML page with print-friendly CSS:
echo '<!DOCTYPE html><html><head><meta charset="utf-8">
<title>My Report</title>
<style>
body { margin:0; padding:0; background:#e0e0e0 }
.page { background:#fff; width:800px; margin:20px auto;
padding:30px 25px; box-shadow:0 2px 8px rgba(0,0,0,.25) }
@media print {
body { background:#fff }
.page { margin:0; padding:15mm; width:auto; box-shadow:none }
}
</style></head><body><div class="page">';
// ... report output ...
echo '</div></body></html>';| Method | Description |
|---|---|
read($path) |
Load template from file |
loadFromHtml($html) |
Load from string |
include($path, $override) |
Merge blocks from another template |
| Method | Description |
|---|---|
setBlockTables($bool) |
Each block = independent table with own widths |
setPageSize($lines) |
Rows per page. 0 = no pagination |
onStartPage($block) |
Repeat block on each new page |
onSub($parent, $subHdr, $subData, $fn) |
Register master-detail |
hasBlock($name) |
Check if block exists |
getBlockNames() |
List all blocks |
getConfig($key) |
Read from {config} block |
| Method | Description |
|---|---|
startDocument() |
Opening HTML tags |
endDocument() |
Closing HTML tags |
renderBlock($name, $vars) |
Render block, return HTML |
print($name, $vars) |
echo + auto-pagination + sub-details |
newPage() |
Force page break |
| Method | Description |
|---|---|
getAcc($field, $N) |
Read accumulator value |
popAcc($field, $N) |
Read and reset |
resetAcc($field, $N) |
Reset to zero |
getAllAccumulators() |
All accumulators as array |
| Method | Description |
|---|---|
checkBreak($N, $value) |
Returns true when value changes |
resetBreak($N) |
Reset break state |
| Property | Description |
|---|---|
$rowNumber |
Current row number (set by you) |
$pageNumber |
Current page (read-only) |
$linesLeft |
Lines remaining on page |
$lineNumber |
Total lines rendered |
Editor1 is a simpler version of Editor2. It uses a single table for all blocks. Block names go in column A (the marker column), just like in Editor2.
When to use Editor1: Simple reports where all blocks have the same column widths.
When to use Editor2: Complex reports with different column layouts per block (invoices, multi-section reports).
URL: http://localhost:8000/editor/index.html
TextTemplateParser is a separate engine for plain-text reports. It generates raw text output with ESC-codes for matrix/dot-matrix printers (Epson, Star, Okidata, etc.).
Unlike the HTML engine (ReportEngine), text templates produce fixed-width columnar output suitable for direct printing without a browser.
Blocks use named tags (not the marker column):
{header}
WAREHOUSE REPORT
Date: {$date}
{/header}
{data}
{$item_name:L30} {$qty:R8,z} {$price:R10}
{/data}
{footer}
Total: {$total:R10,B}
{/footer}
| Format | Example | Description |
|---|---|---|
L20 |
{$name:L20} |
Left-aligned, 20 characters wide |
R10 |
{$price:R10} |
Right-aligned, 10 characters wide |
C15 |
{$title:C15} |
Centered, 15 characters wide |
z |
{$qty:R8,z} |
Suppress zeros (empty instead of "0") |
dup |
{$group:L20,dup} |
Suppress duplicates (show only when value changes) |
B |
{$total:R10,B} |
Bold (ESC-code double-strike) |
Formats can be combined: {$amount:R10,z,B} — right-aligned, suppress zeros, bold.
Set default font for the entire template:
{setfr B}
| Code | Effect |
|---|---|
N |
Normal (default) |
B |
Bold — ESC E / ESC F (double-strike) |
U |
Underline — adds underline row below text |
D |
Double-height — adds empty line after each line |
$parser = new TextTemplateParser('/path/to/template.txt');
// Print header
echo $parser->renderBlock('header', [
'date' => date('d.m.Y'),
]);
// Print data rows
foreach ($items as $item) {
echo $parser->renderBlock('data', [
'item_name' => $item['name'],
'qty' => $item['qty'],
'price' => $item['price'],
]);
}
// Reset duplicate suppression (e.g. on new page)
$parser->resetDuplicateState();
echo $parser->renderBlock('footer', [
'total' => $grandTotal,
]);| Method | Description |
|---|---|
new TextTemplateParser($path) |
Load text template from file |
renderBlock($name, $data) |
Render block with data, return text string |
hasBlock($name) |
Check if block exists |
getBlocks() |
List all block names |
resetDuplicateState() |
Reset dup suppression (for new page/group) |
setColumnSeparator($char) |
Set column separator (e.g. '|') |
getType() |
Returns 'text' |
Template:
{data}
{$name:L20} {$qty:R6} {$price:R10}
{/data}
Data: Laptop/2/999, Mouse/10/29
Output:
Laptop 2 999.00
Mouse 10 29.00
ReportEngine has a test suite with 51 tests covering template compilation, accumulators, pagination, and group breaks.
cd ReportEngine
composer install
./vendor/bin/phpunitPForm2/
├── ReportEngine/
│ ├── src/
│ │ ├── ReportEngine.php — main engine
│ │ ├── TemplateCompiler.php — template parser
│ │ └── TextTemplateParser.php — plain text engine
│ └── tests/ — PHPUnit tests
│
├── editor2/ — visual multi-grid editor
│ ├── index.html — editor application
│ ├── save.php — save template
│ ├── load.php — load template
│ └── browse.php — file browser
│
├── editor/ — simple single-table editor
│ ├── index.html
│ └── save.php
│
└── examples/ — usage examples
├── company_invoice.html — template example
├── report.php — PHP render example
└── template.html — basic template
{blockname} — start block
{/} — end block
{$field} — data field
{$field:f2} — 2 decimal places
{$field:z} — suppress zeros
{$field:s} — suppress repeats
{$field:nowrap} — never break this field's text mid-word
{$field:SUM(N),f2} — accumulate into levels 1..N
{$SUM($field,N):f2} — output accumulated total
{$__rownum__} — row number
{$__rownum__,N} — row number, width reserved for N digits
{$__page__} — page number
$gen = new ReportEngine();
$gen->read('template.html');
$gen->setBlockTables(true);
$gen->setPageSize(40);
$gen->onStartPage('colheads');
echo $gen->startDocument();
echo $gen->renderBlock('header', $vars);
echo $gen->renderBlock('colheads', []);
foreach ($data as $row) {
$gen->rowNumber++;
$gen->print('data', $row);
}
echo $gen->renderBlock('endReport', []);
echo $gen->endDocument();Ctrl+B/I — bold/italic
Ctrl+Z/Y — undo/redo
Enter/F2 — edit cell
Escape — cancel
Shift+Enter — insert row
Alt+Delete — delete row
Alt+Shift+Up/Down — move row
Alt+Up/Down — move block
Tab/Shift+Tab — next/prev cell
PForm2 — HTML template engine for business reports.
License: Free for any use.