Copyright (c) Rodolfo Berrios All rights reserved. Licensed under the MIT license http://opensource.org/licenses/MIT --------------------------------------------------------------------- */ /** * This class uses code that belongs or was taken from the following: * * David Soria Parra * https://github.com/dsp/PHP-Gettext * * Jyxo, s.r.o. * https://github.com/jyxo/php/tree/master/Jyxo/Gettext * * WordPress * https://wordpress.org/ */ /** * class.gettext.php * This class is a stand-alone implementation of gettext. * It works with .po and .mo files and saves the result in a cached static file (by default) */ namespace G; use Exception; class Gettext { // Magic words in the MO header const MO_MAGIC_1 = -569244523; //0xde120495 const MO_MAGIC_2 = -1794895138; //0x950412de // Cache stuff const CACHE_FILE_SUFFIX = '.cache.php'; protected static $default_options = ['cache' => TRUE, 'cache_type' => 'file', 'cache_filepath' => NULL, 'cache_header' => TRUE]; protected $source_file; protected $parsed = FALSE; public $translation_table = []; public $translation_plural = NULL; public $translation_header = NULL; public function __construct($options=[]) { $this->options = array_merge(static::$default_options, (array)$options); $this->source_file = $this->options['file']; if(!@is_readable($this->source_file)) { throw new GettextException("Can't read source file", 100); } $file_extension = pathinfo($this->source_file, PATHINFO_EXTENSION); // Only allow MO and PO if(!in_array($file_extension, ['mo', 'po'])) { throw new GettextException('Invalid file source. This only works with .mo and .po files', 101); } $this->parse_method = strtoupper($file_extension); if($this->options['cache']) { if($this->options['cache_filepath']) { // Custom whatever filepath cache $this->cache_file = $this->options['cache_filepath']; } else { // Default cache filepath.cache.php $this->cache_file = $this->source_file . self::CACHE_FILE_SUFFIX; } if(!$this->getCache()) { // No cache was found $this->parseFile(); } } else { $this->parseFile(); } } /** * Return a translated string * * If the translation is not found, the original message will be returned. * * @param String $msg The message to search for * @return translated string */ public function gettext($msg) { if(empty($msg)) return NULL; if(!$this->parsed) $this->parseFile(); if($this->mustFixQuotes()) { $msg = $this->fixQuotes($msg, 'escape'); } $translated = $msg; if(array_key_exists($msg, $this->translation_table)) { $translated = $this->translation_table[$msg][0]; $translated = !empty($translated) ? $translated : $msg; } if($this->mustFixQuotes()) { $translated = $this->fixQuotes($translated, 'unescape'); } return $translated; } /** * Return a translated string in it's plural form * * Returns the given $count (e.g second, third,...) plural form of the * given string. If the id is not found and $num == 1 $msg is returned, * otherwise $msg_plural * * @param String $msg The message to search for * @param String $msg_plural A fallback plural form * @param Integer $count Which plural form * * @return translated string */ public function ngettext($msg, $msg_plural, $count=0) { if(empty($msg) or empty($msg_plural) or !is_numeric($count)) { return $msg; } if(!$this->parsed) $this->parseFile(); if($this->mustFixQuotes()) { $msg = $this->fixQuotes($msg, 'escape'); $msg_plural = $this->fixQuotes($msg_plural, 'escape'); } $translated = $count == 1 ? $msg : $msg_plural; // Failover if(array_key_exists($msg, $this->translation_table)) { $plural_index = $this->getPluralIndex($count); $index_id = $plural_index !== FALSE ? $plural_index : ($count - 1); $table = $this->translation_table[$msg]; if(array_key_exists($index_id, $table)) { $translated = $table[$index_id]; } } if($this->mustFixQuotes()) { $translated = $this->fixQuotes($translated, 'unescape'); } return $translated; } /** * Parse the source file * If cache is enabled it will try to cache the result */ private function parseFile() { $parseFn = 'parse' . $this->parse_method . 'File'; try { $this->$parseFn(); $this->parsed = TRUE; if($this->options['cache']) { try { $this->cache('file'); } catch(Exception $e) { error_log($e); // Don't scream for cache issues } } } catch(Exception $e) { throw $e; } } /** * Parse the MO file header and returns the table * offsets as described in the file header. * * If an exception occurred, null is returned. This is intentionally * as we need to get close to ext/gettext behaviour. * * @param resource $fp The open file handler to the MO file * * @return array offset */ private function parseMOHeader($fp) { $data = fread($fp, 8); if(!$data) { throw new GettextException("Can't fread(8) file for reading", 202); } $header = unpack('lmagic/lrevision', $data); if(self::MO_MAGIC_1 != $header['magic'] && self::MO_MAGIC_2 != $header['magic']) { return NULL; } if(0 != $header['revision']) { return NULL; } $data = fread($fp, 4 * 5); if(!$data) { throw new GettextException("Can't fread(4 * 5) file for reading", 203); } $offsets = unpack('lnum_strings/lorig_offset/' . 'ltrans_offset/lhash_size/lhash_offset', $data); return $offsets; } /** * Parse and returns the string offsets in a a table. Two table can be found in * a mo file. The table with the translations and the table with the original * strings. Both contain offsets to the strings in the file. * * If an exception occurred, null is returned. This is intentionally * as we need to get close to ext/gettext behaviour. * * @param resource $fp The open file handler to the MO file * @param int $offset The offset to the table that should be parsed * @param int $num The number of strings to parse * * @return Array of offsets */ private function parseMOTableOffset($fp, $offset, $num) { if(fseek($fp, $offset, SEEK_SET) < 0) { return NULL; } $table = []; for($i=0; $i<$num; $i++) { $data = fread($fp, 8); $table[] = unpack('lsize/loffset', $data); } return $table; } /** * Parse a string as referenced by an table. Returns an * array with the actual string. * * @param resource $fp The open file handler to the MO fie * @param array $entry The entry as parsed by parseMOTableOffset() * * @return Parsed string */ private function parseMOEntry($fp, $entry) { if(fseek($fp, $entry['offset'], SEEK_SET) < 0) { return NULL; } if($entry['size'] > 0) { return fread($fp, $entry['size']); } return NULL; } /** * Parse the plural data found in the language * * @param string $header with nplurals and plural declaration */ private function parsePluralData($header) { // Detect plural data. If nothing found then use general plural handling if(preg_match('/\s*nplurals\s*=\s*(\d+)\s*;\s*plural\s*=\s*(\({0,1}.*\){0,1})\s*;/', $header, $matches)) { $plurals = [(int)$matches[1], $matches[2]]; } else { $plurals = [2, '(n != 1)']; // Base english-like plural languages } list($nplurals, $formula) = $plurals; // Fix the plural formula $formula = $this->parenthesizePluralFormula($formula); // Generate the translation_plural array $formula = str_replace('n', '$n', $formula); $function = "\$index = (int)($formula); return (\$index < $nplurals) ? \$index : $nplurals - 1;"; // Stock everything $this->translation_plural = [ 'nplurals' => $nplurals, 'plural' => $plurals[1], 'formula' => $formula, 'function' => $function, ]; } /** * Adds parentheses to the inner parts of ternary operators in * plural formulas, because PHP evaluates ternary operators from left to right * * @param string $formula the expression without parentheses * @return string the formula with parentheses added */ private function parenthesizePluralFormula($formula) { $formula .= ';'; $return = ''; $depth = 0; for ($i = 0; $i < strlen($formula); ++$i) { $char = $formula[$i]; switch($char) { case '?': $return .= ' ? ('; $depth++; break; case ':': $return .= ') : ('; break; case ';': $return .= str_repeat(')', $depth) . ';'; $depth = 0; break; default: $return .= $char; } } $return = trim(rtrim($return, ';')); // Cleaning $return = preg_replace('/\s+/S', ' ', $return); // Extra spaces $return = str_replace('( ', '(', str_replace(' )', ')', $return)); // Remove extra space around () return $return; } /** * Get plural index * * @param int msg count * @return int plural index */ function getPluralIndex($count) { // Detect if function exists (raw) if(!array_key_exists('function', $this->translation_plural)) { return FALSE; } // Detect if callable function has been already created if(!array_key_exists('callable', $this->translation_plural)) { $this->translation_plural['callable'] = create_function('$n', $this->translation_plural['function']); } return call_user_func($this->translation_plural['callable'], $count); } private function parseHeader($header) { $headerTable = []; $lines = array_map('trim', explode("\n", $header)); foreach($lines as $line) { if(starts_with('msgid', $line) or starts_with('msgstr', $line)) continue; $line = preg_replace('#\"(.*)\"#', '$1', $line); $line = rtrim($line, '\n'); $parts = explode(':', $line, 2); if(!isset($parts[1])) continue; // Skip empty keys $headerTable[trim($parts[0])] = trim($parts[1]); } return $headerTable; } /** * Parse a PO entry chunk * @param Array $chunk * * @return Array of translation table */ private function parsePOEntry($chunk) { $chunks = explode("\n", $chunk); foreach($chunks as $chunk) { // Skip #: and empty chunks if(starts_with('#', $chunk) or is_null($chunk)) { continue; } // Parse the plural forms if(is_null($this->translation_plural) and starts_with('"Plural-Forms:', $chunk)) { $this->parsePluralData($chunk); } // Nasty regexes if(preg_match('/^msgid "(.*)"/', $chunk, $matches)) { $msgid = $matches[1]; } elseif(preg_match('/^msgstr "(.*)"/', $chunk, $matches)) { $msgstr = $matches[1]; } elseif(preg_match('/^#~ msgid "(.*)"/', $chunk, $matches)) { //$obsolete = TRUE; $msgid = $matches[1]; } elseif(preg_match('/^#~ msgstr "(.*)"/', $chunk, $matches)) { //$obsolete = TRUE; $msgstr = $matches[1]; } elseif(preg_match('/^(#: .+)$/', $chunk, $matches)) { $location .= $matches[1]; } elseif(preg_match('/^#, fuzzy/', $chunk)) { $fuzzy = TRUE; } elseif(preg_match('/^msgid_plural "(.*)"/', $chunk, $matches)) { $plural = $matches[1]; //$msgstr = []; } elseif(preg_match('/^msgstr\[([0-9])+\] "(.*)"/', $chunk, $matches)) { if($matches[2] == '') continue; if(!is_array($msgstr)) { $msgstr = []; } $msgstr[$matches[1]] = $matches[2]; } } if($msgstr == '') $msgstr = NULL; if(empty($msgid)) { return NULL; } else { return [ 'msgid' => $msgid, 'msgstr'=> is_null($msgstr) ? NULL : (array)$msgstr ]; } } /** * Parse binary .mo file */ private function parseMOFile() { $filesize = filesize($this->source_file); if($filesize < 4 * 7) { return; } $fp = @fopen($this->source_file, 'rb'); if(!$fp) { throw new GettextException("Can't fopen file for reading", 200); } $offsets = $this->parseMOHeader($fp); if(NULL == $offsets || $filesize < 4 * ($offsets['num_strings'] + 7)) { fclose($fp); return; } $transTable = array(); $table = $this->parseMOTableOffset($fp, $offsets['trans_offset'], $offsets['num_strings']); if(NULL == $table) { fclose($fp); return; } foreach($table as $idx => $entry) { $transTable[$idx] = $this->parseMOEntry($fp, $entry); } $this->translation_header = $this->parseHeader(reset($transTable)); // Parse plural data $this->parsePluralData($this->translation_header['Plural-Forms']); $table = $this->parseMOTableOffset($fp, $offsets['orig_offset'], $offsets['num_strings']); foreach($table as $idx => $entry) { $entry = $this->parseMOEntry($fp, $entry); $formes = explode(chr(0), $entry); $translation = explode(chr(0), $transTable[$idx]); foreach($formes as $form) { if(empty($form)) continue; $this->translation_table[$form] = $translation; } } fclose($fp); } /** * Parse text based .po file */ private function parsePOFile() { $linenumber = 0; $chunks = []; $file = file($this->source_file); if(!$file) { throw new GettextException("Can't read file into an array", 204); } foreach($file as $line) { if($line == "\n" or $line == "\r\n") { ++$linenumber; } else { if(!array_key_exists($linenumber, $chunks)) { $chunks[$linenumber] = ''; } $chunks[$linenumber] .= $line; } } $this->translation_header = $this->parseHeader(reset($chunks)); foreach($chunks as $chunk) { $entry = $this->parsePOEntry($chunk); if(!$entry['msgid'] or is_null($entry['msgstr'])) continue; $this->translation_table[$entry['msgid']] = $entry['msgstr']; } } /** * Get cached results (cached file) * * @return bool cache status */ private function getCache() { if(@is_readable($this->cache_file)) { // Outdated cache? $source_mtime = filemtime($this->source_file); $cache_mtime = filemtime($this->cache_file); if($source_mtime and $cache_mtime and $source_mtime > $cache_mtime) { return FALSE; } if(!@include_once($this->cache_file)) { return FALSE; } if(is_array($translation_table)) { $this->translation_table = $translation_table; if(is_array($translation_plural)) { $this->translation_plural = $translation_plural; } if(is_array($translation_header)) { $this->translation_header = $translation_header; } $this->is_cached = TRUE; $this->parsed = TRUE; return TRUE; } } $this->is_cached = FALSE; return FALSE; } /** * Cache the translation results into a file */ private function cache() { if(!@is_dir(dirname($this->cache_file))) { throw new GettextException("Target cache dir doesn't exists", 400); } if(($fh = @fopen($this->cache_file, 'w')) === FALSE) { throw new GettextException("Can't fopen cache file for writing", 401); } // Cache contents, closer as possible to the mo/po scheme $contents = 'options['cache_header']) { if(!is_null($this->translation_header)) { $contents .= '$translation_header = ' . var_export($this->translation_header, TRUE) . ';' . "\n"; } if(!is_null($this->translation_plural)) { $translation_plural = $this->translation_plural; unset($translation_plural['callable']); // Don't cache the callable reference $contents .= '$translation_plural = ' . var_export($this->translation_plural, TRUE) . ';' . "\n"; } } // Note that we keep the same "quotes" used by the PO file scheme $contents .= '$translation_table = ['; foreach($this->translation_table as $k => $v) { $k = $this->parse_method == 'PO' ? $k : $this->fixQuotes($k, 'escape'); $contents .= "\n" . ' "' . $k . '" => ['; foreach($v as $kk => $vv) { $kk = $this->parse_method == 'PO' ? $kk : $this->fixQuotes($kk, 'escape'); $vv = $this->parse_method == 'PO' ? $vv : $this->fixQuotes($vv, 'escape'); $contents .= "\n" . ' ' . $kk . ' => "' . $vv . '",'; } $contents .= "\n". ' ],'; } $contents .= "\n" . '];' . "\n" . '?>'; if(!fwrite($fh, $contents)) { throw new GettextException("Can't save translation results to cache file", 402); } @touch($this->source_file); // Make sure to use the correct filemtime next time fclose($fh); } private function fixQuotes($msg, $action=NULL) { if($this->is_cached) return $msg; switch($action) { case 'escape': $msg = str_replace('"', '\"', $msg); break; case 'unescape': $msg = str_replace('\"', '"', $msg); break; } return $msg; } private function mustFixQuotes() { return $this->is_cached or $this->parse_method == 'PO'; } } class GettextException extends Exception {}