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.

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 truthy, 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

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.

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.