NAME

Text::Stencil - fast XS list/table renderer with escaping, formatting, and transform chaining

SYNOPSIS

use Text::Stencil;

my $s = Text::Stencil->new(
    header => '<table><tr><th>id</th><th>name</th></tr>',
    row    => '<tr><td>{0:int}</td><td>{1:html}</td></tr>',
    footer => '</table>',
);
my $html = $s->render(\@rows);

# hashrefs, chaining, separator
my $s = Text::Stencil->new(
    header => '<ul>',
    row    => '<li>{title:default:Untitled|trim|trunc:80|html}</li>',
    footer => '</ul>',
    separator => "\n",
);

# single row, stream to file
print $s->render_one({id => 1, title => 'Hello'});
$s->render_to_fh($fh, \@rows);

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.

2-3x faster than Text::Xslate for table/list rendering.

The template is parsed once; rendering is a tight C loop. 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 (required)
separator - string inserted between rows (default: none)
escape_char - delimiter character instead of { (default: {). Paired closing: [], (), <>, others use the same char for open and close. Useful for JSON templates where literal braces are needed.
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.

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 can use section markers:

__HEADER__
<table>
__ROW__
<tr><td>{0:html}</td></tr>
__FOOTER__
</table>

Without markers, the entire file content is used as the row template.

clone

my $s2 = $s->clone(row => '{0:uc}');

Create a new renderer reusing the original's header/footer.

METHODS

render

my $output = $s->render(\@rows);

Render all rows. Returns a UTF-8 string.

render_one

my $output = $s->render_one(\@row);
my $output = $s->render_one(\%row);

Render a single row without wrapping in an arrayref.

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 reverses order, numeric compares numerically.

render_to_fh

$s->render_to_fh($fh, \@rows);

Render directly to a filehandle, flushing in 64KB chunks.

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. If a filehandle is given, output is streamed to it; otherwise returns a string.

columns

my $cols = $s->columns;    # [0, 2] or ['name', 'id']

Returns field references used in the row template.

row_count

$s->render(\@rows);
my $n = $s->row_count;

Number of rows processed by the last render().

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.

{#} is the current row number (0-based). 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

String transforms

trim, uc, lc, pad:N, rpad:N, trunc:N, substr:S:L, replace:OLD:NEW, mask:N, length

Logic / conversion

default:VALUE, bool:TRUTHY:FALSY, if:TEXT, unless:TEXT, map:K1=V1:K2=V2:*=DEFAULT, wrap:PREFIX:SUFFIX

Data formatting

count, date:FMT, plural:SINGULAR:PLURAL, number_si, bytes_si, elapsed, ago, 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

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 truthy field), so each must be the first transform in its chain. Using either later in a chain is a compile-time error.

UNICODE

UTF-8 transparent. All string operations preserve multi-byte sequences. Output is flagged UTF-8. uc/lc are ASCII-only.

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. render_sorted additionally uses process-global sort state, so it must not be called concurrently even on separate objects; serialize calls to it.

PERFORMANCE

Perl 5.40, x86_64 Linux.

HTML table (13 rows, html escape):

                     Rate  Text::Xslate  hashref  chained  arrayref  render_one
Text::Xslate     413K/s            --     -44%     -49%      -55%       -92%
render hashref   733K/s           77%       --     -10%      -21%       -86%
render chained   813K/s           97%      11%       --      -12%       -84%
render arrayref  922K/s          123%      26%      13%        --       -82%
render_one      5161K/s         1150%     604%     534%      460%         --

Transform throughput (1000 rows, single transform):

default:x  67.4K/s    int       52.4K/s    int_comma 50.1K/s
trunc:20   44.4K/s    raw       39.8K/s    json      33.7K/s
uc         36.4K/s    url       32.2K/s    html      28.7K/s
trim|html  23.6K/s    float:2    6.3K/s

Chain depth scaling (1000 rows):

1 (html)                    19.1K/s
2 (trim|html)               15.8K/s  (-17%)
3 (trim|uc|html)            11.2K/s  (-29%)
4 (trim|uc|trunc:20|html)   11.0K/s  (-1%)

Row count scaling (int + html escape per row):

~25M rows/s constant from 10 to 10000 rows

render vs render_one (single row):

render_one  7.0M/s  (44% faster than render for single rows)

Run perl bench.pl for your own numbers.

AUTHOR

vividsnow

LICENSE

This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself.