1
\$\begingroup\$

I have a function in JavaScript that converts a string value to title case using APA style:

function titleCase(str, options) {
  const stopwords = 'a an and at but by for in nor of on or so the to up yet'
  const defaults = stopwords.split(' ')

  const opts = options || {}

  if (!str) return ''

  const stop = opts.stopwords || defaults
  const keep = opts.keepSpaces
  const splitter = /(\s+|[-‑–—])/

  return str
    .split(splitter)
    .map((word, index, all) => {
      if (word.match(/\s+/)) return keep ? word : ' '
      if (word.match(splitter)) return word

      if (
        index !== 0 &&
        index !== all.length - 1 &&
        stop.includes(word.toLowerCase())
      ) {
        return word.toLowerCase()
      }

      return capitalize(word)
    })
    .join('')
}

function capitalize(str) {
  return str.charAt(0).toUpperCase() + str.slice(1)
}

Credits of above JS functions

I converted the above to PHP:

function APATitle($title, $keepSpaces = false, $stopWords = array())
{
    if (!is_string($title)) {
        return false;
    }

    if (!is_array($stopWords) || count($stopWords) < 1) {
        $stopWords = array('a', 'an', 'and', 'at', 'but', 'by', 'for', 'in', 'nor', 'of', 'on', 'or', 'so', 'the', 'to', 'up', 'yet'); //Must all be lowercase!
    }

    $regexWordSplit = '/(\s+|[-‑–—])/';
    $words = preg_split($regexWordSplit, $title, -1, PREG_SPLIT_DELIM_CAPTURE);

    foreach ($words as $i => $currWord) {
        if (preg_match('/\s+/', $currWord) === 1) {
            if (!$keepSpaces) {
                $words[$i] = ' ';
            }
            continue;
        }

        if (preg_match($regexWordSplit, $currWord) === 1) {
            continue;
        }

        $currWordLower = strtolower($currWord);
        if ($i != 0 && $i != count($words) - 1 && in_array($currWordLower, $stopWords)) {
            $words[$i] = $currWordLower;
            continue;
        }

        if (strlen($currWord) > 0) {
            $currWord[0] = strtoupper($currWord[0]);
            $words[$i] = $currWord;
        }
    }

    return implode('', $words);
}

Everything works well (as far as I can tell). I'm looking for feedback on the above conversion and also general feedback on the PHP code (which it's maybe too much of a JS translation).

Some el cheapo testing for the PHP code:

$tests = array(
    array('in' => '', 'out' => '', 'keepSpaces' => false),
    array('in' => 'this is a test', 'out' => 'This Is a Test', 'keepSpaces' => false),
    array('in' => 'why sunless tanning is A hot trend', 'out' => 'Why Sunless Tanning Is a Hot Trend', 'keepSpaces' => false),
    array('in' => 'Satin Sheets are a Luxury you Can Afford', 'out' => 'Satin Sheets Are a Luxury You Can Afford', 'keepSpaces' => false),
    array('in' => 'the Dangers Of Hiking Without Proper Shoes', 'out' => 'The Dangers of Hiking Without Proper Shoes', 'keepSpaces' => false),
    array('in' => 'an hour or so', 'out' => 'An Hour or So', 'keepSpaces' => false),
    array('in' => 'Of the meaning Of Of', 'out' => 'Of the Meaning of Of', 'keepSpaces' => false),
    array('in' => 'Thing With     Extra Spaces', 'out' => 'Thing With Extra Spaces', 'keepSpaces' => false),
    array('in' => 'Thing with     extra spaces', 'out' => 'Thing With     Extra Spaces', 'keepSpaces' => true),
    array('in' => 'Observations of isolated pulsars and disk-fed X-ray binaries.', 'out' => 'Observations of Isolated Pulsars and Disk-Fed X-Ray Binaries.', 'keepSpaces' => false)
);

$ok = 0;
foreach ($tests as $currTest) {
    $currRes = APATitle($currTest['in'], $currTest['keepSpaces']);
    if ($currRes === $currTest['out']) {
        ++$ok;
    }
}
echo $ok == count($tests) ? 'All tests OK' : 'Test bad';
\$\endgroup\$
0
1
\$\begingroup\$

First things first: You definitely don't need to be making multiple regex function calls while looping the elements generated by a splitting regex function. Time for an almost complete rewrite. preg_replace_callback() is the tool that I recommend to do all the heavy lifting.

Normally, I like to do everything in a single regex expression, but the retaining of the multiple spaces is a simple operation that is sensibly set apart.

There WILL be other fringe cases that other reviewers may shout out (e.g. handling multibyte characters or calling preg_quote() on the incoming blacklist words to avoid any regex pattern conflicts with special characters, and let's not forget about troublesome Mr McGee) but I am going to only focus on the quality of data that you have presented.

There will be other ways to write the pattern but I have a high appreciation for the clarity of (*SKIP)(*FAIL) it basically consumes substrings that should be disqualified and then discards them. The pipe after the skip-fail non-capturing group will match qualifying consecutive letters. The fullstring matches are passed to the custom function where the first letter is converted to uppercase, then returned. Finally the fully modified string is returned from the preg_replace_callback().

My regex pattern uses negative lookaheads with the start and end of string anchors (^ & $) to implement the rule regarding capitalizing the first and last word. Word boundaries (\b) are a great way to isolate a whole word (or parts of a hyphenated word).

Code: (Demo)

$tests = [
    ['this is a test', 'This Is a Test', false],
    ['why sunless tanning is A hot trend', 'Why Sunless Tanning Is a Hot Trend', false],
    ['Satin Sheets are a Luxury you Can Afford', 'Satin Sheets Are a Luxury You Can Afford', false],
    ['the Dangers Of Hiking Without Proper Shoes', 'The Dangers of Hiking Without Proper Shoes', false],
    ['an hour or so', 'An Hour or So', false],
    ['Of the meaning Of Of', 'Of the Meaning of Of', false],
    ['Thing With     Extra Spaces', 'Thing With Extra Spaces', false],
    ['Thing with     extra spaces', 'Thing With     Extra Spaces', true],
    ['Observations of isolated pulsars and disk-fed X-ray binaries.', 'Observations of Isolated Pulsars and Disk-Fed X-Ray Binaries.', false]
];

function APATitle($title, $keepMultipleSpaces = false, $blackList = [])
{
    if (!is_string($title)) {
        return false;
    }

    $blackList = $blackList
        ? array_map('strtolower', (array)$blackList)
        : ['a', 'an', 'and', 'at', 'but', 'by', 'for', 'in', 'nor', 'of', 'on', 'or', 'so', 'the', 'to', 'up', 'yet'];

    if (!$keepMultipleSpaces) {
        $title = preg_replace('~\s+~', ' ', $title);
    }

    return preg_replace_callback(
        '~(?!^)\b(?:' . implode('|', $blackList) . ')\b(?!$)(*SKIP)(*FAIL)|\b[a-z]+\b~',
        function ($m) {
            return ucfirst($m[0]);
        },
        strtolower($title)
    );
}

foreach ($tests as [$input, $expectedOutput, $keepMultipleSpaces]) {
    $output = APATitle($input, $keepMultipleSpaces);
    echo ($output === $expectedOutput ? 'SUCCESS' : 'FAILURE') . ":\t\"{$input}\" became \"{$output}\"\n";
}

Output:

SUCCESS:    "this is a test" became "This Is a Test"
SUCCESS:    "why sunless tanning is A hot trend" became "Why Sunless Tanning Is a Hot Trend"
SUCCESS:    "Satin Sheets are a Luxury you Can Afford" became "Satin Sheets Are a Luxury You Can Afford"
SUCCESS:    "the Dangers Of Hiking Without Proper Shoes" became "The Dangers of Hiking Without Proper Shoes"
SUCCESS:    "an hour or so" became "An Hour or So"
SUCCESS:    "Of the meaning Of Of" became "Of the Meaning of Of"
SUCCESS:    "Thing With     Extra Spaces" became "Thing With Extra Spaces"
SUCCESS:    "Thing with     extra spaces" became "Thing With     Extra Spaces"
SUCCESS:    "Observations of isolated pulsars and disk-fed X-ray binaries." became "Observations of Isolated Pulsars and Disk-Fed X-Ray Binaries."
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.