The chunks, in order. Empty when the text has no content. * @throws InvalidArgumentException If the limit is not positive. */ public static function split(string $text, int $limit): array { // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing exception message, never echoed. if ($limit < 1) { throw new InvalidArgumentException( sprintf('The chunk limit must be a positive number of characters, got %d.', $limit) ); } // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped if (trim($text) === '') { return []; } if (mb_strlen($text) <= $limit) { return [$text]; } $chunks = []; $remaining = $text; while (mb_strlen($remaining) > $limit) { $splitAt = self::findSplitPosition(mb_substr($remaining, 0, $limit)); $chunks[] = mb_substr($remaining, 0, $splitAt); $remaining = mb_substr($remaining, $splitAt); } if ($remaining !== '') { $chunks[] = $remaining; } return $chunks; } /** * Rejoins chunks produced by {@see self::split()}. * * Splitting preserves every character, including the whitespace at a * boundary, so joining is a plain concatenation. It exists as a named * operation so the round trip can be asserted directly. * * @since 1.3.0 * * @param list $chunks The chunks to join. * @return string The rejoined text. */ public static function join(array $chunks): string { return implode('', $chunks); } /** * Finds how many characters of a candidate chunk to keep. * * Works on a window already truncated to the limit, and returns the offset * just past the best boundary within it. * * @since 1.3.0 * * @param string $window The candidate chunk, no longer than the limit. * @return int The number of characters to take, always at least 1. */ private static function findSplitPosition(string $window): int { $length = mb_strlen($window); // A paragraph break is the least audible place to split. $paragraph = mb_strrpos($window, "\n\n"); if ($paragraph !== false && $paragraph > 0) { return $paragraph + 2; } // Then the end of a sentence, keeping the terminator with its sentence. $sentence = self::lastSentenceEnd($window); if ($sentence !== null) { return $sentence; } // Then any whitespace, keeping it so the join stays lossless. for ($i = $length - 1; $i > 0; $i--) { if (preg_match('/\s/u', mb_substr($window, $i, 1)) === 1) { return $i + 1; } } // A single token longer than the limit; break it rather than stall. return $length; } /** * Finds the offset just past the last sentence terminator in a window. * * @since 1.3.0 * * @param string $window The candidate chunk. * @return int|null The offset just past the terminator, or null if there is none. */ private static function lastSentenceEnd(string $window): ?int { $length = mb_strlen($window); for ($i = $length - 1; $i > 0; $i--) { $char = mb_substr($window, $i, 1); if (!in_array($char, ['.', '!', '?', '。', '!', '?'], true)) { continue; } // Take any whitespace that follows, so the next chunk starts on the // sentence rather than on a stray space. $end = $i + 1; while ($end < $length && preg_match('/\s/u', mb_substr($window, $end, 1)) === 1) { $end++; } return $end; } return null; } }