NAME
Text::Stencil - fast XS list/table renderer with escaping, formatting, and
transform chaining
SYNOPSIS
use Text::Stencil;
my @rows = ([1, 'Alice'], [2, 'Bob & Co']);
my $table = Text::Stencil->new(
header => '
| id | name |
',
row => '| {0:int} | {1:html} |
',
footer => '
',
);
my $html = $table->render(\@rows);
# hashrefs, chaining, separator
my $list = Text::Stencil->new(
header => '',
row => '- {title:default:Untitled|trim|trunc:80|html}
',
footer => '
',
separator => "\n",
);
print $list->render_one({ id => 1, title => 'Hello' });
# stream to a byte handle
open my $fh, '>:raw', 'out.html' or die $!;
$table->render_to_fh($fh, \@rows);
close $fh or die $!;
DESCRIPTION
Renders lists of uniform data (arrayrefs or hashrefs) into text output
using a pre-compiled row template. The template is parsed once at
construction; rendering is a tight C loop with direct buffer writes and
zero Perl interpretation overhead.
About 2x faster than Text::Xslate for arrayref rows, ~1.6x for hashref
rows (see "PERFORMANCE"). Not safe for concurrent renders from multiple
threads (see "THREAD SAFETY").
CONSTRUCTOR
new
my $s = Text::Stencil->new(%opts);
Options:
"header"
String prepended before all rows (default: empty).
"row"
Row template with "{field:type}" placeholders. Omitting it renders
just the header and footer.
"footer"
String appended after all rows (default: empty).
"separator"
String inserted between rows (default: none).
"escape_char"
Delimiter character instead of "{" (default: "{"). Paired closing: "["
→ "]", "(" → ")", "<" → ">"; others use the same character for open
and close. Useful for JSON templates where literal braces are needed.
It must be exactly one byte, and not "NUL". Anything else is an error:
a two-character string, or a character that encodes to more than one
byte, would match nothing - the whole template would then render
literally with no field ever substituted.
A byte above 0x7F is accepted only when the row template is a byte
string. Against a character string it is an error, because the row is
split on raw bytes and a high delimiter would cut a multi-byte
character in half. Note that "from_file" always decodes, so a template
loaded from a file always needs an ASCII delimiter.
"skip_if"
Column index or field name. Rows where this field is truthy
(non-empty, not "0", not undef) are skipped.
"skip_unless"
Column index or field name. Rows where this field is not truthy are
skipped.
An unrecognised option is an error, not a silent no-op: a misspelled
"seperator" would otherwise give you a table with no separators and
nothing to explain it.
As a shorthand, "new" may be called with a single string argument, which
is used as the "row" template: "Text::Stencil->new($template)" is
equivalent to "Text::Stencil->new(row => $template)".
from_file
my $s = Text::Stencil->from_file('template.tpl', separator => "\n");
Load template from a file. The file is read as UTF-8. It can use section
markers:
__HEADER__
__ROW__
| {0:html} |
__FOOTER__
Any subset of the markers may be used, in any order, as long as "__ROW__"
is present; any text before the first marker is ignored. Without markers,
the entire file content is used as the row template.
clone
my $s2 = $s->clone(row => '{0:uc}');
my $s3 = $s->clone(row => '{0:uc}', separator => "\n");
Create a new renderer reusing the original's header, footer, "escape_char"
and skip conditions. "row" is required; "separator" may also be replaced.
Any other option is an error rather than a silent no-op, since everything
else is inherited.
METHODS
render
my $output = $s->render(\@rows);
Render all rows. Returns a string (see "UNICODE" for its encoding).
render_one
my $output = $s->render_one(\@row);
my $output = $s->render_one(\%row);
Render a single row without wrapping in an arrayref. The header and footer
are still applied, and "skip_if"/"skip_unless" still apply - a skipped row
gives "". Only "row_count" is left alone.
render_sorted
my $output = $s->render_sorted(\@rows, $sort_by);
my $output = $s->render_sorted(\@rows, $sort_by, {descending => 1, numeric => 1});
Render rows sorted by a field. $sort_by is a column index for arrayref
rows or a field name for hashref rows. A leading "-" on the field name
sorts descending: '-score'. It can also be an arrayref for multi-column
sort: "[0, 1]" or "['name', 'age']". Sorts lexically ascending by default.
Optional third argument is a hashref: "descending" sorts descending,
"numeric" compares numerically. "descending" and a leading "-" select the
same thing rather than cancelling: with either one present the sort
descends, so '-name' with "{descending => 0}" still descends. A spec of
the wrong kind for the template - field names against "{0}", or column
indices against "{name}" - is an error rather than a sort that quietly
does nothing, since it could not have fetched a key from any row. As with
"new", an unrecognised key in that hashref is an error - a misspelled
"decending" would otherwise sort the wrong way with nothing to explain it.
The leading "-" is a hashref-only shorthand. On arrayref rows a leading
"-" is a negative column index, exactly as in a template, so
"render_sorted(\@rows, '-1')" sorts ascending by the last column; use
"{descending => 1}" there.
In the multi-field form the "-" works the same way, but direction belongs
to the sort as a whole rather than to one field: "['-name', 'age']" sorts
by name then age, all descending. Per-field directions are not supported.
render_to_fh
$s->render_to_fh($fh, \@rows);
Render directly to a filehandle, flushing in 64KB chunks. Bytes are
written as assembled, so use a byte handle; an ":encoding" layer will
mangle non-ASCII output. Print the result of "render" instead if you want
the handle's layers applied.
A failing write croaks. As with "print", an error the handle only notices
when its own buffer is flushed surfaces at "close" rather than here, so
still check "close".
The handle is resolved once, at the start. Closing or reopening it from
code that runs during the render - a tied "FETCH", an overloaded "" -
leaves the rest of the output going to whatever now occupies that slot, so
don't. Don't write to it either: the render's own output is buffered in
chunks, so anything you "print" to the same handle mid-render - or send
there with a nested "render_to_fh" - lands ahead of the output being
assembled around it, not where you wrote it.
render_cb
my $output = $s->render_cb(sub { return \@row_or_undef });
$s->render_cb(sub { return \@row_or_undef }, $fh);
Callback-based rendering. The callback is called repeatedly and should
return an arrayref or hashref (one row) or undef to stop. Anything else -
undef, a plain string, some other kind of reference - also stops it. If a
filehandle is given, output is streamed to it; otherwise returns a string.
Leaving the callback with "last", "next" or "goto" aimed at a label
outside the sub does not work, but it fails cleanly: the callback runs on
a call stack of its own, so the jump finds no such label and dies - Label
not found for "last LABEL", or "Can't find label LABEL" for "goto" - which
you can catch like any other error. (Before 0.03 it unwound this call
mid-flight and the interpreter could resume inside a block it had already
left, run the following statements twice, or crash.) "die" is the
supported way to abort early; it propagates out of "render_cb" unchanged.
To stop without an error, return "undef".
columns
my $cols = $s->columns; # [0, 2] or ['name', 'id']
Field references used in the row template, each listed once, in the order
it first appears. "{#}" is the row number rather than a field reference,
and is not listed.
row_count
$s->render(\@rows);
my $n = $s->row_count;
Number of rows the last "render", "render_sorted", "render_to_fh" or
"render_cb" was given, including any skipped by "skip_if"/"skip_unless".
"render_one" does not change it.
TEMPLATE SYNTAX
Field references
"{0}", "{1}" for arrayref rows. "{name}", "{id}" for hashref rows. Mode
auto-detected from the template. Negative indices count from the end:
"{-1}" is the last element, "{-2}" the second-to-last, etc.
A template uses one row mode throughout, so mixing numeric and named
references ('{0} {name}') is a compile-time error, as is an empty
reference ("{}") or an unclosed delimiter.
"{#}" is the current row number (0-based). It numbers the rows you passed
in, not the ones that come out, so with "skip_if"/"skip_unless" the
numbers have gaps and need not start at 0 - the same rows "row_count"
counts. Under "render_sorted" it is the position after sorting rather than
the position you passed the row in at, since sorting is what the row
number is usually wanted for; skipped rows still leave gaps. Works with
chaining: "{#:int_comma}", "{#:pad:4}". In "render_one", the row number is
0.
Literal delimiters
"{{" produces a literal "{" in output. Useful for JSON templates:
{{"id":{0:int}} # produces {"id":42}
Works with any "escape_char": "[[" produces "[" when using "escape_char =>
'['".
Types
Escaping / encoding
"html", "html_br", "url", "json", "hex", "base64", "base64url", "raw"
Numeric
"int", "int_comma", "float:N", "sprintf:FMT"
Values are read leniently, not validated. "int", "int_comma", "float" and
"sprintf" numify as Perl would; "date", "elapsed", "ago" and "plural"
instead take the digits and ignore everything else, so "1e3" is 13 to them
and 1000 to "int". "plural" keeps a leading "-", the other three drop it.
Junk formats as zero rather than failing.
The integer conversions then narrow the number the way C does, so a value
too large for an "IV" wraps exactly as Perl's own "sprintf "%d"" wraps it:
"1e19" and "1e30" give the same answers here as there. An infinite value
is the one place to be careful - Perl's %d prints the word "Inf", while
this hands the value to the platform's "IV" conversion, and what that
produces differs between perl versions. "NaN" is 0 everywhere, like other
junk.
The Perl-numification half of that holds only where those four come first
in a chain, which is where the original scalar is still there to numify.
Later in a chain they are reading the previous transform's text instead,
and change behaviour accordingly: "int", "int_comma" and "sprintf"'s
integer conversions become digit-takers, so "{0:int}" on " 1e3 " is 1000
while "{0:trim|int}" is 13; "float" and "sprintf"'s floating conversions
fall back to C "strtod", which accepts forms Perl does not, so
"{0:float:2}" on "0x1f" is 0.00 while "{0:raw|float:2}" is 31.00. The
digit-takers are digit-takers in either position - there is nothing for
them to change to.
"number_si" and "bytes_si" follow neither rule: they always use C
"strtod", first in a chain or not. So on "0x1f" "int" gives 0, "elapsed"
gives "1s", and "number_si" gives 31.
"float" formats at C "double" precision, so on a perl built with
"-Duselongdouble" or "-Dusequadmath" it will show fewer significant digits
than Perl's own "sprintf".
"sprintf" accepts one conversion written as
"%[flags][width][.precision]CONV", where "CONV" is one of "dixXoufegs" and
is the last character of the format. Flags are "-+ #0"; width and
precision are at most eight digits. Anything else - a trailing literal
(%dx), a second conversion, "*", a positional specifier ("%2$d"), or a
length modifier (%ld) - is not applied and the value passes through
unchanged.
Formatting is done by the C library, so an accepted format follows C's
printf rather than Perl's "sprintf": %08s pads with spaces, where Perl
would pad with zeros. %s also stops at the first NUL byte, since that is
where a C string ends - every other transform passes NULs through, so use
"raw" rather than "sprintf:%s" for values that may contain one.
String transforms
"trim", "uc", "lc", "pad:N", "rpad:N", "trunc:N", "substr:S:L",
"replace:OLD:NEW", "mask:N", "length"
"trunc:N" never returns more than N bytes: above 3 it appends "..." within
the budget, at or below 3 it cuts hard.
"mask:N" keeps the last N bytes and replaces what precedes them with "*".
A value of N bytes or fewer is left untouched - "mask:4" on "abc" is
"abc", not "***" - so choose N against your shortest value.
"replace:OLD:NEW" replaces every non-overlapping occurrence left to right,
never re-examining what it inserted. "replace:OLD" deletes "OLD"; an empty
"OLD" is a no-op.
Logic / conversion
"default:VALUE", "bool:TRUTHY:FALSY", "if:TEXT", "unless:TEXT",
"map:K1=V1:K2=V2:*=DEFAULT", "wrap:PREFIX:SUFFIX" - with one parameter the
prefix alone; an empty or absent value emits nothing at all, not the
wrapper
"default:VALUE" supplies VALUE only when the field is undef or absent; an
empty string is a value and passes through.
Data formatting
"count", "date:FMT" (a Unix epoch formatted in UTC by strftime; the format
goes straight to the platform "strftime", and a few GNU extensions -
notably %s - are computed in local time there rather than UTC),
"plural:SINGULAR:PLURAL" - emits the count and the word ("2 items"); with
one form given, the plural is that form plus "s", "number_si", "bytes_si",
"elapsed" - a duration in seconds as "1d 1h 1m 1s", dropping the larger
units that are zero but always ending in seconds (3600 is "1h 0s"), and
always non-negative, "ago" - a Unix epoch as "5m ago", "2d ago", "1mo ago"
(a month is 30 days, a year 365) or "in the future",
"coalesce:FIELD1:FIELD2:DEFAULT" - use the primary field if non-empty
(unlike "bool"/"if", the string "0" counts as present), otherwise try each
fallback field in order; the last parameter is a literal default string
"count" is the size of the arrayref or hashref in the field, tied
containers included, and 0 for any other defined value; an undef or absent
field renders empty. "number_si" scales by 1000 up to "P" and "bytes_si"
by 1024 up to "EB"; past the top tier the mantissa keeps growing rather
than gaining a larger prefix.
Chaining
{0:trim|trunc:80|html} # pipe transforms left to right
"count" and "coalesce" act on the raw field value (a container's size, or
the first non-empty field), so each must be the first transform in its
chain. Using either later in a chain is a compile-time error.
An unrecognised transform name is a compile-time error too, so a typo such
as "{0:hmtl}" is reported rather than quietly passing the value through
unescaped. Names are case-sensitive and are not trimmed, so "{0:HTML}" and
"{0: html}" are errors as well. A trailing delimiter with nothing after it
- "{0:}" or "{0:trim|}" - is not a transform at all and is simply ignored.
An empty segment anywhere else ("{0:|trim}", "{0:trim||}") is an error.
A parameter given to a transform that does not take one - "{0:uc:1}",
"{0:html:5}" - is a compile-time error for the same reason: it reads as
though it does something.
Transform parameters
Transforms taking a number ("float", "pad", "rpad", "trunc", "mask",
"substr") require a non-negative decimal integer; anything else, or a
value above 100_000_000, is a compile-time error. An empty parameter is
the exception: it is ignored, leaving the transform its own default - zero
for "trunc", "pad", "rpad" and the "substr" offset, but 2 for "float" and
4 for "mask", so "{0:mask:}" still shows the last four bytes. Precision
above 30 decimals (and within the 100_000_000 bound) is silently clamped
to 30 rather than rejected. An omitted "substr" bound is unbounded:
"substr:3:" runs to the end.
The 100_000_000 bound caps the value, not the work it implies:
"{0:pad:100000000}" compiles and then allocates 100MB per row.
Column indices may be negative, and an index with no matching element
simply renders empty. They must still fit in a C "int": a larger one is
rejected, in a template at compile time and in "skip_if", "skip_unless" or
a "render_sorted" sort spec when the call is made.
A field value larger than 2GB is returned unchanged by the string
transforms. The ones that read the scalar as a number rather than as text
- "int", "int_comma", "float", "count", and "sprintf" with any conversion
but %s - never look at the string, so they behave the same at any length.
UNICODE
The output follows the input. The result is a character string only if
something character-ish contributed and nothing ruled it out: a high byte
in a template piece or field value that arrived as bytes forces a byte
string, as does a transform that cuts a character in half. That holds even
where the assembled output happens to be well-formed UTF-8 - a character
template rendering the bytes "\xc3\xa9" gives those two bytes back
unflagged. Rendering only byte strings therefore round-trips Latin-1 and
binary data unchanged.
Do not mix the two in one render: decode your inputs, or leave them all as
bytes. Feeding already-encoded UTF-8 bytes alongside decoded characters is
ambiguous, and whether the result comes back flagged then depends on the
order the values are seen in - where a byte value carrying high bytes is
seen before anything character-ish, the decision is deferred and settled
by validating the whole output instead.
A row that is not an arrayref or hashref renders every field empty rather
than failing, and still counts towards "row_count".
All string operations are byte-level: "length" counts bytes and
"pad"/"rpad" pad to a byte width, while "trunc", "substr" and "mask" can
cut a multi-byte character in half; the result is then returned as bytes
rather than as a malformed character string. "uc"/"lc" are ASCII-only.
"json" escapes only what JSON requires and passes bytes >= 0x80 through
untouched, so encode the value first if the consumer expects UTF-8.
THREAD SAFETY
The object is not safe for concurrent renders from multiple threads due to
shared render buffer and "last_row_count" state. Create separate objects
per thread, or serialize access. Separate objects are enough: no render
path keeps state outside its own object.
Under ithreads the compiled template is not cloned into new threads
("CLONE_SKIP"), so an object must be created in the thread that uses it.
On Windows "fork" is implemented with ithreads, so the same applies there:
build the renderer in the child, not before the fork.
PERFORMANCE
Perl 5.40, x86_64 Linux. Absolute rates depend heavily on the machine and
move 20% or more between runs on a busy one, so the comparisons below are
given as ratios; the absolute figures in the later sections are from a
single run and are only useful against each other. Run "perl -Mblib
bench.pl" for your own numbers.
HTML table (13 rows, html escape), relative to Text::Xslate:
render arrayref ~2.0-2.1x
render chained ~1.6x
render hashref ~1.6x
render_one ~8-11x (one row against Xslate rendering the whole
13-row table; not a per-row comparison)
Hashref rows cost a hash lookup per field where arrayref rows index
directly, which is most of the gap between the two.
Transform throughput (1000 rows, single transform):
int_comma 28.1K/s int 27.8K/s trunc:20 27.7K/s
uc 26.6K/s default:x 25.3K/s json 23.8K/s
raw 23.3K/s url 22.6K/s html 14.4K/s
trim|html 13.9K/s float:2 4.2K/s
Chain depth scaling (1000 rows), relative to a single "html":
2 (trim|html) ~0.8x
3 (trim|uc|html) ~0.6x
4 (trim|uc|trunc:20|html) ~0.6x (trunc shortens the string before
html, offsetting its own cost)
Row count scaling (int + html escape per row):
~11-17M rows/s; roughly flat from 100 to 10000 rows, ~20% lower at 10
where the per-render setup is not amortised
render vs render_one (single row):
render_one ~4-5M/s (~40% faster than render for single rows)
AUTHOR
vividsnow
LICENSE
This library is free software; you can redistribute it and/or modify it
under the same terms as Perl itself.