TER1 (Statement): 100.00%
TER2 (Branch): 100.00%
TER3 (LCSAJ): 100.0% (14/14)
Approximate LCSAJ segments: 33
● Covered — this LCSAJ path was executed during testing.
● Not covered — this LCSAJ path was never executed. These are the paths to focus on.
Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:
start → end → jump
Uncovered paths show [NOT COVERED] in the tooltip.
1: package Encode::Wide; 2: 3: # TODO: don't transform anything within <script>...</script> in wide_to_html 4: 5: use strict; 6: use warnings; 7: 8: use Carp qw(croak carp confess); 9: use Exporter qw(import); 10: use HTML::Entities; 11: use Params::Get 0.13; 12: 13: our @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. Š before &s...). 18: my %_EXTRA_ENTITY_MAP = ( 19: 'č' => "\x{010D}", # c with caron 20: 'ž' => "\x{017E}", # z with caron 21: 'Ž' => "\x{017D}", # Z with caron 22: 'Š' => "\x{0160}", # S with caron 23: ); 24: my $_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 31: my %_HTML_ESCAPE = ( '<' => '<', '>' => '>', '"' => '"' ); 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: =head1 NAME 44: 45: Encode::Wide - Convert wide characters (Unicode, UTF-8, etc.) into ASCII-safe HTML or XML entities 46: 47: =head1 VERSION 48: 49: 0.07 50: 51: =cut 52: 53: our $VERSION = 0.07; 54: 55: =encoding UTF-8 56: 57: =head1 SYNOPSIS 58: 59: use Encode::Wide qw(wide_to_html wide_to_xml); 60: 61: # Basic HTML conversion 62: my $html = wide_to_html(string => "Cafe\x{E9} d\x{E9}j\x{E0} vu"); 63: # => 'Café déjà vu' 64: 65: # Basic XML conversion (numeric entities, en-dash folded to hyphen) 66: my $xml = wide_to_xml(string => "Cafe\x{E9} \x{2013} na\x{EF}ve"); 67: # => 'Café - naïve' 68: 69: # Preserve embedded HTML markup (keep_hrefs) 70: my $linked = wide_to_html( 71: string => '<a href="/menu">Caf\x{E9}</a>', 72: keep_hrefs => 1, 73: ); 74: # => '<a href="/menu">Café</a>' 75: 76: # Keep apostrophes literal for JavaScript contexts (keep_apos) 77: my $js_safe = wide_to_html( 78: string => "it\x{2019}s na\x{EF}ve", 79: keep_apos => 1, 80: ); 81: # => "it\x{2019}s naïve" (curly apostrophe kept; i-umlaut encoded) 82: 83: # Get notified about unhandled characters instead of dying silently 84: my $out = wide_to_html( 85: string => $untrusted, 86: complain => sub { warn "Unhandled: $_[0]" }, 87: ); 88: 89: # Accept a scalar reference 90: my $text = "na\x{EF}ve"; 91: my $safe = wide_to_html(string => \$text); 92: # => 'naïve' 93: 94: =head1 DESCRIPTION 95: 96: Encode::Wide converts strings that contain non-ASCII (wide) characters into 97: pure 7-bit ASCII output suitable for embedding in HTML pages or XML documents. 98: Every non-ASCII codepoint is replaced by the appropriate entity reference so 99: the output can be safely placed in HTML attributes, HTML body text, or XML 100: element content without triggering encoding errors or security issues. 101: 102: =head2 Why use this module? 103: 104: L<HTML::Entities> is the obvious alternative for HTML, but it makes strict 105: assumptions about input encoding that cause silent failures when the input 106: arrives as raw UTF-8 bytes, already-partially-encoded entities, or a mix of 107: both. Encode::Wide handles all three representations through a multi-pass 108: pipeline and falls back to L<HTML::Entities> numeric encoding for any 109: character not explicitly listed in its tables. 110: 111: For XML, L<XML::Entities> works in the opposite direction (decoding entities, 112: not encoding them). Encode::Wide fills that gap. 113: 114: =head2 Input 115: 116: Both functions accept: 117: 118: =over 4 119: 120: =item * 121: 122: A B<Perl Unicode string> (the internal C<utf8> flag is set) - the normal case 123: when input comes from L<Encode/decode>, a database driver with C<pg_enable_utf8>, 124: or a source file declared C<use utf8>. 125: 126: =item * 127: 128: A B<raw UTF-8 byte string> - the common case when input arrives from a legacy 129: web form or an older database driver without automatic decoding. The pipeline's 130: raw-byte substitution pass handles this transparently. 131: 132: =item * 133: 134: A B<scalar reference> - C<wide_to_html(string =E<gt> \$var)>. The string is 135: read from the referent; the referent is not modified. 136: 137: =item * 138: 139: B<Already-encoded HTML entities> - e.g. C<é> or C<<>. 140: By default the pipeline decodes these first so they are not double-encoded. 141: Pass C<keep_hrefs =E<gt> 1> to suppress decoding when the input contains 142: trusted HTML that must pass through unchanged. 143: 144: =back 145: 146: =head2 Output 147: 148: Both functions return a B<defined scalar string> containing B<only ASCII 149: characters> (code points 0x00-0x7F). The output is safe to concatenate 150: directly into an HTML or XML document without further escaping. 151: 152: =head2 Choosing between the two functions 153: 154: Use C<wide_to_html> when writing into an HTML context (C<< <p> >>, C<< <td> >>, 155: attribute values, etc.). Named entities such as C<é> and C<–> 156: are used wherever possible; they are compact and human-readable in the source. 157: 158: Use C<wide_to_xml> when writing into an XML context (XHTML, RSS, Atom, custom 159: XML schemas). Named HTML entities other than the five predefined XML entities 160: (C<&> C<<> C<>> C<'> C<">) are not valid in XML. 161: This function uses only hexadecimal numeric entities (C<é>), which are 162: valid in all XML 1.0 processors. Em-dashes and en-dashes are folded to a 163: plain ASCII hyphen because many XML consumers normalise whitespace and 164: punctuation anyway. 165: 166: =head1 EXPORT 167: 168: Nothing is exported by default. Import the functions you need explicitly: 169: 170: use Encode::Wide qw(wide_to_html); # one function 171: use Encode::Wide qw(wide_to_html wide_to_xml); # both 172: 173: =head1 COMMON PARAMETERS 174: 175: Both functions accept the following named parameters in addition to C<string>. 176: Pass them as a flat key-value list: 177: 178: wide_to_html(string => $text, keep_hrefs => 1, complain => \&handler); 179: 180: =over 4 181: 182: =item C<string> (required) 183: 184: The text to encode. May be a plain scalar or a B<reference to a scalar>. 185: Must be defined; passing C<undef> causes the function to C<croak> with a 186: usage message. 187: 188: =item C<keep_hrefs> (optional, default 0) 189: 190: When true, angle brackets (C<< < >>, C<< > >>) and double-quotes (C<">) are 191: B<not> escaped, allowing embedded HTML or XML markup to survive intact. 192: 193: B<Security note:> when C<keep_hrefs> is set, entity-decoding is also 194: suppressed. Without this suppression, an encoded payload such as 195: C<<script>> would be decoded to C<< <script> >> and then pass through 196: unescaped, creating an XSS vector. With C<keep_hrefs =E<gt> 1> it is the 197: B<caller's responsibility> to ensure that the input does not contain untrusted 198: content that could be exploited. 199: 200: =item C<complain> (optional) 201: 202: A code reference called with a diagnostic string when the pipeline encounters a 203: character it cannot encode. The function still C<croak>s with a C<BUG:> 204: prefix after invoking the callback - C<complain> is for logging, not recovery. 205: 206: wide_to_html( 207: string => $text, 208: complain => sub { 209: my ($msg) = @_; 210: warn "Encode::Wide gap: $msg"; 211: }, 212: ); 213: 214: =back 215: 216: =head2 wide_to_html 217: 218: Convert a Unicode or UTF-8 string into a pure-ASCII HTML fragment. Every 219: non-ASCII character is replaced by its named HTML entity (e.g. C<é>) 220: where one exists, or a hexadecimal numeric entity (e.g. C<&#xNNNN;>) otherwise. 221: Bare ampersands, angle brackets, and double-quotes are also escaped so the 222: result is safe to embed in HTML body text or attribute values without further 223: processing. 224: 225: =head3 Arguments 226: 227: All parameters are passed as a flat key-value list. The C<string> key may be 228: omitted when passing a bare positional string as the first argument. 229: 230: See L</COMMON PARAMETERS> for C<string>, C<keep_hrefs>, and C<complain>. 231: 232: =over 4 233: 234: =item C<keep_apos> (optional, default 0) 235: 236: When true, apostrophes and their typographic variants (curly single quotes 237: U+2018, U+2019; grave accent U+0060; Windows-1252 byte 0x98) are B<not> 238: converted to C<'>. Useful when the result will be embedded inside a 239: JavaScript string literal where C<'> is not valid syntax. 240: 241: =back 242: 243: =head3 Returns 244: 245: A defined scalar string whose every character is in the ASCII range 246: (code points 0x00-0x7F). The empty string is returned unchanged. 247: 248: =head3 EXAMPLE 249: 250: use Encode::Wide qw(wide_to_html); 251: 252: # Accented characters to named entities 253: my $out = wide_to_html(string => "na\x{EF}ve caf\x{E9}"); 254: # => 'naïve café' 255: 256: # Ampersands and angle brackets are escaped 257: $out = wide_to_html(string => 'Price < 100 & cost > 0'); 258: # => 'Price < 100 & cost > 0' 259: 260: # Existing entities are decoded then re-encoded (no double-encoding) 261: $out = wide_to_html(string => 'é'); 262: # => 'é' 263: 264: # keep_hrefs: HTML markup passes through; only wide chars are encoded 265: $out = wide_to_html( 266: string => '<a href="/m\x{E9}nu">Men\x{FC}</a>', 267: keep_hrefs => 1, 268: ); 269: # => '<a href="/ménu">Menü</a>' 270: 271: # keep_apos: apostrophes kept for JavaScript contexts 272: $out = wide_to_html( 273: string => "it\x{2019}s clich\x{E9}", 274: keep_apos => 1, 275: ); 276: # => "it\x{2019}s cliché" 277: 278: # Scalar reference input 279: my $text = "caf\x{E9}"; 280: $out = wide_to_html(string => \$text); 281: # => 'café' 282: 283: =head3 MESSAGES 284: 285: =over 4 286: 287: =item C<Usage: wide_to_html() string not set> 288: 289: B<Fatal> (via C<croak>). The C<string> parameter was C<undef>. 290: Resolution: pass a defined scalar or scalar reference. 291: 292: =item C<TODO: wide_to_html(E<lt>hex-tokens...E<gt>)> 293: 294: B<Warning> (via C<carp>). A character survived all three byte_map passes and 295: the C<encode_entities_numeric> fallback. The hex tokens in the message 296: identify the unhandled codepoint(s). 297: Resolution: add the character to the appropriate byte_map array, or file a bug 298: report at L<https://rt.cpan.org/Public/Dist/Display.html?Name=Encode-Wide>. 299: 300: =item C<BUG: wide_to_html(E<lt>hex-tokens...E<gt>)> 301: 302: B<Fatal> (via C<croak>), always preceded by the C<TODO> warning above. 303: The same unhandled-character condition caused a hard failure. This should 304: never occur in normal use; it indicates a gap in the character tables. 305: 306: =back 307: 308: =head3 API SPECIFICATION 309: 310: =head4 Input 311: 312: { 313: string => { type => SCALAR | SCALARREF, required => 1, defined => 1 }, 314: keep_hrefs => { type => BOOLEAN, optional => 1, default => 0 }, 315: keep_apos => { type => BOOLEAN, optional => 1, default => 0 }, 316: complain => { type => CODEREF, optional => 1 }, 317: } 318: 319: =head4 Output 320: 321: { type => SCALAR, constraint => sub { $_[0] !~ /[^[:ascii:]]/ } } 322: 323: =head3 PSEUDOCODE 324: 325: 1. Unless keep_hrefs: decode HTML entities via HTML::Entities::decode 326: and the four extra named entities (č ž Ž Š) 327: 2. Escape bare & not followed by a valid entity name 328: (possessive ++ quantifier prevents ReDoS backtracking) 329: 3. Unless keep_hrefs: escape <, >, and " using %_HTML_ESCAPE (no /e eval) 330: 4. First byte_map pass: typographic punctuation and exclamation mark 331: 5. Unless keep_apos: encode apostrophe variants to ' 332: using an alternation regex built from the apostrophe key set 333: 6. Early return if the string is now pure ASCII 334: 7. Second byte_map pass: raw UTF-8 byte sequences -> named HTML entities 335: 8. Third byte_map pass: Perl Unicode chars (\N{U+...}) -> named HTML entities 336: 9. Fallback: HTML::Entities::encode_entities_numeric for any remaining 337: non-ASCII codepoints 338: 10. If non-ASCII still remains after the fallback: invoke complain callback, 339: carp a TODO warning, then croak a BUG error 340: 341: =cut 342: 343: sub wide_to_html 344: { ●345 → 350 → 354 345: my $params = Params::Get::get_params('string', @_); 346: 347: my $string = $params->{'string'}; 348: my $complain = $params->{'complain'}; 349: 350: if(!defined($string)) {Mutants (Total: 1, Killed: 1, Survived: 0)
351: croak 'Usage: wide_to_html() string not set'; 352: } 353: ●354 → 354 → 366 354: if(ref($string) eq 'SCALAR') {
Mutants (Total: 1, Killed: 1, Survived: 0)
355: $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 <script> 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 → 366 → 376 366: unless($params->{'keep_hrefs'}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
367: $string = HTML::Entities::decode($string); 368: 369: # Decode the four named entities HTML::Entities::decode misses 370: $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 → 378 → 388 376: $string =~ s/&(?![A-Za-z#0-9]++;)/&/g; 377: 378: unless($params->{'keep_hrefs'}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
379: # Escape the three characters that break HTML attribute or body contexts. 380: # Module-level %_HTML_ESCAPE eliminates the /e eval flag. 381: $string =~ s/([<>"])/$_HTML_ESCAPE{$1}/g; 382: } 383: 384: # $string =~ s/&db=/&db=/g; 385: # $string =~ s/&id=/&id=/g; 386: 387: # Table of byte-sequences->entities ●388 → 403 → 417 388: my @byte_map = ( 389: ['â', '"'], # U+201C 390: ['â', '"'], # U+201D 391: ["\xe2\x80\x9c", '"'], # â 392: ["\xe2\x80\x9d", '"'], # â 393: ["\xe2\x80\x93", '–'], 394: ["\xe2\x80\x94", '—'], 395: ["\xe2\x80\x98", '''], # â 396: ["\xe2\x80\x99", '''], # â 397: ["\xe2\x80\xA6", '...'], # ⦠398: ['!', '!'], # Do this early before the ascii check, since it's an ascii character 399: ); 400: 401: $string = _sub_map(\$string, \@byte_map); 402: 403: unless($params->{'keep_apos'}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
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: my %apos_map = ( 407: "'" => ''', 408: "\x{2018}" => ''', # U+2018 left single quotation mark 409: "\x{2019}" => ''', # U+2019 right single quotation mark 410: "\x{0060}" => ''', # U+0060 grave accent used as apostrophe 411: "\x98" => ''', 412: ); 413: my $apos_re = join '|', map { quotemeta } keys %apos_map; 414: $string =~ s/($apos_re)/$apos_map{$1}/g; 415: } 416: ●417 → 417 → 421 417: if($string !~ /[^[:ascii:]]/) {
Mutants (Total: 1, Killed: 1, Survived: 0)
418: return $string;
Mutants (Total: 2, Killed: 2, Survived: 0)
419: } 420: ●421 → 580 → 595 421: @byte_map = ( 422: ["\xc2\xa0", ' '], # Non breaking space 423: ["\xc2\xa3", '£'], 424: ["\xc2\xa9", '©'], 425: ["\xc2\xae", '®'], 426: ["\xc3\xa2", 'â'], 427: ["\xc3\xa4", 'ä'], 428: ["\xc3\xa9", 'é'], 429: ["\xc2\xaa", 'ª'], # ª 430: ["\xc2\xab", '"'], # « 431: ["\xc2\xbb", '"'], # » 432: ["\xc3\x81", 'Á'], # à 433: ["\xc3\x83", 'Î'], # à 434: ["\xc3\x9e", 'Þ'], # à 435: ["\xc3\xa0", 'à'], # à 436: ["\xc3\xa1", 'á'], # á 437: ["\xc3\xad", 'í'], # à 438: ["\xc3\xb0", 'ð'], # ð 439: ["\xc3\xba", 'ú'], # ú 440: ["\xc3\xb4", 'ô'], # ô 441: ["\xc3\xb6", 'ö'], 442: ["\xc3\xb8", 'ø'], # ø 443: ["\xc5\xa1", 'š'], 444: ["\xc4\x8d", 'č'], 445: ["\xc5\xbd", 'Ž'], 446: ["\xc5\xbe", 'ž'], 447: ["\xc3\xa5", 'å'], # Ã¥ 448: ["\xc3\xa7", 'ç'], 449: ["\xc3\xaf", 'ï'], # ï 450: ["\xc3\xb3", 'ó'], 451: ["\xc3\x96", 'Ö'], # à 452: ["\xc3\xa8", 'è'], 453: ["\xc3\x89", 'É'], 454: ["\xc3\x9f", 'ß'], 455: ["\xc3\xaa", 'ê'], 456: ["\xc3\xab", 'ë'], 457: ["\xc3\xae", 'î'], 458: ["\xc3\xbb", 'û'], 459: ["\xc3\xbc", 'ü'], # ü 460: ["\xc3\xbe", 'þ'], # þ 461: ["\xc5\x9b", 'ś'], 462: ["\xc5\xa0", 'Š'], 463: ["\xe2\x80\x93", '–'], 464: ["\xe2\x80\x94", '—'], 465: ["\xc3\xb1", 'ñ'], # ñ 466: ["\xe2\x80\x9c", '"'], 467: ["\xe2\x80\x9d", '"'], 468: ["\xe2\x80\xa6", '...'], 469: ["\xe2\x97\x8f", '●'], # â 470: ["\N{U+00A0}", ' '], 471: ["\N{U+00A3}", '£'], 472: ["\N{U+00A9}", '©'], 473: ["\N{U+00AA}", 'ª'], # ª 474: ["\N{U+00AB}", '"'], # « 475: ["\N{U+00AE}", '®'], 476: ["\N{U+00B5}", 'µ'], # µ 477: ["\N{U+00BB}", '"'], # » 478: ["\N{U+00CE}", 'Î'], # à 479: ["\N{U+00DE}", 'Þ'], # à 480: ["\N{U+0161}", 'š'], 481: ["\N{U+010D}", 'č'], 482: ["\N{U+017D}", 'Ž'], 483: ["\N{U+017E}", 'ž'], 484: ["\N{U+00C9}", 'É'], 485: ["\N{U+00D6}", 'Ö'], # à 486: ["\N{U+00DF}", 'ß'], # à 487: ["\N{U+00E1}", 'á'], # á 488: ["\N{U+00E2}", 'â'], 489: ["\N{U+00E4}", 'ä'], 490: ["\N{U+00E5}", 'å'], # Ã¥ 491: ["\N{U+00E0}", 'à'], # à 492: ["\N{U+00E7}", 'ç'], # ç 493: ["\N{U+00E8}", 'è'], 494: ["\N{U+00E9}", 'é'], 495: ["\N{U+00ED}", 'í'], # à 496: ["\N{U+00EE}", 'î'], 497: ["\N{U+00EF}", 'ï'], # ï 498: ["\N{U+00F0}", 'ð'], # ð 499: ["\N{U+00F1}", 'ñ'], # ñ 500: ["\N{U+00F4}", 'ô'], # ô 501: ["\N{U+00F6}", 'ö'], 502: ["\N{U+00F8}", 'ø'], # ø 503: ["\N{U+00FA}", 'ú'], # ú 504: ["\N{U+00FC}", 'ü'], # ü 505: ["\N{U+00FE}", 'þ'], # þ 506: ["\N{U+00C1}", 'Á'], # à 507: ["\N{U+00C9}", 'É'], 508: ["\N{U+00CA}", 'ê'], 509: ["\N{U+00EB}", 'ë'], 510: ["\N{U+00F3}", 'ó'], 511: ["\N{U+015B}", 'ś'], 512: ["\N{U+00FB}", 'û'], 513: ["\N{U+0160}", 'Š'], 514: ["\N{U+2013}", '–'], 515: ["\N{U+2014}", '—'], 516: ["\N{U+2018}", '"'], 517: ["\N{U+2019}", '"'], 518: ["\N{U+201C}", '"'], 519: ["\N{U+201D}", '"'], 520: ["\N{U+2026}", '...'], # ⦠521: ["\N{U+2122}", '™'], # ⢠522: ["\xe2\x84\xa2", '™'], # ⢠UTF-8 523: ["\N{U+25CF}", '●'], # â 524: ); 525: 526: $string = _sub_map(\$string, \@byte_map); 527: 528: 529: @byte_map = ( 530: [ 'Ã', 'Á' ], 531: [ 'Ã¥', 'å' ], 532: [ 'ª', 'ª' ], 533: [ 'Å¡', 'š' ], 534: [ 'Å ', 'Š' ], 535: [ 'Ä', 'č' ], 536: [ 'Ž', 'Ž' ], 537: [ 'ž', 'ž' ], 538: [ 'à ', 'à' ], # à 539: [ 'á', 'á' ], 540: [ 'â', 'â' ], 541: [ 'é', 'é' ], 542: [ 'è', 'è' ], 543: [ 'ç', 'ç' ], 544: [ 'ê', 'ê' ], 545: [ 'ë', 'ë' ], 546: [ 'ð', 'ð' ], 547: [ 'Ã', 'í' ], 548: [ 'ï', 'ï' ], 549: [ 'Ã', 'Î' ], 550: [ '©', '©' ], 551: [ '®', '®' ], 552: [ 'ó', 'ó' ], 553: [ 'ô', 'ô' ], 554: [ 'ö', 'ö' ], 555: [ 'ø', 'ø' ], 556: [ 'Å', 'ś' ], 557: [ 'Ã', 'Þ' ], 558: [ 'þ', 'þ' ], 559: [ 'û', 'û' ], 560: [ 'ü', 'ü' ], 561: [ 'ú', 'ú' ], 562: [ 'µ', 'µ'], 563: [ '£', '£' ], 564: [ 'Ã', 'ß' ], 565: [ 'â', '–' ], 566: [ 'â', '—' ], 567: [ 'ñ', 'ñ' ], 568: [ 'â', '"' ], 569: [ 'â', '"' ], 570: [ '«', '"' ], 571: [ '»', '"' ], 572: [ 'â¦', '...' ], 573: [ 'â¢', '™' ], 574: [ 'â', '●' ], 575: [ "\x80\$", ' ' ], 576: ); 577: 578: $string = _sub_map(\$string, \@byte_map); 579: 580: if($string =~ /[^[:ascii:]]/) {
Mutants (Total: 1, Killed: 1, Survived: 0)
581: $string = HTML::Entities::encode_entities_numeric($string, '\x80-\x{10FFFF}'); 582: if($string =~ /[^[:ascii:]]/) {
Mutants (Total: 1, Killed: 1, Survived: 0)
583: $complain->("TODO: wide_to_html($string)") if($complain); 584: # Sanitize non-ASCII to hex tokens before embedding in the error message 585: $string =~ s{ 586: ([^[:ascii:]]) 587: }{ 588: '>>>>' . sprintf('%04X', ord($1)) . '<<<<' 589: }gex; 590: carp "TODO: wide_to_html($string)"; 591: croak "BUG: wide_to_html($string)"; 592: } 593: } 594: 595: return $string;
Mutants (Total: 2, Killed: 2, Survived: 0)
596: } 597: 598: =head2 wide_to_xml 599: 600: Convert a Unicode or UTF-8 string into a pure-ASCII XML fragment. Every 601: non-ASCII character is replaced by a hexadecimal numeric entity 602: (e.g. C<é>). Only numeric entities are used because named HTML entities 603: such as C<é> are not defined in XML 1.0 outside of XHTML with a DTD. 604: Em-dashes and en-dashes are folded to a plain ASCII hyphen C<->. 605: Bare ampersands, angle brackets, and double-quotes are escaped so the output 606: is valid XML element content. 607: 608: =head3 Arguments 609: 610: All parameters are passed as a flat key-value list. The C<string> key may be 611: omitted when passing a bare positional string as the first argument. 612: 613: See L</COMMON PARAMETERS> for C<string>, C<keep_hrefs>, and C<complain>. 614: This function does not accept C<keep_apos>. 615: 616: =head3 Returns 617: 618: A defined scalar string whose every character is in the ASCII range 619: (code points 0x00-0x7F). The empty string is returned unchanged. 620: 621: =head3 EXAMPLE 622: 623: use Encode::Wide qw(wide_to_xml); 624: 625: # Accented characters become numeric entities 626: my $out = wide_to_xml(string => "SURN \x{017D}ganjar"); 627: # => 'SURN Žganjar' 628: 629: # En-dash and em-dash are folded to a plain hyphen 630: $out = wide_to_xml(string => "2020\x{2013}2026"); 631: # => '2020-2026' 632: 633: # Ampersands and angle brackets are XML-escaped 634: $out = wide_to_xml(string => 'a < b & c > 0'); 635: # => 'a < b & c > 0' 636: 637: # keep_hrefs: XML tags pass through; wide chars are still encoded 638: $out = wide_to_xml( 639: string => '<item lang="fr">Caf\x{E9}</item>', 640: keep_hrefs => 1, 641: ); 642: # => '<item lang="fr">Café</item>' 643: 644: # Scalar reference input 645: my $text = "caf\x{E9}"; 646: $out = wide_to_xml(string => \$text); 647: # => 'café' 648: 649: =head3 MESSAGES 650: 651: =over 4 652: 653: =item C<Usage: wide_to_xml() string not set> 654: 655: B<Fatal> (via C<croak>). The C<string> parameter was C<undef>. 656: Resolution: pass a defined scalar or scalar reference. 657: 658: =item C<TODO: wide_to_xml(E<lt>hex-tokens...E<gt>)> 659: 660: B<Warning> (via C<carp>). A character survived all three byte_map passes. 661: The hex tokens in the message identify the unhandled codepoint(s). 662: Resolution: add the character to the appropriate byte_map array, or file a bug 663: report at L<https://rt.cpan.org/Public/Dist/Display.html?Name=Encode-Wide>. 664: 665: =item C<BUG: wide_to_xml(E<lt>hex-tokens...E<gt>)> 666: 667: B<Fatal> (via C<croak>), always preceded by the C<TODO> warning above. 668: This should never occur in normal use; it indicates a gap in the XML character 669: tables. Unlike C<wide_to_html>, there is no numeric-entity fallback for XML 670: because there is no safe generic fallback that is valid in all XML contexts. 671: 672: =back 673: 674: =head3 API SPECIFICATION 675: 676: =head4 Input 677: 678: { 679: string => { type => SCALAR | SCALARREF, required => 1, defined => 1 }, 680: keep_hrefs => { type => BOOLEAN, optional => 1, default => 0 }, 681: complain => { type => CODEREF, optional => 1 }, 682: } 683: 684: =head4 Output 685: 686: { type => SCALAR, constraint => sub { $_[0] !~ /[^[:ascii:]]/ } } 687: 688: =head3 PSEUDOCODE 689: 690: 1. Unless keep_hrefs: decode HTML entities via HTML::Entities::decode 691: and the four extra named entities (č ž Ž Š) 692: 2. Escape bare & not followed by a valid entity name 693: (possessive ++ quantifier prevents ReDoS backtracking) 694: 3. Unless keep_hrefs: escape <, >, and " using %_HTML_ESCAPE (no /e eval) 695: 4. First byte_map pass: curly quotes -> ", dashes -> -, apostrophes 696: 5. Early return if the string is now pure ASCII 697: 6. Second byte_map pass: raw UTF-8 byte sequences -> numeric XML entities 698: 7. Third byte_map pass: Perl Unicode chars (\N{U+...}) -> numeric XML entities 699: 8. If non-ASCII still remains: invoke complain callback, carp a TODO warning, 700: then croak a BUG error 701: 702: =cut 703: 704: # See https://www.compart.com/en/unicode/U+0161 etc. 705: # https://www.compart.com/en/unicode/U+00EB 706: sub wide_to_xml 707: { ●708 → 713 → 717 708: my $params = Params::Get::get_params('string', @_); 709: 710: my $string = $params->{'string'}; 711: my $complain = $params->{'complain'}; 712: 713: if(!defined($string)) {
Mutants (Total: 1, Killed: 1, Survived: 0)
714: croak 'Usage: wide_to_xml() string not set'; 715: } 716: ●717 → 717 → 724 717: if(ref($string) eq 'SCALAR') {
Mutants (Total: 1, Killed: 1, Survived: 0)
718: $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 → 724 → 733 724: unless($params->{'keep_hrefs'}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
725: $string = HTML::Entities::decode($string); 726: 727: # Decode the four named entities HTML::Entities::decode misses 728: $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 → 735 → 748 733: $string =~ s/&(?![A-Za-z#0-9]++;)/&/g; 734: 735: unless($params->{'keep_hrefs'}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
736: # Escape ASCII markup chars; %_HTML_ESCAPE eliminates the /e eval flag. 737: $string =~ s/([<>"])/$_HTML_ESCAPE{$1}/g; 738: } 739: 740: # $string =~ s/â/'/g; 741: # $string =~ s/â/'/g; 742: # $string =~ s/â/'/g; 743: # $string =~ s/â/'/g; 744: # $string =~ s/\x98/'/g; 745: # $string =~ s/['âââ\x98]/'/g; 746: 747: # Table of byte-sequences->entities ●748 → 793 → 797 748: my @byte_map = ( 749: [ "\xe2\x80\x9c", '"' ], # â 750: [ "\xe2\x80\x9d", '"' ], # â 751: [ 'â', '"' ], # U+201C 752: [ 'â', '"' ], # U+201D 753: [ "\xe2\x80\x93", '-' ], # ndash 754: [ "\xe2\x80\x94", '-' ], # mdash 755: [ "\xe2\x80\x98", ''' ], # â 756: [ "\xe2\x80\x99", ''' ], # â 757: [ "\xe2\x80\xA6", '...' ], # ⦠758: [ "'", ''' ], 759: [ 'â', ''' ], 760: [ 'â', ''' ], 761: [ 'â', ''' ], 762: [ "\x98", ''' ], 763: ); 764: 765: $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. '©', 'Á'). 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: 'ú' => '�FA;' (decimal, 775: # not hex) and 'û' => 'ô' (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: # '©' => '©', 781: # 'Á' => 'Á', 782: # 'č' => 'č', 783: # ... 784: # '!' => '!', 785: # ); 786: # $string =~ s{(.)}{ 787: # my $cp = $1; 788: # exists $entity_map{$cp} 789: # ? $entity_map{$cp} 790: # : $cp 791: # }gex; 792: 793: if($string !~ /[^[:ascii:]]/) {
Mutants (Total: 1, Killed: 1, Survived: 0)
794: return $string;
Mutants (Total: 2, Killed: 2, Survived: 0)
795: } 796: ●797 → 952 → 963 797: @byte_map = ( 798: ["\xc2\xa0", ' '], # Non breaking space 799: ["\xc2\xa3", '£'], # £ 800: ["\xc2\xa9", '©'], 801: ["\xc2\xaa", 'ª'], # ª 802: ["\xc2\xab", '"'], # « 803: ["\xc2\xae", '®'], 804: ["\xc3\x81", 'Á'], # à 805: ["\xc3\x8e", 'Î'], # à 806: ["\xc3\xa0", 'à'], # à 807: ["\xc3\xa1", 'á'], # á 808: ["\xc3\xa5", 'å'], # Ã¥ 809: ["\xc3\xa9", 'é'], 810: ["\xc3\xaf", 'ï'], # ï 811: ["\xc3\xb1", 'ñ'], # ntilde ñ 812: ["\xc5\xa1", 'š'], 813: ["\xc4\x8d", 'č'], 814: ["\xc5\xbd", 'Ž'], # Ž 815: ["\xc5\xbe", 'ž'], # ž 816: ["\xc3\x96", 'Ö'], # à 817: ["\xc3\x9e", 'Þ'], # à 818: ["\xc3\x9f", 'ß'], # à 819: ["\xc3\xa2", 'â'], # â 820: ["\xc3\xad", 'í'], # à 821: ["\xc3\xa4", 'ä'], # ä 822: ["\xc3\xa7", 'ç'], # ç 823: ["\xc3\xb0", 'ð'], # ð 824: ["\xc3\xb3", 'ó'], # ó 825: ["\xc3\xb8", 'ø'], # ø 826: ["\xc3\xbc", 'ü'], # ü 827: ["\xc3\xbe", 'þ'], # þ 828: ["\xc3\xa8", 'è'], # è 829: ["\xc3\xee", 'î'], 830: ["\xc3\xb4", 'ô'], # ô 831: ["\xc3\xb6", 'ö'], # ö 832: ["\xc3\x89", 'É'], 833: ["\xc3\xaa", 'ê'], 834: ["\xc3\xab", 'ë'], # eumlaut 835: ["\xc3\xba", 'ú'], # ú 836: ["\xc3\xbb", '»'], # û - ucirc 837: ["\xc5\x9b", 'ś'], # Å - sacute 838: ["\xc5\xa0", 'Š'], 839: ["\xe2\x80\x93", '-'], 840: ["\xe2\x80\x94", '-'], 841: ["\xe2\x80\x9c", '"'], 842: ["\xe2\x80\x9d", '"'], 843: ["\xe2\x80\xa6", '...'], 844: ["\xe2\x97\x8f", '●'], # â 845: ["\xe3\xb1", 'ñ'], # ntilde ñ - what's this one? 846: 847: ["\N{U+00A0}", ' '], 848: ["\N{U+010D}", 'č'], 849: ["\N{U+00AB}", '"'], # « 850: ["\N{U+00AE}", '®'], # ® 851: ["\N{U+00B5}", 'µ'], # µ 852: ["\N{U+00C1}", 'Á'], # à 853: ["\N{U+00CE}", 'Î'], # à 854: ["\N{U+00DE}", 'Þ'], # à 855: ["\N{U+00E0}", 'à'], # à 856: ["\N{U+00E4}", 'ä'], # ä 857: ["\N{U+00E5}", 'å'], # Ã¥ 858: ["\N{U+00EA}", 'ê'], 859: ["\N{U+00ED}", 'í'], 860: ["\N{U+00EE}", 'î'], 861: ["\N{U+00FE}", 'þ'], # þ 862: ["\N{U+00C9}", 'É'], 863: ["\N{U+017D}", 'Ž'], # Ž 864: ["\N{U+017E}", 'ž'], # ž 865: ["\N{U+00D6}", 'Ö'], # à 866: ["\N{U+00DF}", 'ß'], # à 867: ["\N{U+00E1}", 'á'], # á - aacute 868: ["\N{U+00E2}", 'â'], 869: ["\N{U+00E8}", 'è'], # è 870: ["\N{U+00EF}", 'ï'], # ï 871: ["\N{U+00F0}", 'ð'], # ð 872: ["\N{U+00F1}", 'ñ'], # ñ 873: ["\N{U+00F3}", 'ó'], # ó 874: ["\N{U+00F4}", 'ô'], # ô 875: ["\N{U+00F6}", 'ö'], # ö 876: ["\N{U+00F8}", 'ø'], # ø 877: ["\N{U+00FA}", 'ú'], # ú 878: ["\N{U+00FC}", 'ü'], # ü 879: ["\N{U+015B}", 'ś'], # Å 880: ["\N{U+00E9}", 'é'], 881: ["\N{U+00E7}", 'ç'], # ç 882: ["\N{U+00EB}", 'ë'], # ë 883: ["\N{U+00FB}", 'û'], # û 884: ["\N{U+0160}", 'Š'], 885: ["\N{U+0161}", 'š'], 886: ["\N{U+00A3}", '£'], # £ 887: ["\N{U+00A9}", '©'], # © 888: ["\N{U+2013}", '-'], 889: ["\N{U+2014}", '-'], 890: ["\N{U+2018}", '"'], 891: ["\N{U+2019}", '"'], 892: ["\N{U+201C}", '"'], 893: ["\N{U+201D}", '"'], 894: ["\N{U+2026}", '...'], # ⦠895: ["\N{U+2122}", '™'], # ⢠896: ["\xe2\x84\xa2", '™'], # ⢠UTF-8 897: ["\N{U+25CF}", '●'], # â 898: ); 899: 900: $string = _sub_map(\$string, \@byte_map); 901: 902: @byte_map = ( 903: ["'", '''], 904: ["\x98", '''], 905: ['©', '©'], 906: ['ª', 'ª'], 907: ['®', '®'], 908: ['Ã¥', 'å'], 909: ['Å¡', 'š'], 910: ['Ä', 'č'], 911: ['Ž', 'Ž'], 912: ['ž', 'ž'], 913: ['£', '£'], 914: ['µ', 'µ'], 915: ['à ', 'à'], # à 916: ['á', 'á'], # á 917: ['â', 'â'], 918: ['ä', 'ä'], # ä 919: ['Ã', 'Á'], # à 920: ['Ã', 'Ö'], 921: ['Ã', 'ß'], 922: ['ç', 'ç'], 923: ['è', 'è'], 924: ['é', 'é'], 925: ['ê', 'ê'], 926: ['ë', 'ë'], 927: ['Ã', 'í'], 928: ['ï', 'ï'], 929: ['Ã', 'Î'], # à 930: ['Ã', 'Þ'], # à 931: ['ð', 'ð'], # ð 932: ['ø', 'ø'], # ø 933: ['û', 'û'], 934: ['ñ', 'ñ'], 935: ['ú', 'ú'], 936: ['ü', 'ü'], 937: ['þ', 'þ'], # þ 938: ['â', '"'], 939: ['â', '"'], 940: ['«', '"'], 941: ['»', '"'], 942: ['â', '-'], 943: ['â', '-'], 944: ['â¦', '...'], 945: ['â¢', '™'], 946: ['â', '●'], 947: ["\x80\$", ' '], 948: ); 949: 950: $string = _sub_map(\$string, \@byte_map); 951: 952: if($string =~ /[^[:ascii:]]/) {
Mutants (Total: 1, Killed: 1, Survived: 0)
953: $complain->("TODO: wide_to_xml($string)") if($complain); 954: # Sanitize non-ASCII to hex tokens before embedding in the error message 955: $string =~ s{ 956: ([^[:ascii:]]) 957: }{ 958: '>>>>' . sprintf('%04X', ord($1)) . '<<<<' 959: }gex; 960: carp "TODO: wide_to_xml($string)"; 961: croak "BUG: wide_to_xml($string)"; 962: } 963: return $string;
Mutants (Total: 2, Killed: 2, Survived: 0)
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. 974: sub _sub_map 975: { ●976 → 988 → 993 976: my $string = ${$_[0]}; 977: my $byte_map = $_[1]; 978: 979: # Build the alternation regex, longest key first to prevent partial matches 980: my $pattern = join '|', 981: map { quotemeta($_->[0]) } 982: sort { length($b->[0]) <=> length($a->[0]) } 983: @{$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: my %map; 988: for my $pair (reverse @{$byte_map}) { 989: $map{$pair->[0]} = $pair->[1]; 990: } 991: 992: # No /e flag: hash dereference is a simple value interpolation, not code. 993: $string =~ s/($pattern)/$map{$1}/g; 994: 995: return $string;
Mutants (Total: 2, Killed: 2, Survived: 0)
996: } 997: 998: =head1 SECURITY 999: 1000: =head2 XSS via entity decode and keep_hrefs 1001: 1002: By default both functions call C<HTML::Entities::decode> as the first pipeline 1003: step, normalising input like C<<b>> to C<< <b> >> before re-escaping it. 1004: This round-trip is safe when C<keep_hrefs> is false because the re-escape step 1005: then converts C<< < >> and C<< > >> back to C<<> and C<>>. 1006: 1007: When C<keep_hrefs =E<gt> 1> is set, the re-escape step is skipped so that 1008: existing markup survives intact. If the decode step still ran, a malicious 1009: input such as C<<script>alert(1)</script>> would become the raw 1010: string C<< <script>alert(1)</script> >> and pass through to the output 1011: unescaped, creating a stored XSS vector. 1012: 1013: B<Fix applied in 0.07:> when C<keep_hrefs> is true, the decode step is also 1014: skipped. The pipeline treats the input as already-trusted HTML; wide 1015: characters are still encoded, but entity normalisation becomes the caller's 1016: responsibility. 1017: 1018: =head2 ReDoS in bare-ampersand substitution 1019: 1020: The substitution that escapes bare C<&> characters uses a negative lookahead 1021: to distinguish bare ampersands from valid entity references. A naive 1022: backtracking quantifier inside that lookahead creates O(n^2) work for inputs 1023: such as C<&aaaaa...X> (many word characters, no closing semicolon). 1024: 1025: B<Fix applied in 0.07:> the character class inside the lookahead uses a 1026: possessive quantifier C<[A-Za-z#0-9]++>, which commits matches and prevents 1027: backtracking. Perl 5.10 or later is required, consistent with the declared 1028: C<MIN_PERL_VERSION>. 1029: 1030: =head2 Eval-free substitutions 1031: 1032: All substitutions in this module use plain C</g> rather than C</ge> (evaluate 1033: replacement as Perl code). The C</e> flag was present in earlier versions but 1034: was unnecessary: hash lookups are value interpolation, not executable code. 1035: Removing C</e> eliminates a class of potential code-injection issues should a 1036: future change inadvertently expose user-controlled data in the replacement 1037: expression. 1038: 1039: =head1 LIMITATIONS 1040: 1041: =over 4 1042: 1043: =item Character coverage is hand-maintained 1044: 1045: Both functions use explicit C<@byte_map> tables organised into three passes 1046: (raw UTF-8 bytes, C<\N{U+...}> named chars, literal Unicode source chars). 1047: Characters not covered by these tables fall back to 1048: C<HTML::Entities::encode_entities_numeric> in C<wide_to_html>, or trigger a 1049: fatal C<BUG:> error in C<wide_to_xml> (XML has no safe generic numeric 1050: fallback). To add a missing character, extend all three passes for the 1051: relevant function and add a regression test in C<t/30-basics.t>. 1052: 1053: =item No C<< <script> >> or C<< <style> >> awareness 1054: 1055: C<wide_to_html> encodes wide characters uniformly regardless of context. It 1056: does not detect content inside C<< <script> >> or C<< <style> >> blocks, so 1057: passing a complete HTML document through this function will corrupt embedded 1058: scripts and stylesheets. Feed only text fragments or attribute values, not 1059: full documents. 1060: 1061: =item XML numeric entity format uses a minimal hex width 1062: 1063: The XML pipeline outputs C<é> (three hex digits with one leading zero 1064: for values below 0x100) rather than the canonical four-digit form C<é>. 1065: Both representations are valid XML 1.0. Consumers that perform strict byte- 1066: level comparison of entity strings should normalise to a consistent width 1067: before comparing. 1068: 1069: =item Raw binary input is not supported 1070: 1071: The module assumes its input is either a Perl Unicode string (internal C<utf8> 1072: flag set) or a valid UTF-8 byte string. Passing arbitrary binary data or 1073: text in a single-byte encoding other than Latin-1 will produce incorrect 1074: output or trigger decoding errors. Decode the input with L<Encode/decode> 1075: before calling these functions. 1076: 1077: =item C<keep_hrefs> shifts trust to the caller 1078: 1079: When C<keep_hrefs =E<gt> 1> is set, entity-decoding is suppressed and markup 1080: characters pass through unescaped. The caller must guarantee that the input 1081: does not contain untrusted content that could produce XSS output. 1082: 1083: =back 1084: 1085: =head1 SEE ALSO 1086: 1087: =over 4 1088: 1089: =item * L<Test Dashboard|https://nigelhorne.github.io/Encode-Wide/coverage/> 1090: 1091: =item * L<HTML::Entities> â the standard module for HTML entity encoding and decoding 1092: 1093: =item * L<Encode> â Perl's core character encoding framework 1094: 1095: =item * L<XML::Entities> â decodes XML named entities (the inverse of wide_to_xml) 1096: 1097: =item * L<Unicode::Escape> â alternative Unicode escaping approaches 1098: 1099: =item * L<https://www.compart.com/en/unicode/> â Unicode character reference 1100: 1101: =back 1102: 1103: =head1 SUPPORT 1104: 1105: Please report bugs and feature requests through the RT bug tracker: 1106: 1107: https://rt.cpan.org/Public/Dist/Display.html?Name=Encode-Wide 1108: 1109: Or by email: C<bug-encode-wide at rt.cpan.org> 1110: 1111: You will be notified automatically of progress on your report. 1112: 1113: =head1 FORMAL SPECIFICATION 1114: 1115: =head2 wide_to_html 1116: 1117: Let S be the input string, S' the output string. 1118: 1119: â c â S' : ord(c) ⤠0x7F (ASCII-only output) 1120: S = "" â¹ S' = "" (empty pass-through) 1121: keep_hrefs = 0 â¹ "<" â S' â§ ">" â S' â§ â bare " in S' 1122: keep_apos = 0 â¹ â bare apostrophe in S' 1123: ¬â bare & in S' (& appears only as part of a valid &name; or &#xNN; entity) 1124: string = undef â¹ croak("Usage: wide_to_html() string not set") 1125: 1126: =head2 wide_to_xml 1127: 1128: Let S be the input string, S' the output string. 1129: 1130: â c â S' : ord(c) ⤠0x7F (ASCII-only output) 1131: S = "" â¹ S' = "" (empty pass-through) 1132: keep_hrefs = 0 â¹ "<" â S' â§ ">" â S' â§ â bare " in S' 1133: U+2013 â S â¹ "-" â S' â§ U+2013 â S' (en-dash collapsed) 1134: U+2014 â S â¹ "-" â S' â§ U+2014 â S' (em-dash collapsed) 1135: ¬â bare & in S' (& appears only as part of a valid &name; or &#xNN; entity) 1136: string = undef â¹ croak("Usage: wide_to_xml() string not set") 1137: 1138: =head1 AUTHOR 1139: 1140: Nigel Horne, C<< <njh at nigelhorne.com> >> 1141: 1142: =head1 LICENCE AND COPYRIGHT 1143: 1144: Copyright 2025-2026 Nigel Horne. 1145: 1146: This library is free software; you may redistribute it and/or modify it under 1147: the same terms as Perl itself (GPL version 2 or later). 1148: 1149: If you use this module, please let me know at 1150: C<njh at nigelhorne.com>. 1151: 1152: =cut 1153: 1154: 1;