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 => '<table><tr><th>id</th><th>name</th></tr>',
            row    => '<tr><td>{0:int}</td><td>{1:html}</td></tr>',
            footer => '</table>',
        );
        my $html = $table->render(\@rows);

        # hashrefs, chaining, separator
        my $list = Text::Stencil->new(
            header => '<ul>',
            row    => '<li>{title:default:Untitled|trim|trunc:80|html}</li>',
            footer => '</ul>',
            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__
        <table>
        __ROW__
        <tr><td>{0:html}</td></tr>
        __FOOTER__
        </table>

    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.

