File Coverage

File:blib/lib/Encode/Wide.pm
Coverage:100.0%

linestmtbrancondsubtimecode
1package Encode::Wide;
2
3# TODO: don't transform anything within <script>...</script> in wide_to_html
4
5
10
10
10
919308
8
86
use strict;
6
10
10
10
14
7
203
use warnings;
7
8
10
10
10
15
6
248
use Carp qw(croak carp confess);
9
10
10
10
14
7
106
use Exporter qw(import);
10
10
10
10
1333
21367
464
use HTML::Entities;
11
10
10
10
1035
101677
10707
use Params::Get 0.13;
12
13our @EXPORT_OK = qw(wide_to_html wide_to_xml);
14
15# HTML::Entities::decode does not handle these four named entities, so we
16# decode them ourselves.  The regex is built once at compile time: longest
17# key first to avoid partial-match ambiguity (e.g. &Scaron; before &s...).
18my %_EXTRA_ENTITY_MAP = (
19        '&ccaron;' => "\x{010D}",      # c with caron
20        '&zcaron;' => "\x{017E}",      # z with caron
21        '&Zcaron;' => "\x{017D}",      # Z with caron
22        '&Scaron;' => "\x{0160}",      # S with caron
23);
24my $_EXTRA_ENTITY_RE = do {
25        my $pat = join '|', map { quotemeta }
26                sort { length($b) <=> length($a) } keys %_EXTRA_ENTITY_MAP;
27        qr/$pat/;
28};
29
30# Module-level HTML escape map eliminates the /e eval flag in keep_hrefs substitutions
31my %_HTML_ESCAPE = ( '<' => '&lt;', '>' => '&gt;', '"' => '&quot;' );
32
33# Encode to HTML whatever the non-ASCII encoding scheme has been chosen
34# Can't use HTML:Entities::encode since that doesn't seem to cope with
35#       all encodings and misses some characters
36#
37# See https://www.compart.com/en/unicode/U+0161 etc.
38#       https://www.compart.com/en/unicode/U+00EB
39#
40# keep_hrefs => 1 means ensure hyperlinks still work
41# keep_apos => 1 means keep apostrophes, useful within <script>
42
43 - 51
=head1 NAME

Encode::Wide - Convert wide characters (Unicode, UTF-8, etc.) into ASCII-safe HTML or XML entities

=head1 VERSION

0.07

=cut
52
53our $VERSION = 0.07;
54
55=encoding UTF-8
56
57 - 341
=head1 SYNOPSIS

    use Encode::Wide qw(wide_to_html wide_to_xml);

    # Basic HTML conversion
    my $html = wide_to_html(string => "Cafe\x{E9} d\x{E9}j\x{E0} vu");
    # => 'Caf&eacute; d&eacute;j&agrave; vu'

    # Basic XML conversion (numeric entities, en-dash folded to hyphen)
    my $xml = wide_to_xml(string => "Cafe\x{E9} \x{2013} na\x{EF}ve");
    # => 'Caf&#x0E9; - na&#x0EF;ve'

    # Preserve embedded HTML markup (keep_hrefs)
    my $linked = wide_to_html(
        string     => '<a href="/menu">Caf\x{E9}</a>',
        keep_hrefs => 1,
    );
    # => '<a href="/menu">Caf&eacute;</a>'

    # Keep apostrophes literal for JavaScript contexts (keep_apos)
    my $js_safe = wide_to_html(
        string    => "it\x{2019}s na\x{EF}ve",
        keep_apos => 1,
    );
    # => "it\x{2019}s na&iuml;ve"   (curly apostrophe kept; i-umlaut encoded)

    # Get notified about unhandled characters instead of dying silently
    my $out = wide_to_html(
        string   => $untrusted,
        complain => sub { warn "Unhandled: $_[0]" },
    );

    # Accept a scalar reference
    my $text = "na\x{EF}ve";
    my $safe = wide_to_html(string => \$text);
    # => 'na&iuml;ve'

=head1 DESCRIPTION

Encode::Wide converts strings that contain non-ASCII (wide) characters into
pure 7-bit ASCII output suitable for embedding in HTML pages or XML documents.
Every non-ASCII codepoint is replaced by the appropriate entity reference so
the output can be safely placed in HTML attributes, HTML body text, or XML
element content without triggering encoding errors or security issues.

=head2 Why use this module?

L<HTML::Entities> is the obvious alternative for HTML, but it makes strict
assumptions about input encoding that cause silent failures when the input
arrives as raw UTF-8 bytes, already-partially-encoded entities, or a mix of
both.  Encode::Wide handles all three representations through a multi-pass
pipeline and falls back to L<HTML::Entities> numeric encoding for any
character not explicitly listed in its tables.

For XML, L<XML::Entities> works in the opposite direction (decoding entities,
not encoding them).  Encode::Wide fills that gap.

=head2 Input

Both functions accept:

=over 4

=item *

A B<Perl Unicode string> (the internal C<utf8> flag is set) - the normal case
when input comes from L<Encode/decode>, a database driver with C<pg_enable_utf8>,
or a source file declared C<use utf8>.

=item *

A B<raw UTF-8 byte string> - the common case when input arrives from a legacy
web form or an older database driver without automatic decoding.  The pipeline's
raw-byte substitution pass handles this transparently.

=item *

A B<scalar reference> - C<wide_to_html(string =E<gt> \$var)>.  The string is
read from the referent; the referent is not modified.

=item *

B<Already-encoded HTML entities> - e.g. C<&eacute;> or C<&lt;>.
By default the pipeline decodes these first so they are not double-encoded.
Pass C<keep_hrefs =E<gt> 1> to suppress decoding when the input contains
trusted HTML that must pass through unchanged.

=back

=head2 Output

Both functions return a B<defined scalar string> containing B<only ASCII
characters> (code points 0x00-0x7F).  The output is safe to concatenate
directly into an HTML or XML document without further escaping.

=head2 Choosing between the two functions

Use C<wide_to_html> when writing into an HTML context (C<< <p> >>, C<< <td> >>,
attribute values, etc.).  Named entities such as C<&eacute;> and C<&ndash;>
are used wherever possible; they are compact and human-readable in the source.

Use C<wide_to_xml> when writing into an XML context (XHTML, RSS, Atom, custom
XML schemas).  Named HTML entities other than the five predefined XML entities
(C<&amp;> C<&lt;> C<&gt;> C<&apos;> C<&quot;>) are not valid in XML.
This function uses only hexadecimal numeric entities (C<&#x0E9;>), which are
valid in all XML 1.0 processors.  Em-dashes and en-dashes are folded to a
plain ASCII hyphen because many XML consumers normalise whitespace and
punctuation anyway.

=head1 EXPORT

Nothing is exported by default.  Import the functions you need explicitly:

    use Encode::Wide qw(wide_to_html);          # one function
    use Encode::Wide qw(wide_to_html wide_to_xml);  # both

=head1 COMMON PARAMETERS

Both functions accept the following named parameters in addition to C<string>.
Pass them as a flat key-value list:

    wide_to_html(string => $text, keep_hrefs => 1, complain => \&handler);

=over 4

=item C<string> (required)

The text to encode.  May be a plain scalar or a B<reference to a scalar>.
Must be defined; passing C<undef> causes the function to C<croak> with a
usage message.

=item C<keep_hrefs> (optional, default 0)

When true, angle brackets (C<< < >>, C<< > >>) and double-quotes (C<">) are
B<not> escaped, allowing embedded HTML or XML markup to survive intact.

B<Security note:> when C<keep_hrefs> is set, entity-decoding is also
suppressed.  Without this suppression, an encoded payload such as
C<&lt;script&gt;> would be decoded to C<< <script> >> and then pass through
unescaped, creating an XSS vector.  With C<keep_hrefs =E<gt> 1> it is the
B<caller's responsibility> to ensure that the input does not contain untrusted
content that could be exploited.

=item C<complain> (optional)

A code reference called with a diagnostic string when the pipeline encounters a
character it cannot encode.  The function still C<croak>s with a C<BUG:>
prefix after invoking the callback - C<complain> is for logging, not recovery.

    wide_to_html(
        string   => $text,
        complain => sub {
            my ($msg) = @_;
            warn "Encode::Wide gap: $msg";
        },
    );

=back

=head2 wide_to_html

Convert a Unicode or UTF-8 string into a pure-ASCII HTML fragment.  Every
non-ASCII character is replaced by its named HTML entity (e.g. C<&eacute;>)
where one exists, or a hexadecimal numeric entity (e.g. C<&#xNNNN;>) otherwise.
Bare ampersands, angle brackets, and double-quotes are also escaped so the
result is safe to embed in HTML body text or attribute values without further
processing.

=head3 Arguments

All parameters are passed as a flat key-value list.  The C<string> key may be
omitted when passing a bare positional string as the first argument.

See L</COMMON PARAMETERS> for C<string>, C<keep_hrefs>, and C<complain>.

=over 4

=item C<keep_apos> (optional, default 0)

When true, apostrophes and their typographic variants (curly single quotes
U+2018, U+2019; grave accent U+0060; Windows-1252 byte 0x98) are B<not>
converted to C<&apos;>.  Useful when the result will be embedded inside a
JavaScript string literal where C<&apos;> is not valid syntax.

=back

=head3 Returns

A defined scalar string whose every character is in the ASCII range
(code points 0x00-0x7F).  The empty string is returned unchanged.

=head3 EXAMPLE

    use Encode::Wide qw(wide_to_html);

    # Accented characters to named entities
    my $out = wide_to_html(string => "na\x{EF}ve caf\x{E9}");
    # => 'na&iuml;ve caf&eacute;'

    # Ampersands and angle brackets are escaped
    $out = wide_to_html(string => 'Price < 100 & cost > 0');
    # => 'Price &lt; 100 &amp; cost &gt; 0'

    # Existing entities are decoded then re-encoded (no double-encoding)
    $out = wide_to_html(string => '&eacute;');
    # => '&eacute;'

    # keep_hrefs: HTML markup passes through; only wide chars are encoded
    $out = wide_to_html(
        string     => '<a href="/m\x{E9}nu">Men\x{FC}</a>',
        keep_hrefs => 1,
    );
    # => '<a href="/m&eacute;nu">Men&uuml;</a>'

    # keep_apos: apostrophes kept for JavaScript contexts
    $out = wide_to_html(
        string    => "it\x{2019}s clich\x{E9}",
        keep_apos => 1,
    );
    # => "it\x{2019}s clich&eacute;"

    # Scalar reference input
    my $text = "caf\x{E9}";
    $out = wide_to_html(string => \$text);
    # => 'caf&eacute;'

=head3 MESSAGES

=over 4

=item C<Usage: wide_to_html() string not set>

B<Fatal> (via C<croak>).  The C<string> parameter was C<undef>.
Resolution: pass a defined scalar or scalar reference.

=item C<TODO: wide_to_html(E<lt>hex-tokens...E<gt>)>

B<Warning> (via C<carp>).  A character survived all three byte_map passes and
the C<encode_entities_numeric> fallback.  The hex tokens in the message
identify the unhandled codepoint(s).
Resolution: add the character to the appropriate byte_map array, or file a bug
report at L<https://rt.cpan.org/Public/Dist/Display.html?Name=Encode-Wide>.

=item C<BUG: wide_to_html(E<lt>hex-tokens...E<gt>)>

B<Fatal> (via C<croak>), always preceded by the C<TODO> warning above.
The same unhandled-character condition caused a hard failure.  This should
never occur in normal use; it indicates a gap in the character tables.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        string     => { type => SCALAR | SCALARREF, required => 1, defined => 1 },
        keep_hrefs => { type => BOOLEAN, optional => 1, default => 0 },
        keep_apos  => { type => BOOLEAN, optional => 1, default => 0 },
        complain   => { type => CODEREF,  optional => 1 },
    }

=head4 Output

    { type => SCALAR, constraint => sub { $_[0] !~ /[^[:ascii:]]/ } }

=head3 PSEUDOCODE

    1. Unless keep_hrefs: decode HTML entities via HTML::Entities::decode
       and the four extra named entities (&ccaron; &zcaron; &Zcaron; &Scaron;)
    2. Escape bare & not followed by a valid entity name
       (possessive ++ quantifier prevents ReDoS backtracking)
    3. Unless keep_hrefs: escape <, >, and " using %_HTML_ESCAPE (no /e eval)
    4. First byte_map pass: typographic punctuation and exclamation mark
    5. Unless keep_apos: encode apostrophe variants to &apos;
       using an alternation regex built from the apostrophe key set
    6. Early return if the string is now pure ASCII
    7. Second byte_map pass: raw UTF-8 byte sequences -> named HTML entities
    8. Third byte_map pass: Perl Unicode chars (\N{U+...}) -> named HTML entities
    9. Fallback: HTML::Entities::encode_entities_numeric for any remaining
       non-ASCII codepoints
   10. If non-ASCII still remains after the fallback: invoke complain callback,
       carp a TODO warning, then croak a BUG error

=cut
342
343sub wide_to_html
344{
345
295
491779
        my $params = Params::Get::get_params('string', @_);
346
347
293
3199
        my $string = $params->{'string'};
348
293
180
        my $complain = $params->{'complain'};
349
350
293
255
        if(!defined($string)) {
351
4
49
                croak 'Usage: wide_to_html() string not set';
352        }
353
354
289
346
        if(ref($string) eq 'SCALAR') {
355
21
21
8
19
                $string = ${$string};
356        }
357
358
359        # SECURITY: skip entity-decoding when keep_hrefs is set.  Calling
360        # HTML::Entities::decode before the keep_hrefs gate converts encoded
361        # payloads like &lt;script&gt; to raw <script>, which then bypass
362        # re-escaping and produce XSS output.  When the caller asserts the input
363        # contains trusted HTML (keep_hrefs => 1) we treat the text as-is and
364        # only encode wide characters; entity-normalisation is then the caller's
365        # responsibility.
366
289
256
        unless($params->{'keep_hrefs'}) {
367
271
481
                $string = HTML::Entities::decode($string);
368
369                # Decode the four named entities HTML::Entities::decode misses
370
271
692
                $string =~ s/($_EXTRA_ENTITY_RE)/$_EXTRA_ENTITY_MAP{$1}/g;
371        }
372
373        # Escape bare & not already part of a valid entity.
374        # Possessive ++ on the char class prevents O(n^2) backtracking (ReDoS)
375        # when the input contains & not followed by a semicolon-terminated name.
376
289
264
        $string =~ s/&(?![A-Za-z#0-9]++;)/&amp;/g;
377
378
289
209
        unless($params->{'keep_hrefs'}) {
379                # Escape the three characters that break HTML attribute or body contexts.
380                # Module-level %_HTML_ESCAPE eliminates the /e eval flag.
381
271
340
                $string =~ s/([<>"])/$_HTML_ESCAPE{$1}/g;
382        }
383
384        # $string =~ s/&db=/&amp;db=/g;
385        # $string =~ s/&id=/&amp;id=/g;
386
387        # Table of byte-sequences->entities
388
289
642
        my @byte_map = (
389                ['“', '&quot;'],  # U+201C
390                ['”', '&quot;'],  # U+201D
391                ["\xe2\x80\x9c", '&quot;'],       # “
392                ["\xe2\x80\x9d", '&quot;'],       # ”
393                ["\xe2\x80\x93", '&ndash;'],
394                ["\xe2\x80\x94", '&mdash;'],
395                ["\xe2\x80\x98", '&apos;'],       # ‘
396                ["\xe2\x80\x99", '&apos;'],       # ’
397                ["\xe2\x80\xA6", '...'],      # …
398                ['!', '&excl;'],    # Do this early before the ascii check, since it's an ascii character
399        );
400
401
289
264
        $string = _sub_map(\$string, \@byte_map);
402
403
289
275
        unless($params->{'keep_apos'}) {
404                # Multi-byte curly apostrophes can’t be combined in a char-class, so use
405                # an alternation regex built from the key set — no /e eval needed.
406
278
467
                my %apos_map = (
407                        "'"            => '&apos;',
408                        "\x{2018}" => '&apos;',        # U+2018 left single quotation mark
409                        "\x{2019}" => '&apos;',        # U+2019 right single quotation mark
410                        "\x{0060}" => '&apos;',        # U+0060 grave accent used as apostrophe
411                        "\x98"     => '&apos;',
412                );
413
278
1390
240
966
                my $apos_re = join '|', map { quotemeta } keys %apos_map;
414
278
3118
                $string =~ s/($apos_re)/$apos_map{$1}/g;
415        }
416
417
289
402
        if($string !~ /[^[:ascii:]]/) {
418
109
262
                return $string;
419        }
420
421        @byte_map = (
422
180
2247
                ["\xc2\xa0", ' '],    # Non breaking space
423                ["\xc2\xa3", '&pound;'],
424                ["\xc2\xa9", '&copy;'],
425                ["\xc2\xae", '&reg;'],
426                ["\xc3\xa2", '&acirc;'],
427                ["\xc3\xa4", '&auml;'],
428                ["\xc3\xa9", '&eacute;'],
429                ["\xc2\xaa", '&ordf;'],   # ª
430                ["\xc2\xab", '&quot;'],   # «
431                ["\xc2\xbb", '&quot;'],   # »
432                ["\xc3\x81", '&Aacute;'], # Á
433                ["\xc3\x83", '&Icirc;'],  # ÃŽ
434                ["\xc3\x9e", '&THORN;'],  # Þ
435                ["\xc3\xa0", '&agrave;'], # à
436                ["\xc3\xa1", '&aacute;'], # á
437                ["\xc3\xad", '&iacute;'], # í
438                ["\xc3\xb0", '&eth;'],    # ð
439                ["\xc3\xba", '&uacute;'], # ú
440                ["\xc3\xb4", '&ocirc;'],  # ô
441                ["\xc3\xb6", '&ouml;'],
442                ["\xc3\xb8", '&oslash;'], # ø
443                ["\xc5\xa1", '&scaron;'],
444                ["\xc4\x8d", '&ccaron;'],
445                ["\xc5\xbd", '&Zcaron;'],
446                ["\xc5\xbe", '&zcaron;'],
447                ["\xc3\xa5", '&aring;'],  # Ã¥
448                ["\xc3\xa7", '&ccedil;'],
449                ["\xc3\xaf", '&iuml;'],   # ï
450                ["\xc3\xb3", '&oacute;'],
451                ["\xc3\x96", '&Ouml;'], # Ö
452                ["\xc3\xa8", '&egrave;'],
453                ["\xc3\x89", '&Eacute;'],
454                ["\xc3\x9f", '&szlig;'],
455                ["\xc3\xaa", '&ecirc;'],
456                ["\xc3\xab", '&euml;'],
457                ["\xc3\xae", '&icirc;'],
458                ["\xc3\xbb", '&ucirc;'],
459                ["\xc3\xbc", '&uuml;'], # ü
460                ["\xc3\xbe", '&thorn;'],  # þ
461                ["\xc5\x9b", '&sacute;'],
462                ["\xc5\xa0", '&Scaron;'],
463                ["\xe2\x80\x93", '&ndash;'],
464                ["\xe2\x80\x94", '&mdash;'],
465                ["\xc3\xb1", '&ntilde;'], # ñ
466                ["\xe2\x80\x9c", '&quot;'],
467                ["\xe2\x80\x9d", '&quot;'],
468                ["\xe2\x80\xa6", '...'],
469                ["\xe2\x97\x8f", '&#x25CF;'],     # ●
470                ["\N{U+00A0}", ' '],
471                ["\N{U+00A3}", '&pound;'],
472                ["\N{U+00A9}", '&copy;'],
473                ["\N{U+00AA}", '&ordf;'], # ª
474                ["\N{U+00AB}", '&quot;'], # «
475                ["\N{U+00AE}", '&reg;'],
476                ["\N{U+00B5}", '&micro;'],        # µ
477                ["\N{U+00BB}", '&quot;'], # »
478                ["\N{U+00CE}", '&Icirc;'],        # ÃŽ
479                ["\N{U+00DE}", '&THORN;'],        # Þ
480                ["\N{U+0161}", '&scaron;'],
481                ["\N{U+010D}", '&ccaron;'],
482                ["\N{U+017D}", '&Zcaron;'],
483                ["\N{U+017E}", '&zcaron;'],
484                ["\N{U+00C9}", '&Eacute;'],
485                ["\N{U+00D6}", '&Ouml;'], # Ö
486                ["\N{U+00DF}", '&szlig;'],        # ß
487                ["\N{U+00E1}", '&aacute;'],       # á
488                ["\N{U+00E2}", '&acirc;'],
489                ["\N{U+00E4}", '&auml;'],
490                ["\N{U+00E5}", '&aring;'],        # Ã¥
491                ["\N{U+00E0}", '&agrave;'],       # à
492                ["\N{U+00E7}", '&ccedil;'],       # ç
493                ["\N{U+00E8}", '&egrave;'],
494                ["\N{U+00E9}", '&eacute;'],
495                ["\N{U+00ED}", '&iacute;'],       # í
496                ["\N{U+00EE}", '&icirc;'],
497                ["\N{U+00EF}", '&iuml;'], # ï
498                ["\N{U+00F0}", '&eth;'],  # ð
499                ["\N{U+00F1}", '&ntilde;'],       # ñ
500                ["\N{U+00F4}", '&ocirc;'],        # ô
501                ["\N{U+00F6}", '&ouml;'],
502                ["\N{U+00F8}", '&oslash;'],       # ø
503                ["\N{U+00FA}", '&uacute;'],       # ú
504                ["\N{U+00FC}", '&uuml;'], # ü
505                ["\N{U+00FE}", '&thorn;'],        # þ
506                ["\N{U+00C1}", '&Aacute;'],       # Á
507                ["\N{U+00C9}", '&Eacute;'],
508                ["\N{U+00CA}", '&ecirc;'],
509                ["\N{U+00EB}", '&euml;'],
510                ["\N{U+00F3}", '&oacute;'],
511                ["\N{U+015B}", '&sacute;'],
512                ["\N{U+00FB}", '&ucirc;'],
513                ["\N{U+0160}", '&Scaron;'],
514                ["\N{U+2013}", '&ndash;'],
515                ["\N{U+2014}", '&mdash;'],
516                ["\N{U+2018}", '&quot;'],
517                ["\N{U+2019}", '&quot;'],
518                ["\N{U+201C}", '&quot;'],
519                ["\N{U+201D}", '&quot;'],
520                ["\N{U+2026}", '...'],        # …
521                ["\N{U+2122}", '&trade;'],        # â„¢
522                ["\xe2\x84\xa2", '&trade;'],      # â„¢ UTF-8
523                ["\N{U+25CF}", '&#x25CF;'],       # ●
524        );
525
526
180
154
        $string = _sub_map(\$string, \@byte_map);
527
528
529
180
2255
        @byte_map = (
530                [ 'Á', '&Aacute;' ],
531                [ 'Ã¥', '&aring;' ],
532                [ 'ª', '&ordf;' ],
533                [ 'Å¡', '&scaron;' ],
534                [ 'Å ', '&Scaron;' ],
535                [ 'č', '&ccaron;' ],
536                [ 'Ž', '&Zcaron;' ],
537                [ 'ž', '&zcaron;' ],
538                [ 'à', '&agrave;' ],   # à
539                [ 'á', '&aacute;' ],
540                [ 'â', '&acirc;' ],
541                [ 'é', '&eacute;' ],
542                [ 'è', '&egrave;' ],
543                [ 'ç', '&ccedil;' ],
544                [ 'ê', '&ecirc;' ],
545                [ 'ë', '&euml;' ],
546                [ 'ð', '&eth;' ],
547                [ 'í', '&iacute;' ],
548                [ 'ï', '&iuml;' ],
549                [ 'ÃŽ', '&Icirc;' ],
550                [ '©', '&copy;' ],
551                [ '®', '&reg;' ],
552                [ 'ó', '&oacute;' ],
553                [ 'ô', '&ocirc;' ],
554                [ 'ö', '&ouml;' ],
555                [ 'ø', '&oslash;' ],
556                [ 'Å›', '&sacute;' ],
557                [ 'Þ', '&THORN;' ],
558                [ 'þ', '&thorn;' ],
559                [ 'û', '&ucirc;' ],
560                [ 'ü', '&uuml;' ],
561                [ 'ú', '&uacute;' ],
562                [ 'µ', '&micro;'],
563                [ '£', '&pound;' ],
564                [ 'ß', '&szlig;' ],
565                [ '–', '&ndash;' ],
566                [ '—', '&mdash;' ],
567                [ 'ñ', '&ntilde;' ],
568                [ '“', '&quot;' ],
569                [ '”', '&quot;' ],
570                [ '«', '&quot;' ],
571                [ '»', '&quot;' ],
572                [ '…', '...' ],
573                [ 'â„¢', '&trade;' ],
574                [ '●', '&#x25CF;' ],
575                [ "\x80\$", ' ' ],
576        );
577
578
180
153
        $string = _sub_map(\$string, \@byte_map);
579
580
180
217
        if($string =~ /[^[:ascii:]]/) {
581
11
18
                $string = HTML::Entities::encode_entities_numeric($string, '\x80-\x{10FFFF}');
582
11
180
                if($string =~ /[^[:ascii:]]/) {
583
7
19
                        $complain->("TODO: wide_to_html($string)") if($complain);
584                        # Sanitize non-ASCII to hex tokens before embedding in the error message
585
6
12
                        $string =~ s{
586                                        ([^[:ascii:]])
587                                }{
588
6
24
                                        '>>>>' . sprintf('%04X', ord($1)) . '<<<<'
589                                }gex;
590
6
120
                        carp "TODO: wide_to_html($string)";
591
6
912
                        croak "BUG: wide_to_html($string)";
592                }
593        }
594
595
173
680
        return $string;
596}
597
598 - 702
=head2 wide_to_xml

Convert a Unicode or UTF-8 string into a pure-ASCII XML fragment.  Every
non-ASCII character is replaced by a hexadecimal numeric entity
(e.g. C<&#x0E9;>).  Only numeric entities are used because named HTML entities
such as C<&eacute;> are not defined in XML 1.0 outside of XHTML with a DTD.
Em-dashes and en-dashes are folded to a plain ASCII hyphen C<->.
Bare ampersands, angle brackets, and double-quotes are escaped so the output
is valid XML element content.

=head3 Arguments

All parameters are passed as a flat key-value list.  The C<string> key may be
omitted when passing a bare positional string as the first argument.

See L</COMMON PARAMETERS> for C<string>, C<keep_hrefs>, and C<complain>.
This function does not accept C<keep_apos>.

=head3 Returns

A defined scalar string whose every character is in the ASCII range
(code points 0x00-0x7F).  The empty string is returned unchanged.

=head3 EXAMPLE

    use Encode::Wide qw(wide_to_xml);

    # Accented characters become numeric entities
    my $out = wide_to_xml(string => "SURN \x{017D}ganjar");
    # => 'SURN &#x17D;ganjar'

    # En-dash and em-dash are folded to a plain hyphen
    $out = wide_to_xml(string => "2020\x{2013}2026");
    # => '2020-2026'

    # Ampersands and angle brackets are XML-escaped
    $out = wide_to_xml(string => 'a < b & c > 0');
    # => 'a &lt; b &amp; c &gt; 0'

    # keep_hrefs: XML tags pass through; wide chars are still encoded
    $out = wide_to_xml(
        string     => '<item lang="fr">Caf\x{E9}</item>',
        keep_hrefs => 1,
    );
    # => '<item lang="fr">Caf&#x0E9;</item>'

    # Scalar reference input
    my $text = "caf\x{E9}";
    $out = wide_to_xml(string => \$text);
    # => 'caf&#x0E9;'

=head3 MESSAGES

=over 4

=item C<Usage: wide_to_xml() string not set>

B<Fatal> (via C<croak>).  The C<string> parameter was C<undef>.
Resolution: pass a defined scalar or scalar reference.

=item C<TODO: wide_to_xml(E<lt>hex-tokens...E<gt>)>

B<Warning> (via C<carp>).  A character survived all three byte_map passes.
The hex tokens in the message identify the unhandled codepoint(s).
Resolution: add the character to the appropriate byte_map array, or file a bug
report at L<https://rt.cpan.org/Public/Dist/Display.html?Name=Encode-Wide>.

=item C<BUG: wide_to_xml(E<lt>hex-tokens...E<gt>)>

B<Fatal> (via C<croak>), always preceded by the C<TODO> warning above.
This should never occur in normal use; it indicates a gap in the XML character
tables.  Unlike C<wide_to_html>, there is no numeric-entity fallback for XML
because there is no safe generic fallback that is valid in all XML contexts.

=back

=head3 API SPECIFICATION

=head4 Input

    {
        string     => { type => SCALAR | SCALARREF, required => 1, defined => 1 },
        keep_hrefs => { type => BOOLEAN, optional => 1, default => 0 },
        complain   => { type => CODEREF,  optional => 1 },
    }

=head4 Output

    { type => SCALAR, constraint => sub { $_[0] !~ /[^[:ascii:]]/ } }

=head3 PSEUDOCODE

    1. Unless keep_hrefs: decode HTML entities via HTML::Entities::decode
       and the four extra named entities (&ccaron; &zcaron; &Zcaron; &Scaron;)
    2. Escape bare & not followed by a valid entity name
       (possessive ++ quantifier prevents ReDoS backtracking)
    3. Unless keep_hrefs: escape <, >, and " using %_HTML_ESCAPE (no /e eval)
    4. First byte_map pass: curly quotes -> &quot;, dashes -> -, apostrophes
    5. Early return if the string is now pure ASCII
    6. Second byte_map pass: raw UTF-8 byte sequences -> numeric XML entities
    7. Third byte_map pass: Perl Unicode chars (\N{U+...}) -> numeric XML entities
    8. If non-ASCII still remains: invoke complain callback, carp a TODO warning,
       then croak a BUG error

=cut
703
704# See https://www.compart.com/en/unicode/U+0161 etc.
705#       https://www.compart.com/en/unicode/U+00EB
706sub wide_to_xml
707{
708
275
106893
        my $params = Params::Get::get_params('string', @_);
709
710
274
2877
        my $string = $params->{'string'};
711
274
162
        my $complain = $params->{'complain'};
712
713
274
233
        if(!defined($string)) {
714
5
35
                croak 'Usage: wide_to_xml() string not set';
715        }
716
717
269
220
        if(ref($string) eq 'SCALAR') {
718
21
21
12
13
                $string = ${$string};
719        }
720
721
722        # SECURITY: skip entity-decoding when keep_hrefs is set — same rationale as
723        # wide_to_html: decoded payloads bypass re-escaping and produce XSS output.
724
269
225
        unless($params->{'keep_hrefs'}) {
725
258
416
                $string = HTML::Entities::decode($string);
726
727                # Decode the four named entities HTML::Entities::decode misses
728
258
604
                $string =~ s/($_EXTRA_ENTITY_RE)/$_EXTRA_ENTITY_MAP{$1}/g;
729        }
730
731        # Possessive ++ prevents O(n^2) backtracking (ReDoS) on inputs like
732        # "&aaaaaa..." with no closing semicolon.
733
269
200
        $string =~ s/&(?![A-Za-z#0-9]++;)/&amp;/g;
734
735
269
195
        unless($params->{'keep_hrefs'}) {
736                # Escape ASCII markup chars; %_HTML_ESCAPE eliminates the /e eval flag.
737
258
253
                $string =~ s/([<>"])/$_HTML_ESCAPE{$1}/g;
738        }
739
740        # $string =~ s/‘/&apos;/g;
741        # $string =~ s/’/&apos;/g;
742        # $string =~ s/‘/&apos;/g;
743        # $string =~ s/‘/&apos;/g;
744        # $string =~ s/\x98/&apos;/g;
745        # $string =~ s/['‘’‘\x98]/&apos;/g;
746
747        # Table of byte-sequences->entities
748
269
680
        my @byte_map = (
749                [ "\xe2\x80\x9c", '&quot;' ],     # “
750                [ "\xe2\x80\x9d", '&quot;' ],     # ”
751                [ '“', '&quot;' ],        # U+201C
752                [ '”', '&quot;' ],        # U+201D
753                [ "\xe2\x80\x93", '-' ],      # ndash
754                [ "\xe2\x80\x94", '-' ],      # mdash
755                [ "\xe2\x80\x98", '&apos;' ],     # ‘
756                [ "\xe2\x80\x99", '&apos;' ],     # ’
757                [ "\xe2\x80\xA6", '...' ],    # …
758                [ "'", '&apos;' ],
759                [ '‘', '&apos;' ],
760                [ '’', '&apos;' ],
761                [ '‘', '&apos;' ],
762                [ "\x98", '&apos;' ],
763        );
764
765
269
227
        $string = _sub_map(\$string, \@byte_map);
766
767        # DEAD CODE: the %entity_map below has keys that are multi-character HTML
768        # entity strings (e.g. '&copy;', '&Aacute;').  The s{(.)}{...}gex loop
769        # that follows matches one character at a time; a single char can never
770        # equal a multi-char key, so the true branch is unreachable.
771        # By the time the string reaches here, HTML::Entities::decode has already
772        # converted all recognised named entities to their Unicode equivalents,
773        # which the byte_map passes below encode correctly.
774        # Additional bugs in the removed block: '&uacute;' => '&#0FA;' (decimal,
775        # not hex) and '&ucirc;' => '&#x0F4;' (maps to o-circumflex, not u-circumflex).
776        # This block has been commented out to remove dead code and reach 100%
777        # branch coverage.  See t/extended_tests.t section 3 for validation.
778        #
779        # %entity_map = (
780        #       '&copy;' => '&#x0A9;',
781        #       '&Aacute;' => '&#x0C1;',
782        #       '&ccaron;' => '&#x10D;',
783        #       ...
784        #       '&excl;' => '!',
785        # );
786        # $string =~ s{(.)}{
787        #       my $cp = $1;
788        #       exists $entity_map{$cp}
789        #               ? $entity_map{$cp}
790        #               : $cp
791        # }gex;
792
793
269
332
        if($string !~ /[^[:ascii:]]/) {
794
74
175
                return $string;
795        }
796
797        @byte_map = (
798
195
2397
                ["\xc2\xa0", ' '],    # Non breaking space
799                ["\xc2\xa3", '&#x0A3;'],  # £
800                ["\xc2\xa9", '&#x0A9;'],
801                ["\xc2\xaa", '&#x0AA;'],  # ª
802                ["\xc2\xab", '&quot;'],   # «
803                ["\xc2\xae", '&#x0AE;'],
804                ["\xc3\x81", '&#x0C1;'],  # Á
805                ["\xc3\x8e", '&#x0CE;'],  # ÃŽ
806                ["\xc3\xa0", '&#x0E0;'],  # à
807                ["\xc3\xa1", '&#x0E1;'],  # á
808                ["\xc3\xa5", '&#x0E5;'],  # Ã¥
809                ["\xc3\xa9", '&#x0E9;'],
810                ["\xc3\xaf", '&#x0EF;'],  # ï
811                ["\xc3\xb1", '&#x0F1;'],  # ntilde ñ
812                ["\xc5\xa1", '&#x161;'],
813                ["\xc4\x8d", '&#x10D;'],
814                ["\xc5\xbd", '&#x17D;'],  # Ž
815                ["\xc5\xbe", '&#x17E;'],  # ž
816                ["\xc3\x96", '&#x0D6;'],  # Ö
817                ["\xc3\x9e", '&#x0DE;'],  # Þ
818                ["\xc3\x9f", '&#x0DF;'],  # ß
819                ["\xc3\xa2", '&#x0E2;'],  # â
820                ["\xc3\xad", '&#x0ED;'],  # í
821                ["\xc3\xa4", '&#x0E4;'],  # ä
822                ["\xc3\xa7", '&#x0E7;'],  # ç
823                ["\xc3\xb0", '&#x0F0;'],  # ð
824                ["\xc3\xb3", '&#x0F3;'],  # ó
825                ["\xc3\xb8", '&#x0F8;'],  # ø
826                ["\xc3\xbc", '&#x0FC;'],  # ü
827                ["\xc3\xbe", '&#x0FE;'],  # þ
828                ["\xc3\xa8", '&#x0E8;'],  # è
829                ["\xc3\xee", '&#x0EE;'],
830                ["\xc3\xb4", '&#x0F4;'],  # ô
831                ["\xc3\xb6", '&#x0F6;'],  # ö
832                ["\xc3\x89", '&#x0C9;'],
833                ["\xc3\xaa", '&#x0EA;'],
834                ["\xc3\xab", '&#x0EB;'],  # eumlaut
835                ["\xc3\xba", '&#x0FA;'],  # ú
836                ["\xc3\xbb", '&#x0BB;'],  # û - ucirc
837                ["\xc5\x9b", '&#x15B;'],  # Å› - sacute
838                ["\xc5\xa0", '&#x160;'],
839                ["\xe2\x80\x93", '-'],
840                ["\xe2\x80\x94", '-'],
841                ["\xe2\x80\x9c", '&quot;'],
842                ["\xe2\x80\x9d", '&quot;'],
843                ["\xe2\x80\xa6", '...'],
844                ["\xe2\x97\x8f", '&#x25CF;'],     # ●
845                ["\xe3\xb1", '&#x0F1;'],  # ntilde ñ - what's this one?
846
847                ["\N{U+00A0}", ' '],
848                ["\N{U+010D}", '&#x10D;'],
849                ["\N{U+00AB}", '&quot;'], # «
850                ["\N{U+00AE}", '&#x0AE;'],        # ®
851                ["\N{U+00B5}", '&#x0B5;'],        # µ
852                ["\N{U+00C1}", '&#x0C1;'],        # Á
853                ["\N{U+00CE}", '&#x0CE;'],        # ÃŽ
854                ["\N{U+00DE}", '&#x0DE;'],        # Þ
855                ["\N{U+00E0}", '&#x0E0;'],        # à
856                ["\N{U+00E4}", '&#x0E4;'],        # ä
857                ["\N{U+00E5}", '&#x0E5;'],        # Ã¥
858                ["\N{U+00EA}", '&#x0EA;'],
859                ["\N{U+00ED}", '&#x0ED;'],
860                ["\N{U+00EE}", '&#x0EE;'],
861                ["\N{U+00FE}", '&#x0FE;'],        # þ
862                ["\N{U+00C9}", '&#x0C9;'],
863                ["\N{U+017D}", '&#x17D;'],        # Ž
864                ["\N{U+017E}", '&#x17E;'],        # ž
865                ["\N{U+00D6}", '&#x0D6;'],        # Ö
866                ["\N{U+00DF}", '&#x0DF;'],        # ß
867                ["\N{U+00E1}", '&#x0E1;'],        # á - aacute
868                ["\N{U+00E2}", '&#x0E2;'],
869                ["\N{U+00E8}", '&#x0E8;'],        # è
870                ["\N{U+00EF}", '&#x0EF;'],        # ï
871                ["\N{U+00F0}", '&#x0F0;'],        # ð
872                ["\N{U+00F1}", '&#x0F1;'],        # ñ
873                ["\N{U+00F3}", '&#x0F3;'],        # ó
874                ["\N{U+00F4}", '&#x0F4;'],        # ô
875                ["\N{U+00F6}", '&#x0F6;'],        # ö
876                ["\N{U+00F8}", '&#x0F8;'],        # ø
877                ["\N{U+00FA}", '&#x0FA;'],        # ú
878                ["\N{U+00FC}", '&#x0FC;'],        # ü
879                ["\N{U+015B}", '&#x15B;'],        # Å›
880                ["\N{U+00E9}", '&#x0E9;'],
881                ["\N{U+00E7}", '&#x0E7;'],        # ç
882                ["\N{U+00EB}", '&#x0EB;'],        # ë
883                ["\N{U+00FB}", '&#x0FB;'],        # û
884                ["\N{U+0160}", '&#x160;'],
885                ["\N{U+0161}", '&#x161;'],
886                ["\N{U+00A3}", '&#x0A3;'],        # £
887                ["\N{U+00A9}", '&#x0A9;'],        # ©
888                ["\N{U+2013}", '-'],
889                ["\N{U+2014}", '-'],
890                ["\N{U+2018}", '&quot;'],
891                ["\N{U+2019}", '&quot;'],
892                ["\N{U+201C}", '&quot;'],
893                ["\N{U+201D}", '&quot;'],
894                ["\N{U+2026}", '...'],        # …
895                ["\N{U+2122}", '&#x2122;'],       # â„¢
896                ["\xe2\x84\xa2", '&#x2122;'],     # â„¢ UTF-8
897                ["\N{U+25CF}", '&#x25CF;'],       # ●
898        );
899
900
195
161
        $string = _sub_map(\$string, \@byte_map);
901
902
195
2347
        @byte_map = (
903                ["'", '&#039;'],
904                ["\x98", '&#039;'],
905                ['©', '&#x0A9;'],
906                ['ª', '&#x0AA;'],
907                ['®', '&#x0AE;'],
908                ['Ã¥', '&#x0E5;'],
909                ['Å¡', '&#x161;'],
910                ['č', '&#x10D;'],
911                ['Ž', '&#x17D;'],
912                ['ž', '&#x17E;'],
913                ['£', '&#x0A3;'],
914                ['µ', '&#x0B5;'],
915                ['à', '&#x0E0;'],      # à
916                ['á', '&#x0E1;'],     # á
917                ['â', '&#x0E2;'],
918                ['ä', '&#x0E4;'],    # ä
919                ['Á', '&#x0C1;'],      # Á
920                ['Ö', '&#x0D6;'],
921                ['ß', '&#x0DF;'],
922                ['ç', '&#x0E7;'],
923                ['è', '&#x0E8;'],
924                ['é', '&#x0E9;'],
925                ['ê', '&#x0EA;'],
926                ['ë', '&#x0EB;'],
927                ['í', '&#x0ED;'],
928                ['ï', '&#x0EF;'],
929                ['ÃŽ', '&#x0CE;'],      # ÃŽ
930                ['Þ', '&#x0DE;'],      # Þ
931                ['ð', '&#x0F0;'],       # ð
932                ['ø', '&#x0F8;'],     # ø
933                ['û', '&#x0FB;'],
934                ['ñ', '&#x0F1;'],
935                ['ú', '&#x0FA;'],
936                ['ü', '&#x0FC;'],
937                ['þ', '&#x0FE;'],    # þ
938                ['“', '&quot;'],
939                ['”', '&quot;'],
940                ['«', '&quot;'],
941                ['»', '&quot;'],
942                ['—', '-'],
943                ['–', '-'],
944                ['…', '...'],
945                ['â„¢', '&#x2122;'],
946                ['●', '&#x25CF;'],
947                ["\x80\$", ' '],
948        );
949
950
195
167
        $string = _sub_map(\$string, \@byte_map);
951
952
195
211
        if($string =~ /[^[:ascii:]]/) {
953
10
19
                $complain->("TODO: wide_to_xml($string)") if($complain);
954                # Sanitize non-ASCII to hex tokens before embedding in the error message
955
10
36
                $string =~ s{
956                                ([^[:ascii:]])
957                        }{
958
11
36
                                '>>>>' . sprintf('%04X', ord($1)) . '<<<<'
959                        }gex;
960
10
126
                carp "TODO: wide_to_xml($string)";
961
10
1421
                croak "BUG: wide_to_xml($string)";
962        }
963
185
674
        return $string;
964}
965
966# _sub_map -- apply a list of [from, to] substitutions in a single pass.
967#
968# Purpose:    Replace every occurrence of each 'from' key with its 'to' value.
969#             Longer keys take priority over shorter ones (longest-match-first).
970# Entry:      $_[0] scalar-ref to string; $_[1] arrayref of [from, to] pairs.
971#             Duplicate 'from' keys: the first definition wins.
972# Exit:       Returns a new string (the scalar-ref argument is not modified).
973# Side Effects: None.
974sub _sub_map
975{
976
1314
1314
7687
799
        my $string   = ${$_[0]};
977
1314
648
        my $byte_map = $_[1];
978
979        # Build the alternation regex, longest key first to prevent partial matches
980        my $pattern = join '|',
981
61385
34683
                map  { quotemeta($_->[0]) }
982
110539
50436
                sort { length($b->[0]) <=> length($a->[0]) }
983
1314
1314
668
1343
                @{$byte_map};
984
985        # Pre-build a hash for O(1) lookup during substitution.
986        # Iterate in reverse so that the first definition in @byte_map wins on duplicate keys.
987
1314
1867
        my %map;
988
1314
1314
637
754
        for my $pair (reverse @{$byte_map}) {
989
61385
36267
                $map{$pair->[0]} = $pair->[1];
990        }
991
992        # No /e flag: hash dereference is a simple value interpolation, not code.
993
1314
62180
        $string =~ s/($pattern)/$map{$1}/g;
994
995
1314
5933
        return $string;
996}
997
998 - 1152
=head1 SECURITY

=head2 XSS via entity decode and keep_hrefs

By default both functions call C<HTML::Entities::decode> as the first pipeline
step, normalising input like C<&lt;b&gt;> to C<< <b> >> before re-escaping it.
This round-trip is safe when C<keep_hrefs> is false because the re-escape step
then converts C<< < >> and C<< > >> back to C<&lt;> and C<&gt;>.

When C<keep_hrefs =E<gt> 1> is set, the re-escape step is skipped so that
existing markup survives intact.  If the decode step still ran, a malicious
input such as C<&lt;script&gt;alert(1)&lt;/script&gt;> would become the raw
string C<< <script>alert(1)</script> >> and pass through to the output
unescaped, creating a stored XSS vector.

B<Fix applied in 0.07:> when C<keep_hrefs> is true, the decode step is also
skipped.  The pipeline treats the input as already-trusted HTML; wide
characters are still encoded, but entity normalisation becomes the caller's
responsibility.

=head2 ReDoS in bare-ampersand substitution

The substitution that escapes bare C<&> characters uses a negative lookahead
to distinguish bare ampersands from valid entity references.  A naive
backtracking quantifier inside that lookahead creates O(n^2) work for inputs
such as C<&aaaaa...X> (many word characters, no closing semicolon).

B<Fix applied in 0.07:> the character class inside the lookahead uses a
possessive quantifier C<[A-Za-z#0-9]++>, which commits matches and prevents
backtracking.  Perl 5.10 or later is required, consistent with the declared
C<MIN_PERL_VERSION>.

=head2 Eval-free substitutions

All substitutions in this module use plain C</g> rather than C</ge> (evaluate
replacement as Perl code).  The C</e> flag was present in earlier versions but
was unnecessary: hash lookups are value interpolation, not executable code.
Removing C</e> eliminates a class of potential code-injection issues should a
future change inadvertently expose user-controlled data in the replacement
expression.

=head1 LIMITATIONS

=over 4

=item Character coverage is hand-maintained

Both functions use explicit C<@byte_map> tables organised into three passes
(raw UTF-8 bytes, C<\N{U+...}> named chars, literal Unicode source chars).
Characters not covered by these tables fall back to
C<HTML::Entities::encode_entities_numeric> in C<wide_to_html>, or trigger a
fatal C<BUG:> error in C<wide_to_xml> (XML has no safe generic numeric
fallback).  To add a missing character, extend all three passes for the
relevant function and add a regression test in C<t/30-basics.t>.

=item No C<< <script> >> or C<< <style> >> awareness

C<wide_to_html> encodes wide characters uniformly regardless of context.  It
does not detect content inside C<< <script> >> or C<< <style> >> blocks, so
passing a complete HTML document through this function will corrupt embedded
scripts and stylesheets.  Feed only text fragments or attribute values, not
full documents.

=item XML numeric entity format uses a minimal hex width

The XML pipeline outputs C<&#x0E9;> (three hex digits with one leading zero
for values below 0x100) rather than the canonical four-digit form C<&#x00E9;>.
Both representations are valid XML 1.0.  Consumers that perform strict byte-
level comparison of entity strings should normalise to a consistent width
before comparing.

=item Raw binary input is not supported

The module assumes its input is either a Perl Unicode string (internal C<utf8>
flag set) or a valid UTF-8 byte string.  Passing arbitrary binary data or
text in a single-byte encoding other than Latin-1 will produce incorrect
output or trigger decoding errors.  Decode the input with L<Encode/decode>
before calling these functions.

=item C<keep_hrefs> shifts trust to the caller

When C<keep_hrefs =E<gt> 1> is set, entity-decoding is suppressed and markup
characters pass through unescaped.  The caller must guarantee that the input
does not contain untrusted content that could produce XSS output.

=back

=head1 SEE ALSO

=over 4

=item * L<Test Dashboard|https://nigelhorne.github.io/Encode-Wide/coverage/>

=item * L<HTML::Entities> — the standard module for HTML entity encoding and decoding

=item * L<Encode> — Perl's core character encoding framework

=item * L<XML::Entities> — decodes XML named entities (the inverse of wide_to_xml)

=item * L<Unicode::Escape> — alternative Unicode escaping approaches

=item * L<https://www.compart.com/en/unicode/> — Unicode character reference

=back

=head1 SUPPORT

Please report bugs and feature requests through the RT bug tracker:

    https://rt.cpan.org/Public/Dist/Display.html?Name=Encode-Wide

Or by email: C<bug-encode-wide at rt.cpan.org>

You will be notified automatically of progress on your report.

=head1 FORMAL SPECIFICATION

=head2 wide_to_html

Let S be the input string, S' the output string.

    âˆ€ c ∈ S' : ord(c) ≤ 0x7F                           (ASCII-only output)
    S = ""  âŸ¹  S' = ""                                  (empty pass-through)
    keep_hrefs = 0 ⟹ "<" ∉ S' ∧ ">" ∉ S' ∧ ∄ bare " in S'
    keep_apos  = 0 ⟹ ∄ bare apostrophe in S'
    Â¬âˆƒ bare & in S'  (& appears only as part of a valid &name; or &#xNN; entity)
    string = undef ⟹ croak("Usage: wide_to_html() string not set")

=head2 wide_to_xml

Let S be the input string, S' the output string.

    âˆ€ c ∈ S' : ord(c) ≤ 0x7F                           (ASCII-only output)
    S = ""  âŸ¹  S' = ""                                  (empty pass-through)
    keep_hrefs = 0 ⟹ "<" ∉ S' ∧ ">" ∉ S' ∧ ∄ bare " in S'
    U+2013 ∈ S ⟹ "-" ∈ S' ∧ U+2013 ∉ S'               (en-dash collapsed)
    U+2014 ∈ S ⟹ "-" ∈ S' ∧ U+2014 ∉ S'               (em-dash collapsed)
    Â¬âˆƒ bare & in S'  (& appears only as part of a valid &name; or &#xNN; entity)
    string = undef ⟹ croak("Usage: wide_to_xml() string not set")

=head1 AUTHOR

Nigel Horne, C<< <njh at nigelhorne.com> >>

=head1 LICENCE AND COPYRIGHT

Copyright 2025-2026 Nigel Horne.

This library is free software; you may redistribute it and/or modify it under
the same terms as Perl itself (GPL version 2 or later).

If you use this module, please let me know at
C<njh at nigelhorne.com>.

=cut
1153
11541;