Subversion Repositories web.active

Rev

Rev 1 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * ProcessWire Text Tools
 *
 * #pw-summary Specific text and markup tools for ProcessWire $sanitizer and elsewhere.
 *
 * ProcessWire 3.x, Copyright 2020 by Ryan Cramer
 * https://processwire.com
 * 
 * @since 3.0.101
 * 
 * @method array wordAlternates($word, array $options = array()) Protected method for hooking purposes only #pw-hooker #pw-internal
 * @method string wordStem($word) Protected method for hooking purposes only #pw-hooker #pw-internal
 *
 */

class WireTextTools extends Wire {

    /**
     * mbstring support?
     * 
     * @var bool
     * 
     */
    protected $mb;

    /**
     * Construct
     * 
     */
    public function __construct() {
        $this->mb = function_exists("mb_internal_encoding");
        parent::__construct();
    }
    
    /**
     * Convert HTML markup to readable text
     * 
     * Like PHP’s strip_tags but with some small improvements in HTML-to-text conversion that
     * improves the readability of the text. 
     * 
     * In 3.0.197+ inner content of script, style and object tags is now removed, rather than just the tags. 
     * To revert this behavior or to remove content of additional tags, see the `clearTags` option. 
     * 
     * Note that this method differs from the `Sanitizer::markupToText()` method in that this method is newer,
     * more powerful and has more options. But the two methods differ in how they perform markup-to-text 
     * conversion so you may want to review and try both to determine which one better suits your needs.
     * 
     * @param string $str String to convert to text
     * @param array $options 
     *  - `keepTags` (array): Tag names to keep in returned value, i.e. [ "em", "strong" ]. (default=none)
     *  - `clearTags` (array): Tags that should also have their content cleared. (default=[ "script", "style", "object" ]) Since 3.0.197
     *  - `splitBlocks` (string): String to split paragraph and header elements. (default="\n\n")
     *  - `convertEntities` (bool): Convert HTML entities to plain text equivalents? (default=true)
     *  - `listItemPrefix` (string): Prefix for converted list item `<li>` elements. (default='• ')
     *  - `linksToUrls` (bool): Convert links to `(url)` rather than removing? (default=true) Since 3.0.132
     *  - `linksToMarkdown` (bool): Convert links to `[text](url)` rather than removing? (default=false) Since 3.0.197
     *  - `uppercaseHeadlines` (bool): Convert headline tags to uppercase? (default=false) Since 3.0.132
     *  - `underlineHeadlines` (bool): Underline headlines with "=" or "-"? (default=true) Since 3.0.132
     *  - `collapseSpaces` (bool): Collapse extra/redundant extra spaces to single space? (default=true) Since 3.0.132
     *  - `replacements` (array): Associative array of strings to manually replace. (default=['&nbsp;' => ' '])
     * @return string
     * @see Sanitizer::markupToText()
     *
     */
    public function markupToText($str, array $options = array()) {
        
        $sanitizer = $this->wire()->sanitizer;
        
        $defaults = array(
            'keepTags' => array(),
            'clearTags' => array('script', 'style', 'object'), 
            'linksToUrls' => true, // convert links to just URL rather than removing entirely
            'linksToMarkdown' => false, // convert links to Markdown style links
            'splitBlocks' => "\n\n",
            'uppercaseHeadlines' => false, 
            'underlineHeadlines' => true, 
            'convertEntities' => true, 
            'listItemPrefix' => '• ', 
            'preIndent' => '', // indent for text within a <pre>
            'collapseSpaces' => true,
            'replacements' => array(
                '&nbsp;' => ' '
            ),
            'finishReplacements' => array(), // replacements applied at very end (internal)
        );

        // merge options using arrays
        foreach(array('replacements') as $key) {
            if(!isset($options[$key])) continue;
            $options[$key] = array_merge($defaults[$key], $options[$key]);
        }
        
        $options = array_merge($defaults, $options);

        if(strpos($str, '>') !== false) {

            // strip out everything up to and including </head>, if present
            if(strpos($str, '</head>') !== false) list(, $str) = explode('</head>', $str);

            // ensure tags are separated by whitespace
            $str = str_replace('><', '> <', $str);

            // normalize newlines
            if(strpos($str, "\r") !== false) {
                $str = str_replace(array("\r\n", "\r"), "\n", $str);
            }

            // normalize tabs to spaces
            if(strpos($str, "\t") !== false) {
                $str = str_replace("\t", " ", $str);
            }

            // ensure paragraphs and headers are followed by two newlines
            if(stripos($str, '</p') || stripos($str, '</h') || stripos($str, '</li') || stripos($str, '</bl') || stripos($str, '</div')) {
                $str = preg_replace('!(</?(?:p|h\d|ul|ol|pre|blockquote|div)>)!i', '$1' . $options['splitBlocks'], $str);
            }

            // ensure list items are on their own line and prefixed with a bullet
            if(stripos($str, '<li') !== false) {
                $prefix = in_array('li', $options['keepTags']) ? '' : $options['listItemPrefix'];
                $str = preg_replace('![\s\r\n]+<li[^>]*>[\s\r\n]*!i', "\n<li>$prefix", $str);
                if($prefix) {
                    $options['replacements']["\n$prefix "] = "\n$prefix"; // prevent extra space
                    $prefix = trim($prefix); 
                    $options['finishReplacements']["\n$prefix\n$prefix"] = ""; // prevent blank items
                    $options['finishReplacements']["\n$prefix\n"] = "";
                    
                }
            }

            // convert <br> tags to be just a single newline
            if(stripos($str, '<br') !== false) {
                $str = str_replace(array('<br>', '<br/>', '<br />', '</li>'), "<br>\n", $str);
                while(stripos($str, "\n<br>") !== false) $str = str_replace("\n<br>", "<br>", $str);
                while(stripos($str, "<br>\n\n") !== false) $str = str_replace("<br>\n\n", "<br>\n", $str);
            }

            // make headlines more prominent with underlines or uppercase
            if(($options['uppercaseHeadlines'] || $options['underlineHeadlines']) && stripos($str, '<h') !== false) {
                $topHtag = '';
                if($options['underlineHeadlines']) {
                    // determine which is the top level headline tag 
                    for($n = 1; $n <= 6; $n++) {
                        if(stripos($str, "<h$n") === false) continue;
                        $topHtag = "h$n";
                        break;
                    }
                }
                if(preg_match_all('!<(h[123456])[^>]*>(.+?)</\1>!is', $str, $matches)) {
                    foreach($matches[2] as $key => $headline) {
                        $fullMatch = $matches[0][$key];
                        $tagName = strtolower($matches[1][$key]);
                        $underline = '';
                        //$headline = trim($headline);
                        if($options['underlineHeadlines']) {
                            $char = $tagName === $topHtag ? '=' : '-';
                            $underline = "\n" . str_repeat($char, $this->strlen(trim(strip_tags($headline))));
                        }
                        if($options['uppercaseHeadlines']) $headline = strtoupper($headline);
                        $str = str_replace($fullMatch, "\n\n<$tagName>$headline</$tagName>$underline", $str);
                    }
                }
            }
        
            // convert "<a href='url'>text</a>" tags to "text (url)"
            if(($options['linksToUrls'] || $options['linksToMarkdown']) && stripos($str, '<a ') !== false) {
                if(preg_match_all('!<a\s[^<>]*href=([^\s>]+)[^<>]*>(.+?)</a>!is', $str, $matches)) {
                    $links = array();
                    foreach($matches[0] as $key => $fullMatch) {
                        $href = trim($matches[1][$key], '"\'');
                        if(strpos($href, '#') === 0) continue; // do not convert jumplinks
                        $anchorText = trim($matches[2][$key]);
                        $links[$fullMatch] = "[$anchorText]($href)";
                    }
                    if(count($links)) {
                        $str = str_replace(array_keys($links), array_values($links), $str); 
                    }
                    unset($links);
                }
            }
        
            // indent within <pre>...</pre> sections
            if(strlen($options['preIndent']) && strpos($str, '<pre') !== false) {
                if(preg_match_all('!<pre(?:>|\s[^>]*>)(.+?)</pre>!is', $str, $matches)) {
                    foreach($matches[0] as $key => $fullMatch) {
                        $lines = explode("\n", $matches[1][$key]);
                        foreach($lines as $k => $line) {
                            $lines[$k] = ':preIndent:' . rtrim($line); 
                        }
                        $str = str_replace($fullMatch, implode("\n", $lines), $str); 
                        $options['finishReplacements'][':preIndent:'] = $options['preIndent'];
                        unset($lines);
                    }
                }
            }
        
            // strip tags AND their contents for specified tags
            foreach($options['clearTags'] as $s) {
                $s = strtolower($s);
                if(stripos($str, "<$s") === false) continue;
                $str = str_ireplace(array("<$s", "</$s"), array("<$s", "</$s"), $str); // adjust case
                $parts = explode("<$s", $str); 
                foreach($parts as $key => $part) {
                    if(strpos($part, "</$s>") === false) {
                        if($key > 0) unset($parts[$key]); // remove nested inner content
                    } else {
                        $endparts = explode("</$s>", $part);
                        $parts[$key] = array_pop($endparts); // convert to content after last </s>
                    }
                }
                $str = implode("", $parts);
                unset($parts, $endparts, $s);
            }
        }

        // strip tags
        if(count($options['keepTags'])) {
            // some tags will be allowed to remain
            $keepTags = '';
            foreach($options['keepTags'] as $tag) {
                $keepTags .= "<" . trim($tag, "<>") . ">";
            }
            $str = strip_tags($str, $keepTags);

        } else {
            // not allowing any tags
            $str = strip_tags($str);
            // if any possible tag characters remain, drop them now
            $str = str_replace(array('<', '>'), ' ', $str);
        }

        // apply any other replacements
        foreach($options['replacements'] as $find => $replace) {
            $str = str_ireplace($find, $replace, $str);
        }

        // convert entities to plain text equivalents
        if($options['convertEntities'] && strpos($str, '&') !== false) {
            $str = $sanitizer->unentities($str);
        }
    
        // collapse any redundant/extra whitespace
        if($options['collapseSpaces']) {
            while(strpos($str, '  ') !== false) $str = str_replace('  ', ' ', $str);
        }
        
        // normalize newlines and whitespace around newlines
        while(strpos($str, " \n") !== false) $str = str_replace(" \n", "\n", $str);
        while(strpos($str, "\n ") !== false) $str = str_replace("\n ", "\n", $str);
        while(strpos($str, "\n\n\n") !== false) $str = str_replace("\n\n\n", "\n\n", $str);

        if(strpos($str, '](')) {
            // contains links
            if(strpos($str, '[](') !== false || strpos($str, '[ ](') !== false) {
                // remove links that lack anchor text
                $str = preg_replace('!\[\s*\]\([^)]*\)!', '', $str);
            }
            if($options['linksToUrls']) {
                // convert markdown style "[text](url)" to "text (url)"
                if(!$options['linksToMarkdown']) $str = preg_replace('!\[\s*(.+?)\]\(!', '$1 (', $str);
            }
        }

        if(count($options['finishReplacements'])) {
            $str = str_replace(array_keys($options['finishReplacements']), array_values($options['finishReplacements']), $str); 
        }

        return trim($str);
    }

    /**
     * Remove (or close) unclosed HTML tags from given string
     *
     * Remove unclosed tags:
     * ---------------------
     * At present, if it finds an unclosed tag, it removes all tags of the same kind.
     * This is in order to keep the function fast, by delegating what it can to strip_tags().
     * This is sufficient for our internal use here, but may not be ideal for all situations.
     * 
     * Fix/close unclosed tags:
     * ------------------------
     * When the remove option is false, it will attempt to close unclosed tags rather than 
     * remove them. It doesn't know exactly where they should be closed, so it appends the 
     * close tags to the end of the string. 
     * 
     * @param string $str
     * @param bool $remove Remove unclosed tags? If false, it will attempt to close them instead. (default=true)
     * @param array $options
     *  - `ignoreTags` (array): Tags that can be ignored because they close themselves. (default=per HTML spec)
     * @return string
     *
     */
    public function fixUnclosedTags($str, $remove = true, $options = array()) {
        
        $defaults = array(
            'ignoreTags' => array(
                'area','base','br','col','command','embed','hr','img','input',
                'keygen','link','menuitem','meta','param','source','track','wbr',
            ),
        );

        if(isset($options['ignoreTags'])) {
            // merge user specified ignoreTags with our defaults so that both are used
            $options['ignoreTags'] = array_merge($defaults['ignoreTags'], $options['ignoreTags']);
        }
        
        $options = array_merge($defaults, $options);
        $tags = array();
        $unclosed = array();

        $n1 = substr_count($str, '>');
        $n2 = substr_count($str, '</');

        if($n1) $n1 = $n1 / 2;
        
        // if the quantity of ">" is equal to double the quantity of "</" then early exit
        if($n1 === $n2) return $str;
    
        // now check for string possibly ending with a partial tag, and remove if present
        $n1 = strrpos($str, '<');
        $n2 = strrpos($str, '>');
        if($n1 > $n2) {
            // string might end with a partial tag, i.e. "<span"
            $test = substr($str, $n1 + 1, 1); // i.e. "s" from "<span", or "<" is last char in the string
            if(ctype_alpha($test) || $test === false || $test === '') {
                // going to assume this is a tag, so trucate 
                $str = substr($str, 0, $n1 - 1);
            }
        }

        // find all open tags
        if(!preg_match_all('!<([a-z]+[a-z0-9]*)(>|\s*/>|\s[^>]+>)!i', $str, $matches)) return $str;

        foreach($matches[1] as $key => $tag) {
            if(strpos($matches[2][$key], '/>') !== false) continue; // ignore self closing tags
            if(in_array(strtolower($tag), $options['ignoreTags'])) continue; 
            $tags[$tag] = $tag;
        }

        // count appearances of found tags
        foreach($tags as $tag) {
            // count number of open tags of this type
            $openQty = substr_count($str, "<$tag>") + substr_count($str, "<$tag ");
            // count number of closing tags of this type
            $closeQty = substr_count($str, "</$tag>");
            // if quantities do not match, mark tag for deletion
            if($openQty !== $closeQty) {
                unset($tags[$tag]);
                $unclosed[] = $tag;
            }
        }
        

        if(count($unclosed)) {
            if($remove) {
                // strip all tags except those where open/close quantity matched
                $keepTags = count($tags) ? '<' . implode('><', $tags) . '>' : '';
                $str = strip_tags($str, $keepTags);
            } else {
                foreach($unclosed as $tag) {
                    $str .= "</$tag>";
                }
            }
        }

        return $str;
    }

    /**
     * Collapse string to plain text that all exists on a single long line without destroying words/punctuation.
     *
     * @param string $str String to collapse
     * @param array $options
     *  - `stripTags` (bool): Strip markup tags? (default=true)
     *  - `keepTags` (array): Array of tag names to keep, if stripTags==true. (default=[])
     *  - `collapseLinesWith` (string): String to collapse newlines with. (default=' ')
     *  - `linksToUrls` (bool): Convert links to "(url)" rather than removing entirely? (default=false) Since 3.0.132
     *  - `endBlocksWith` (string): Character or string to insert to identify paragraph/header separation (default='')
     *  - `convertEntities` (bool): Convert entity-encoded characters to text? (default=true)
     * @return mixed|string
     *
     */
    public function collapse($str, array $options = array()) {

        $defaults = array(
            'stripTags' => true,
            'keepTags' => array(),
            'collapseLinesWith' => ' ',
            'endBlocksWith' => '',
            'convertEntities' => true,
            'linksToUrls' => false,
        );

        $options = array_merge($defaults, $options);

        if($options['stripTags']) {
            $str = $this->markupToText($str, array(
                'underlineHeadlines' => false,
                'uppercaseHeadlines' => false,
                'convertEntities' => $options['convertEntities'],
                'linksToUrls' => $options['linksToUrls'],
                'keepTags' => $options['keepTags'],
            ));
            if(!strlen($str)) return $str;
        }

        // character that we collapse lines with
        $r = $options['collapseLinesWith'];

        // convert any tabs to space
        if(strpos($str, "\t") !== false) {
            $str = str_replace("\t", " ", $str);
        }

        // convert CRs to LFs
        if(strpos($str, "\r") !== false) {
            $str = str_replace(array("\r\n", "\r"), "\n", $str);
        }

        // collapse whitespace that appears before or after newlines
        while(strpos($str, " \n") !== false) $str = str_replace(" \n", "\n", $str);
        while(strpos($str, "\n ") !== false) $str = str_replace("\n ", "\n", $str);

        // convert redundant LFs to no more than double LFs
        while(strpos($str, "\n\n\n") !== false) {
            $str = str_replace("\n\n\n", "\n\n", $str);
        }

        // add character to indicate blocks, when asked for
        if(!empty($options['endBlocksWith'])) {
            $str = str_replace("\n\n", "$options[endBlocksWith]\n\n", $str);
        }

        // replace all types of newlines
        $str = str_replace(array("\r\n", "\r", "\n\n", "\n"), $r, $str);

        // while there are consecutives of our collapse string, reduce them to one
        while(strpos($str, "$r$r") !== false) {
            $str = str_replace("$r$r", $r, $str);
        }

        if($r !== $defaults['collapseLinesWith']) {
            // replacement of whitespace with something other than another single whitespace
            // so collapse consecutive spaces to one space, since this would not be already done
            while(strpos($str, "  ") !== false) {
                $str = str_replace("  ", " ", $str);
            }
            // use space rather than replacement char when left side already ends with punctuation
            foreach($this->getPunctuationChars() as $c) {
                if(strpos($str, "$c$r")) $str = str_replace("$c$r", "$c ", $str);
            }
        }

        return trim($str);
    }

    /**
     * Truncate string to given maximum length without breaking words
     * 
     * This method can truncate between words, sentences, punctuation or blocks (like paragraphs). 
     * See the `type` option for details on how it should truncate. By default it truncates between
     * words. Description of types:
     * 
     * - word: truncate to closest word.
     * - punctuation: truncate to closest punctuation within sentence. 
     * - sentence: truncate to closest sentence.
     * - block: truncate to closest block of text (like a paragraph or headline). 
     * 
     * Note that if your specified `type` is something other than “word”, and it cannot be matched 
     * within the maxLength, then it will attempt a different type. For instance, if you specify
     * “sentence” as the type, and it cannot match a sentence, it will try to match to “punctuation”
     * instead. If it cannot match that, then it will attempt “word”. 
     * 
     * HTML will be stripped from returned string. If you want to keep some tags use the `keepTags` or `keepFormatTags`
     * options to specify what tags are allowed to remain. The `keepFormatTags` option that, when true, will make it
     * retain all HTML inline text formatting tags. 
     * 
     * ~~~~~~~
     * // Truncate string to closest word within 150 characters
     * $s = $sanitizer->truncate($str, 150); 
     * 
     * // Truncate string to closest sentence within 300 characters
     * $s = $sanitizer->truncate($str, 300, 'sentence'); 
     * 
     * // Truncate with options
     * $s = $sanitizer->truncate($str, [
     *   'type' => 'punctuation',
     *   'maxLength' => 300,
     *   'visible' => true,
     *   'more' => '…'
     * ]);
     * ~~~~~~~
     *
     * @param string $str String to truncate
     * @param int|array $maxLength Maximum length of returned string, or specify $options array here.
     * @param array|string $options Options array, or specify `type` option (string).
     *  - `type` (string): Preferred truncation type of word, punctuation, sentence, or block. (default='word')
     *       This is a “preferred type”, not an absolute one, because it will adjust to match what it can within your maxLength.
     *  - `maxLength` (int): Max characters for truncation, used only if $options array substituted for $maxLength argument.
     *  - `maximize` (bool): Include as much as possible within specified type and max-length? (default=true)
     *       If you specify false for the maximize option, it will truncate to first word, puncutation, sentence or block.
     *  - `visible` (bool): When true, invisible text (markup, entities, etc.) does not count towards string length. (default=false)
     *  - `trim` (string): Characters to trim from returned string. (default=',;/ ')
     *  - `noTrim` (string): Never trim these from end of returned string. (default=')]>}”»')
     *  - `more` (string): Append this to truncated strings that do not end with sentence punctuation. (default='…')
     *  - `keepTags` (array): HTML tags that should be kept in returned string. (default=[])
     *  - `keepFormatTags` (bool): Keep HTML text-formatting tags? Simpler alternative to keepTags option. (default=false)
     *  - `collapseLinesWith` (string): String to collapse lines with where the first is not punctuated. (default=' … ')
     *  - `convertEntities` (bool): Convert HTML entities to non-entity characters? (default=false)
     *  - `noEndSentence` (string): Strings that sentence may not end with, space-separated values (default='Mr. Mrs. …')
     * @return string
     *
     */
    function truncate($str, $maxLength, $options = array()) {
        
        if(!strlen($str)) return '';

        $ent = __(true, 'entityEncode', false);

        $defaults = array(
            'type' => 'word', // word, punctuation, sentence, or block
            'maximize' => true, // include as much as possible within the type and maxLength (false=include as little as possible)
            'visible' => false, // when true, invisible text (markup, entities, etc.) does not count towards string length. (default=false)
            'trim' => $this->_(',;/') . ' ', // Trim these characters from the end of the returned string
            'noTrim' => $this->_(')]>}”»'), // Never trim these characters from end of returned string
            'more' => '…', // Append to truncated strings that do not end with sentence punctuation
            'stripTags' => true, // strip HTML tags? (currently required, see keepTags to keep some)
            'keepTags' => array(), // if strip HTML tags is true, optional array of tag names you want to keep
            'keepFormatTags' => false, // alternative to keepTags: keep just inline text format tags like strong, em, etc. 
            'collapseWhitespace' => true, // collapsed whitespace (currently required)
            'collapseLinesWith' => ' ' . $this->_('…') . ' ', // String placed between joined lines (like from paragraphs)
            'convertEntities' => false, // convert entity encoded characters to non-entity equivalents? (default=false)
            'noEndSentence' => $this->_('Mr. Mrs. Ms. Dr. Hon. PhD. i.e. e.g.'), // When in sentence type, words that do not end the sentence (space-separated)
        );

        if($ent) __(true, 'entityEncode', $ent);
        
        if(is_string($options) && ctype_alpha($options)) {
            $defaults['type'] = $options;
            $options = array();
        }

        if(is_array($maxLength)) {
            $options = $maxLength;
            if(!isset($options['maxLength'])) $options['maxLength'] = 0;
            $maxLength = $options['maxLength'];
        } else if(is_string($maxLength) && ctype_alpha($maxLength)) {
            $options['type'] = $maxLength;
            $maxLength = isset($options['maxLength']) ? $options['maxLength'] : $this->strlen($str);
        }

        if(!$maxLength) $maxLength = 255;
        $options = array_merge($defaults, $options);
        $type = $options['type'];
        $str = trim($str);
        $blockEndChar = '¶';
        $tests = array();
        $punctuationChars = $this->getPunctuationChars();
        $endSentenceChars = $this->getPunctuationChars(true);
        $endSentenceChars[] = ':';

        if($options['keepFormatTags']) {
            $options['keepTags'] = array_merge($options['keepTags'], array(
                'abbr','acronym','b','big','cite','code','em','i','kbd', 'q','samp','small','span','strong','sub','sup','time','var',
            ));
        }

        if($type === 'block') {
            if($this->strpos($str, $blockEndChar) !== false) $str = str_replace($blockEndChar, ' ', $str);
            $options['endBlocksWith'] = $blockEndChar;
        }

        // collapse whitespace and strip tags
        $str = $this->collapse($str, $options);
        
        if(trim($options['collapseLinesWith']) && $this->strpos($str, $options['collapseLinesWith'])) {
            // if lines are collapsed with something other than whitespace, avoid using that string
            // when the line already ends with sentence punctuation
            foreach($endSentenceChars as $c) {
                $str = str_replace("$c$options[collapseLinesWith]", "$c ", $str); 
            }
        }

        // if anything above reduced the length of the string enough, return it now
        if($this->strlen($str) <= $maxLength) return $str;

        // get string at maximum possible length
        if($options['visible']) {
            // adjust for only visible length
            $_str = $str;
            $str = $this->substr($str, 0, $maxLength);
            $len = $this->getVisibleLength($str);
            if($len < $maxLength) {
                $maxLength += ($maxLength - $len);
                $str = $this->substr($_str, 0, $maxLength);
            }
            unset($_str);
        } else {
            $str = $this->substr($str, 0, $maxLength);
        }

        // match to closest blocks, like paragraph(s)
        if($type === 'block') {
            $pos = $options['maximize'] ? $this->strrpos($str, $blockEndChar) : $this->strpos($str, $blockEndChar);
            if($pos === false) {
                $type = 'sentence';
            } else {
                $tests[] = $pos;
                $options['trim'] .= $blockEndChar;
            }
        }

        // find sentences closest to end    
        if($type === 'sentence') {
            $this->truncateSentenceTests($str, $tests, $endSentenceChars, $options);
            if(!count($tests)) $type = 'punctuation';
        }

        // find punctuation closes to end of string
        if($type === 'punctuation') {
            foreach($punctuationChars as $find) {
                $pos = $options['maximize'] ? $this->strrpos($str, $find) : $this->strpos($str, $find);
                if($pos) $tests[] = $pos;
            }
            if(!count($tests)) $type = 'word';
        }

        // find whitespace and last word closest to end of string
        if($type === 'word' || !count($tests)) {
            $pos = $options['maximize'] ? $this->strrpos($str, ' ') : $this->strpos($str, ' ');
            if($pos) $tests[] = $pos;
        }

        if(count($tests)) {
            // we found somewhere to truncate, so truncate at the longest one possible
            if($options['maximize']) {
                sort($tests);
            } else {
                rsort($tests);
            }

            // process our tests
            do {
                $pos = array_pop($tests);
                $result = trim($this->substr($str, 0, $pos + 1));
                $lastChar = $this->substr($result, -1);
                $result = $this->rtrim($result, $options['trim']);

                if($type === 'sentence' || $type === 'block') {
                    // good to go with result as is
                } else if(in_array($lastChar, $endSentenceChars)) {
                    // good, end with sentence ending punctuation
                } else if(in_array($lastChar, $punctuationChars)) {
                    $trims = ' ';
                    foreach($punctuationChars as $c) {
                        if($this->strpos($options['noTrim'], $c) !== false) continue;
                        if(in_array($c, $endSentenceChars)) continue;
                        $trims .= $c;
                    }
                    $result = $this->rtrim($result, $trims) . $options['more'];
                } else {
                    $result .= $options['more'];
                }

            } while(!strlen($result) && count($tests));

            // make sure we didn't break any HTML tags as a result of truncation
            if(strlen($result) && count($options['keepTags']) && strpos($result, '<') !== false) {
                $result = $this->fixUnclosedTags($result);
            }
        } else {
            // if we didn't find any place to truncate, just return exact truncated string
            $result = $this->trim($str, $options['trim']) . $options['more'];
        }
        
        if(strlen($options['more'])) {
            // remove any duplicated more strings
            $more = $options['more'];
            while(strpos($result, "$more$more") !== false) {
                $result = str_replace("$more$more", "$more", $result); 
            }
        }
        
        return $result;
    }

    /**
     * Helper to truncate() method, generate tests/positions for where sentences end
     * 
     * @param string $str
     * @param array $tests Tests to append found positions to
     * @param array $endSentenceChars
     * @param array $options Options provided to truncate method
     * 
     */
    protected function truncateSentenceTests($str, array &$tests, array $endSentenceChars, array $options) {
        
        $chars = $endSentenceChars;
        $thisStr = $str;
        $nextStr = '';
        $nextOffset = 0;
        $offset = 0; // offset used for maximize==false mode only
        $n = 0;
        
        // regex matches specified words, plus digits or single letters followed by period
        $noEndRegex = '!\b(' . str_replace(' ', '|', preg_quote($options['noEndSentence'])) . '|\d+\.|\w\.)$!';
        
        do {
            
            if($nextStr) {
                $offset = $nextOffset;
                $thisStr = $nextStr;
                $nextStr = '';
                $chars = array('.');
            }
            
            foreach($chars as $find) {
                
                $pos = $options['maximize'] ? $this->strrpos($thisStr, "$find ") : $this->strpos($thisStr, "$find ", $offset);
                
                if(!$pos) continue;
                
                if($find === '.') {
                    $testStr = $this->substr($thisStr, 0, $pos + 1);
                    if(preg_match($noEndRegex, $testStr, $matches)) {
                        // ends with a disallowed word, next time try to match with a shorter string
                        if($options['maximize']) {
                            $nextStr = $this->substr($testStr, 0, $this->strlen($testStr) - $this->strlen($matches[1]) - 1);
                        } else {
                            $nextOffset = $this->strlen($testStr);
                        }
                        continue;
                    }
                }
                
                $tests[] = $pos;
            }
            
        } while(strlen($nextStr) && ++$n < 3);
    }

    /**
     * Return visible length of string, which is length not counting markup or entities
     * 
     * @param string $str
     * @return int
     * 
     */
    public function getVisibleLength($str) {
        if(strpos($str, '>')) {
            $str = strip_tags($str);
        }
        if(strpos($str, '&') !== false && strpos($str, ';')) {
            $str = html_entity_decode($str, ENT_QUOTES, 'UTF-8');
        }
        return $this->strlen($str);
    }

    /**
     * Get array of punctuation characters
     * 
     * @param bool $sentence Get only sentence-ending punctuation
     * @return array|string
     * 
     */
    public function getPunctuationChars($sentence = false) {
        $ent = __(true, 'entityEncode', false);
        if($sentence) {
            $s = $this->_('. ? !'); // Sentence ending punctuation characters (must be space-separated)
        } else {
            $s = $this->_(', : . ? ! “ ” „ " – -- ( ) [ ] { } « »'); // All punctuation characters (must be space-separated)
        }
        if($ent) __(true, 'entityEncode', $ent);
        return explode(' ', $s); 
    }

    /**
     * Get alternate words for given word 
     * 
     * This method does not do anything unless an implementation is provided by a module (or something else)
     * hooking the protected `WireTextTools::wordAlternates($word, $options)` method. Implementation should 
     * populate $event->return with any or all of the following (as available): 
     * 
     * - Word plural(s)
     * - Word singular(s)
     * - Word Lemmas
     * - Word Synonyms
     * - Anything else applicable to current $user->language
     * 
     * See the protected WireTextTools::wordAlternates() method for hook instructions and an example. 
     * 
     * @param string $word
     * @param array $options
     *  - `operator` (string): Operator being used, if applicable (default='')
     *  - `minLength` (int): Minimum word length to return in alternates (default=2)
     *  - `lowercase` (bool): Convert words to lowercase, if not already (default=false)
     * @return array
     * @since 3.0.162
     * @see WireTextTools::getWordStem()
     * 
     */
    public function getWordAlternates($word, array $options = array()) {
        
        if(!$this->hasHook('wordAlternates()')) return array();
        
        $defaults = array(
            'operator' => '', 
            'minLength' => 2, 
            'lowercase' => false, 
        );
        
        $options = array_merge($defaults, $options);
        $word = $this->trim($word);
        $words = array();
        $wordLow = $this->strtolower($word);
        
        if($options['lowercase']) $word = $wordLow;
        if(empty($word)) return array();
        
        $alternates = $this->wordAlternates($word, $options);
        if(!count($alternates)) return array();
        
        // if original word appears in return value, remove it
        $key = array_search($word, $alternates);
        if($key !== false) unset($alternates[$key]);
        
        // populate $words, removing any invalid or duplicate values
        foreach($alternates as $w) {
            if(!is_string($w)) continue;
            $w = $this->trim($w);
            $wLow = $this->strtolower($w);
            if($wLow === $wordLow) continue; // dup of original word
            if($options['lowercase']) $w = $wLow; // use lowercase
            if($this->strlen($w) < $options['minLength']) continue; // too short
            if(isset($words[$wLow])) continue; // already have it
            $words[$wLow] = $w;
        }
    
        return array_values($words);
    }
    
    /**
     * Hookable method to return alternate words for given word
     *
     * This hookable method is separate from the public getWordAlternates() method so that
     * we can provide predictable and already-populated $options to whatever is hooking this, as
     * as provide some additional QA with the return value from modules/hooks.
     *
     * It is fine if the return value contains duplicates, the original word, or too-short words,
     * as the calling getWordAlternates() takes care of those before returning words to user.
     * Basically, hooks can ignore the `$options` argument, unless they need to know the `operator`,
     * which may or may not be provided by the caller.
     *
     * In hook implementation, avoid deleting what’s already present in $event->return just in
     * case multiple hooks are adding words.
     *
     * ~~~~~
     * // Contrived example of how to implement
     * $wire->addHookAfter('WireTextTools::wordAlternates', function(HookEvent $event) {
     *   $word = $event->arguments(0); // string: word requested alternates for
     *   $words = $event->return; // array: existing return value
     *
     *   $cats = [ 'cat', 'cats', 'kitty', 'feline', 'felines' ];
     *   $dogs = [ 'dog', 'dogs', 'doggy', 'canine', 'canines' ];
     *
     *   if(in_array($word, $cats)) {
     *     $words = array_merge($words, $cats);
     *   } else if(in_array($word, $dogs)) {
     *     $words = array_merge($words, $dogs);
     *   }
     *
     *   $event->return = $words;
     * });
     *
     * // Test it out
     * $words = $sanitizer->getTextTools()->getWordAlternates('cat');
     * echo implode(', ', $words); // outputs: cats, kitty, kitten, feline, felines
     * ~~~~~
     *
     * #pw-hooker
     *
     * @param string $word
     * @param array $options
     *  - `operator` (string): Operator being used, if applicable (default='')
     * @return array
     * @since 3.0.162
     *
     */
    protected function ___wordAlternates($word, array $options) {
        if($word && $options) {} // ignore
        $alternates = array();
        return $alternates;
    }

    /**
     * Find and return all {placeholder} tags found in given string
     *
     * @param string $str String that might contain field {tags}
     * @param array $options
     *  - `has` (bool): Specify true to only return true or false if it has tags (default=false).
     *  - `tagOpen` (string): The required opening tag character(s), default is '{'
     *  - `tagClose` (string): The required closing tag character(s), default is '}'
     * @return array|bool
     * @since 3.0.126
     *
     */
    public function findPlaceholders($str, array $options = array()) {
        
        $defaults = array(
            'has' => false,
            'tagOpen' => '{',
            'tagClose' => '}', 
        );
        
        $options = array_merge($defaults, $options);
        $tags = array();
        $pos1 = strpos($str, $options['tagOpen']);
        
        if($pos1 === false) return $options['has'] ? false : $tags;
        
        if(strlen($options['tagClose'])) {
            $pos2 = strpos($str, $options['tagClose']);
            if($pos2 === false) return $options['has'] ? false : $tags;
        }

        $regex = '/' . preg_quote($options['tagOpen']) . '([-_.|a-zA-Z0-9]+)' . preg_quote($options['tagClose']) . '/';
        if($options['has']) return (bool) preg_match($regex, $str);
        if(!preg_match_all($regex, $str, $matches)) return $tags;
        
        foreach($matches[0] as $key => $tag) {
            $name = $matches[1][$key];
            $tags[$name] = $tag;
        }
            
        return $tags;
    }

    /**
     * Does the string have any {placeholder} tags in it?
     *
     * @param string $str
     * @param array $options
     *  - `tagOpen` (string): The required opening tag character(s), default is '{'
     *  - `tagClose` (string): The required closing tag character(s), default is '}'
     * @return bool
     * @since 3.0.126
     *
     */
    public function hasPlaceholders($str, array $options = array()) {
        $options['has'] = true;
        return $this->findPlaceholders($str, $options);
    }
    
    /**
     * Given a string ($str) and values ($vars), populate placeholder “{tags}” in the string with the values
     *
     * - The `$vars` should be an associative array of `[ 'tag' => 'value' ]`.
     * - The `$vars` may also be an object, in which case values will be pulled as properties of the object.
     *
     * By default, tags are specified in the format: {first_name} where first_name is the name of the
     * variable to pull from $vars, `{` is the opening tag character, and `}` is the closing tag char.
     *
     * The tag parser can also handle subfields and OR tags, if `$vars` is an object that supports that.
     * For instance `{products.title}` is a subfield, and `{first_name|title|name}` is an OR tag.
     *
     * ~~~~~
     * $vars = [ 'foo' => 'FOO!', 'bar' => 'BAR!' ];
     * $str = 'This is a test: {foo}, and this is another test: {bar}';
     * echo $sanitizer->getTextTools()->populatePlaceholders($str, $vars);
     * // outputs: This is a test: FOO!, and this is another test: BAR!
     * ~~~~~
     *
     * @param string $str The string to operate on (where the {tags} might be found)
     * @param WireData|object|array $vars Object or associative array to pull replacement values from.
     * @param array $options Array of optional changes to default behavior, including:
     *  - `tagOpen` (string): The required opening tag character(s), default is '{'
     *  - `tagClose` (string): The optional closing tag character(s), default is '}'
     *  - `recursive` (bool): If replacement value contains tags, populate those too? (default=false)
     *  - `removeNullTags` (bool): If a tag resolves to a NULL, remove it? If false, tag will remain. (default=true)
     *  - `entityEncode` (bool): Entity encode the values pulled from $vars? (default=false)
     *  - `entityDecode` (bool): Entity decode the values pulled from $vars? (default=false)
     *  - `allowMarkup` (bool): Allow markup to appear in populated variables? (default=true)
     * @return string String with tags populated.
     * @since 3.0.126 Use wirePopulateStringTags() function for older versions
     *
     */
    public function populatePlaceholders($str, $vars, array $options = array()) {
        
        $defaults = array(
            'tagOpen' => '{', // opening tag (required)
            'tagClose' => '}', // closing tag (optional)
            'recursive' => false, // if replacement value contains tags, populate those too?
            'removeNullTags' => true, // if a tag value resolves to a NULL, remove it? If false, tag will be left in tact.
            'entityEncode' => false, // entity encode values pulled from $vars?
            'entityDecode' => false, // entity decode values pulled from $vars?
            'allowMarkup' => true, // allow markup to appear in populated variables?
        );

        $options = array_merge($defaults, $options);
        $optionsNoRecursive = $options['recursive'] ? array_merge($options, array('recursive' => false)) : $options;
        $replacements = array();
        $tags = $this->findPlaceholders($str, $options);

        // create a list of replacements by finding replacement values in $vars
        foreach($tags as $fieldName => $tag) {
            
            if(isset($replacements[$tag])) continue; // if already found, do not do it again
            $fieldValue = null;

            if(is_object($vars)) {
                if($vars instanceof Page) {
                    $fieldValue = $options['allowMarkup'] ? $vars->getMarkup($fieldName) : $vars->getText($fieldName);
                } else if($vars instanceof WireData) {
                    $fieldValue = $vars->get($fieldName);
                } else {
                    $fieldValue = $vars->$fieldName;
                }
            } else if(is_array($vars)) {
                $fieldValue = isset($vars[$fieldName]) ? $vars[$fieldName] : null;
            }
            
            // if value resolves to null and we are not removing null tags, then do not add to replacements
            if($fieldValue === null && !$options['removeNullTags']) continue;
            
            $fieldValue = (string) $fieldValue;

            if(!$options['allowMarkup'] && strpos($fieldValue, '<') !== false) $fieldValue = strip_tags($fieldValue);
            if($options['entityEncode']) $fieldValue = htmlentities($fieldValue, ENT_QUOTES, 'UTF-8', false);
            if($options['entityDecode']) $fieldValue = html_entity_decode($fieldValue, ENT_QUOTES, 'UTF-8');
            
            if($options['recursive'] && strpos($fieldValue, $options['tagOpen']) !== false) {
                $fieldValue = $this->populatePlaceholders($fieldValue, $vars, $optionsNoRecursive);
            }
        
            $replacements[$tag] = $fieldValue;
        }

        // replace the tags 
        if(count($tags)) {
            $str = str_replace(array_keys($replacements), array_values($replacements), $str);
        }

        return $str; 
    }
    
    /**
     * Populate placeholders in string with sanitizers applied to populated values
     * 
     * These placeholders accept one or more sanitizer names as part `{placeholder}` in the format `{placeholder:sanitizers}`,
     * where `placeholder` is the name of a variable accessible from `$data` argument and `sanitizers` is the name of a 
     * sanitizer method or a CSV string of sanitizer methods. Placeholders with any whitespace are ignored.
     * 
     * #pw-internal 
     * 
     * ~~~~~
     * $tools = $sanitizer->getTextTools();
     * $data = [ 'name' => 'John <Bob> Smith', 'age' => 46.5 ];
     * 
     * $str = "My name is {name:camelCase}, my age is {age:int}";
     * echo $tools->placeholderSanitizers($str, $data); // outputs: My name is johnBobSmith, my age is 46
     * 
     * $str = "My name is {name:removeWhitespace,entities}, my age is {age:float}";
     * echo $tools->placeholderSanitizers($str, $data); // outputs: My name is John&lt;Bob&gt;Smith, my age is 46.5
     * 
     * $str = "My name is {name:text,word}, my age is {age:digits}";
     * echo $tools->placeholderSanitizers($str, $data); // outputs: My name is John, my age is 465
     * ~~~~~
     * 
     * @param string $str
     * @param array|WireData|WireInputData 
     * @param array $options
     * @return string
     * @throws WireException
     * @since 3.0.178
     * @todo currently 'protected' for later use
     *
     */
    protected function placeholderSanitizers($str, $data, array $options = array()) {
        

        $defaults = array(
            'tagOpen' => '{', 
            'tagClose' => '}', 
            'sanitizersBefore' => array('string'), // sanitizers to apply before requested ones
            'sanitizersAfter' => array(), // sanitizers to apply after requested ones
            'sanitizersDefault' => array('text'), // defaults if only {var} is presented without {var:sanitizer}
        );
        

        $options = array_merge($defaults, $options);
        $sanitizer = $this->wire()->sanitizer;
        $dataIsArray = is_array($data);
        $replacements = array();
        $parts = array();
        
        if(strpos($str, $options['tagOpen']) === false || !strpos($str, $options['tagClose'])) return $str;
        
        if(!is_array($data) && !$data instanceof WireData && !$data instanceof WireInputData) {
            throw new WireException('$data argument must be associative array, WireData or WireInputData');
        }
    
        list($tagOpen, $tagClose) = array(preg_quote($options['tagOpen']), preg_quote($options['tagClose'])); 
        
        $regex = '/OPEN([-_.a-z0-9]+)(:[_,a-z0-9]+CLOSE|CLOSE)/i';
        $regex = str_replace(array('OPEN', 'CLOSE'), array($tagOpen, $tagClose), $regex); 
        if(!preg_match_all($regex, $str, $matches)) return $str;

        foreach($matches[0] as $key => $placeholder) {
            $varName = $matches[1][$key];
            $sanitizers = trim($matches[2][$key], ':}');
            $sanitizers = strlen($sanitizers) ? explode(',', $sanitizers) : array();
            if(!count($sanitizers)) $sanitizers = $options['sanitizersDefault'];
            if($dataIsArray) {
                /** @var array $data */
                $value = isset($data[$varName]) ? $data[$varName] : null;
            } else {
                /** @var WireData|WireInputData $data */
                $value = $data->get($varName);
            }
            $n = 0;
            foreach(array($options['sanitizersBefore'], $sanitizers, $options['sanitizersAfter']) as $methods) {
                foreach($methods as $method) {
                    if(!$sanitizer->methodExists($method)) throw new WireException("Unknown sanitizer method: $method");
                    $value = $sanitizer->sanitize($value, $method);
                    $n++;
                }
            }
            if(!$n) $value = $placeholder;
            $replacements[] = array($placeholder, $value);
        }

        // piece it back together manually so values in $data cannot introduce more placeholders
        foreach($replacements as $item) {
            list($placeholder, $value) = $item;
            list($before, $after) = explode($placeholder, $str, 2);
            $parts[] = $before . $value;
            $str = $after;
        }

        return implode('', $parts) . $str;
    }

    /**
     * Populate placeholders with optional sanitizers in a selector string
     * 
     * #pw-internal
     * 
     * @param string $selectorString
     * @param array|WireData|WireInputData
     * @param array $options
     * @return string
     * @throws WireException
     * @since 3.0.178
     * @todo currently 'protected' for later use
     * 
     */
    protected function placeholderSelector($selectorString, $data, array $options = array()) {
        if(!isset($options['sanitizersBefore'])) $options['sanitizersBefore'] = array();
        if(!isset($options['sanitizersAfter'])) $options['sanitizersAfter'] = array();
        $options['sanitizersBefore'][] = 'text';
        $options['sanitizersAfter'][] = 'selectorValue';
        return $this->placeholderSanitizers($selectorString, $data, $options);
    }

    /**
     * Given two arrays, return array of the changes with 'ins' and 'del' keys
     * 
     * Based upon Paul Butler’s Simple Diff Algorithm v0.1 © 2007 (zlib/libpng) https://paulbutler.org
     * 
     * @param array $oldArray
     * @param array $newArray
     * @return array
     * @since 3.0.144
     * 
     */
    protected function diffArray(array $oldArray, array $newArray) {
        
        $matrix = array();
        $maxLen = 0;
        $oldMax = 0; 
        $newMax = 0;
        
        foreach($oldArray as $oldKey => $oldValue){
            
            $newKeys = array_keys($newArray, $oldValue);
            
            foreach($newKeys as $newKey) {
                $len = 1;
                if(isset($matrix[$oldKey - 1][$newKey - 1])) {
                    $len = $matrix[$oldKey - 1][$newKey - 1] + 1;
                }
                $matrix[$oldKey][$newKey] = $len;

                if($len > $maxLen) {
                    $maxLen = $len;
                    $oldMax = $oldKey + 1 - $maxLen;
                    $newMax = $newKey + 1 - $maxLen;
                }
            }
        }
        
        if($maxLen == 0) {
            $result = array(
                array('del' => $oldArray, 'ins' => $newArray)
            );
            
        } else {
            $result = array_merge(
                $this->diffArray(
                    array_slice($oldArray, 0, $oldMax), 
                    array_slice($newArray, 0, $newMax)
                ),
                array_slice($newArray, $newMax, $maxLen),
                $this->diffArray(
                    array_slice($oldArray, $oldMax + $maxLen), 
                    array_slice($newArray, $newMax + $maxLen)
                )
            );
        }
        
        return $result;
    }

    /**
     * Given two strings ($old and $new) return a diff string in HTML markup
     * 
     * @param string $old Old string value
     * @param string $new New string value
     * @param array $options Options to modify behavior:
     *  - `ins` (string) Markup to use for diff insertions (default: `<ins>{out}</ins>`)
     *  - `del` (string) Markup to use for diff deletions (default: `<del>{out}</del>`)
     *  - `entityEncode` (bool): Entity encode values, other than added ins/del tags? (default=true)
     *  - `split` (string): Regex used to split strings for parts to diff (default=`\s+`)
     * @return string
     * @since 3.0.144
     * 
     */
    public function diffMarkup($old, $new, array $options = array()) {
        
        $defaults = array(
            'ins' => "<ins>{out}</ins>",
            'del' => "<del>{out}</del>", 
            'entityEncode' => true,
            'split' => '\s+', 
        );
        
        /** @var Sanitizer $sanitizer */
        $sanitizer = $this->wire('sanitizer');
        list($old, $new) = array("$old", "$new"); // enforce as string
        $options = array_merge($defaults, $options);
        $oldArray = preg_split("!($options[split])!", $old, 0, PREG_SPLIT_DELIM_CAPTURE);
        $newArray = preg_split("!($options[split])!", $new, 0, PREG_SPLIT_DELIM_CAPTURE);
        $diffArray = $this->diffArray($oldArray, $newArray);
        list(,$delClose) = explode('{out}', $options['del'], 2);
        list($insOpen,) = explode('{out}', $options['ins'], 2); 
        $out = '';
        
        foreach($diffArray as $diff) {
            if(is_array($diff)) {
                foreach(array('del', 'ins') as $key) {
                    if(empty($diff[$key])) continue;
                    $diffStr = implode('', $diff[$key]);
                    if($options['entityEncode']) $diffStr = $sanitizer->entities1($diffStr);
                    $out .= str_replace('{out}', $diffStr, $options[$key]);
                }
            } else {
                $out .= ($options['entityEncode'] ? $sanitizer->entities1($diff) : $diff);
            }
        }

        if(strpos($out, "$delClose$insOpen")) {
            // put a space between '</del><ins>' so that it is '</del> <ins>'
            $out = str_replace("$delClose$insOpen", "$delClose $insOpen", $out);
        }
        
        return $out;
    }

    /**
     * Find escaped characters in $str, replace them with a placeholder, and return the placeholders 
     * 
     * Usage
     * ~~~~~
     * // 1. Escape certain chars in a string that you want to survive some processing:
     * $str = 'Hello \*world\* foo \"bar\" baz'; 
     * 
     * // 2. Use this method to find escape chars and replace them temporarily:
     * $a = $sanitizer->getTextTools()->findReplaceEscapeChars($str, [ '*', '"' ]); 
     * 
     * // 3. Process string with anything that you want NOT to see chars that were escaped:
     * $str = some_function_that_processes_the_string($str);
     * 
     * // 4. Do this to restore the escaped chars (restored without backslashes by default):
     * $str = str_replace(array_keys($a), array_values($a), $str); 
     * ~~~~~
     * 
     * @param string &$str String to find escape chars in, it will be modified directly (passed by reference)
     * @param array $escapeChars Array of chars you want to escape i.e. [ '*', '[', ']', '(', ')', '`', '_', '\\', '"' ]
     * @param array $options Options to modify behavior: 
     *  - `escapePrefix` (string): Character used to escape another character (default is backslash).
     *  - `restoreEscape` (bool): Should returned array also include the escape prefix, so escapes are restored? (default=false)
     *  - `gluePrefix` (string): Prefix for placeholders we substitute for escaped characters (default='{ESC') 
     *  - `glueSuffix` (string): Suffix for placeholders we substitute for escaped characters (default='}')
     *  - `unescapeUnknown` (bool): If we come across escaped char not in your $escapeChars list, unescape it? (default=false)
     *  - `removeUnknown` (bool): If we come across escaped char not in your $escapeChars list, remove the escape and char? (default=false)
     * @return array Returns assoc array where keys are placeholders substituted in $str and values are escaped characters. 
     * @since 3.0.162
     * 
     */
    public function findReplaceEscapeChars(&$str, array $escapeChars, array $options = array()) {

        $defaults = array(
            'escapePrefix' => '\\',
            'restoreEscape' => false,  // when restoring, also restore escape prefix?
            'gluePrefix' => '{ESC',
            'glueSuffix' => '}',
            'unescapeUnknown' => false,
            'removeUnknown' => false,
        );

        $options = array_merge($defaults, $options);
        $escapePrefix = $options['escapePrefix'];
        if(strpos($str, $escapePrefix) === false) return array();
        $escapes = array();
        $glueSuffix = $options['glueSuffix'];
        $parts = explode($escapePrefix, $str);
        $n = 0;

        do {
            $gluePrefix = $options['gluePrefix'] . $n;
        } while($this->strpos($str, $gluePrefix) !== false && ++$n);

        $str = array_shift($parts);

        foreach($parts as $key => $part) {

            $len = $this->strlen($part);
            $char = $len > 0 ? $this->substr($part, 0, 1) : ''; // char being escaped
            $part = $len > 1 ? $this->substr($part, 1) : ''; // everything after it
            $charKey = array_search($char, $escapeChars); // find placeholder (glue)

            if($charKey !== false) {
                // replace escaped char with placeholder ($glue)
                $glue = $gluePrefix . $charKey . $glueSuffix;
                $escapes[$glue] = $options['restoreEscape'] ? $escapePrefix . $char : $char;
                $str .= $glue . $part;
            } else if($options['unescapeUnknown']) {
                // unescape unknown escape char
                $str .= $char . $part;
            } else if($options['removeUnknown']) {
                // remove unknown escape char
                $str .= $part;
            } else {
                // some other backslash that’s allowed, restore back as it was
                $str .= $escapePrefix . $char . $part;
            }
        }

        return $escapes;
    }
    
    /***********************************************************************************************************
     * MULTIBYTE PHP STRING FUNCTIONS THAT FALLBACK WHEN MBSTRING NOT AVAILABLE
     * 
     * These duplicate the equivalent PHP string methods and use exactly the same arguments
     * and exhibit exactly the same behavior. The only difference is that these methods using
     * the multibyte string versions when they are available, and fallback to the regular PHP 
     * string methods when not. Use these functions only when that behavior is okay. 
     * 
     */

    /**
     * Get part of a string
     * 
     * #pw-group-PHP-function-alternates
     * 
     * @param string $str
     * @param int $start
     * @param int|null $length Max chars to use from str. If omitted or NULL, extract all characters to the end of the string.
     * @return string
     * @see https://www.php.net/manual/en/function.substr.php
     * 
     */
    public function substr($str, $start, $length = null) {
        return $this->mb ? mb_substr($str, $start, $length) : substr($str, $start, $length);
    }

    /**
     * Find position of first occurrence of string in a string
     * 
     * #pw-group-PHP-function-alternates
     * 
     * @param string $haystack
     * @param string $needle
     * @param int $offset
     * @return bool|false|int
     * @see https://www.php.net/manual/en/function.strpos.php
     * 
     */
    public function strpos($haystack, $needle, $offset = 0) {
        return $this->mb ? mb_strpos($haystack, $needle, $offset) : strpos($haystack, $needle, $offset);
    }

    /**
     * Find the position of the first occurrence of a case-insensitive substring in a string
     * 
     * #pw-group-PHP-function-alternates
     *
     * @param string $haystack
     * @param string $needle
     * @param int $offset
     * @return bool|false|int
     * @see https://www.php.net/manual/en/function.stripos.php
     *
     */
    public function stripos($haystack, $needle, $offset = 0) {
        return $this->mb ? mb_stripos($haystack, $needle, $offset) : stripos($haystack, $needle, $offset);
    }

    /**
     * Find the position of the last occurrence of a substring in a string
     * 
     * #pw-group-PHP-function-alternates
     *
     * @param string $haystack
     * @param string $needle
     * @param int $offset
     * @return bool|false|int
     * @see https://www.php.net/manual/en/function.strrpos.php
     *
     */
    public function strrpos($haystack, $needle, $offset = 0) {
        return $this->mb ? mb_strrpos($haystack, $needle, $offset) : strrpos($haystack, $needle, $offset);
    }

    /**
     * Find the position of the last occurrence of a case-insensitive substring in a string
     * 
     * #pw-group-PHP-function-alternates
     *
     * @param string $haystack
     * @param string $needle
     * @param int $offset
     * @return bool|false|int
     * @see https://www.php.net/manual/en/function.strripos.php
     *
     */
    public function strripos($haystack, $needle, $offset = 0) {
        return $this->mb ? mb_strripos($haystack, $needle, $offset) : strripos($haystack, $needle, $offset);
    }

    /**
     * Get string length
     * 
     * #pw-group-PHP-function-alternates
     * 
     * @param string $str
     * @return int
     * @see https://www.php.net/manual/en/function.strlen.php
     * 
     */
    public function strlen($str) {
        return $this->mb ? mb_strlen($str) : strlen($str);
    }

    /**
     * Make a string lowercase
     * 
     * #pw-group-PHP-function-alternates
     * 
     * @param string $str
     * @return string
     * @see https://www.php.net/manual/en/function.strtolower.php
     * 
     */
    public function strtolower($str) {
        return $this->mb ? mb_strtolower($str) : strtolower($str);
    }

    /**
     * Make a string uppercase
     * 
     * #pw-group-PHP-function-alternates
     *
     * @param string $str
     * @return string
     * @see https://www.php.net/manual/en/function.strtoupper.php
     *
     */
    public function strtoupper($str) {
        return $this->mb ? mb_strtoupper($str) : strtoupper($str);
    }

    /**
     * Count the number of substring occurrences
     * 
     * #pw-group-PHP-function-alternates
     * 
     * @param string $haystack
     * @param string $needle
     * @return int
     * @see https://www.php.net/manual/en/function.substr-count.php
     * 
     */
    public function substrCount($haystack, $needle) {
        return $this->mb ? mb_substr_count($haystack, $needle) : substr_count($haystack, $needle); 
    }

    /**
     * Find the first occurrence of a string
     * 
     * #pw-group-PHP-function-alternates
     *
     * @param string $haystack
     * @param string $needle
     * @param bool $beforeNeedle Return part of haystack before first occurrence of the needle? (default=false)
     * @return false|string
     * @see https://www.php.net/manual/en/function.strstr.php
     *
     */
    public function strstr($haystack, $needle, $beforeNeedle = false) {
        return $this->mb ? mb_strstr($haystack, $needle, $beforeNeedle) : strstr($haystack, $needle, $beforeNeedle);
    }

    /**
     * Find the first occurrence of a string (case insensitive)
     * 
     * #pw-group-PHP-function-alternates
     * 
     * @param string $haystack
     * @param string $needle
     * @param bool $beforeNeedle Return part of haystack before first occurrence of the needle? (default=false)
     * @return false|string
     * @see https://www.php.net/manual/en/function.stristr.php
     * 
     */
    public function stristr($haystack, $needle, $beforeNeedle = false) {
        return $this->mb ? mb_stristr($haystack, $needle, $beforeNeedle) : stristr($haystack, $needle, $beforeNeedle); 
    }


    /**
     * Find the last occurrence of a character in a string
     * 
     * #pw-group-PHP-function-alternates
     * 
     * @param string $haystack
     * @param string $needle Only first given character used
     * @return false|string
     * @see https://www.php.net/manual/en/function.strrchr.php
     * 
     */
    public function strrchr($haystack, $needle) {
        return $this->mb ? mb_strrchr($haystack, $needle) : strrchr($haystack, $needle); 
    }

    /**
     * Strip whitespace (or other characters) from the beginning and end of a string
     * 
     * #pw-group-PHP-function-alternates
     * 
     * @param string $str
     * @param string $chars Omit for default
     * @return string
     * 
     */
    public function trim($str, $chars = '') {
        if(!$this->mb) return $chars === '' ? trim($str) : trim($str, $chars);
        return $this->wire()->sanitizer->trim($str, $chars);
    }

    /**
     * Strip whitespace (or other characters) from the beginning of string only (aka left trim)
     *
     * #pw-group-PHP-function-alternates
     *
     * @param string $str
     * @param string $chars Omit for default
     * @return string
     * @since 3.0.168
     *
     */
    public function ltrim($str, $chars = '') {
        if(!$this->mb) return $chars === '' ? ltrim($str) : ltrim($str, $chars);
        return $this->wire()->sanitizer->trim($str, $chars, 'ltrim');
    }
    
    /**
     * Strip whitespace (or other characters) from the end of string only (aka right trim)
     *
     * #pw-group-PHP-function-alternates
     *
     * @param string $str
     * @param string $chars Omit for default
     * @return string
     * @since 3.0.168
     *
     */
    public function rtrim($str, $chars = '') {
        if(!$this->mb) return $chars === '' ? rtrim($str) : rtrim($str, $chars);
        return $this->wire()->sanitizer->trim($str, $chars, 'rtrim');
    }
    

}