598 lines
17 KiB
PHP
598 lines
17 KiB
PHP
<?php
|
|
|
|
/* --------------------------------------------------------------------
|
|
|
|
G\ library
|
|
http://gbackslash.com
|
|
|
|
@author Rodolfo Berrios A. <http://rodolfoberrios.com/>
|
|
|
|
Copyright (c) Rodolfo Berrios <inbox@rodolfoberrios.com> 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 <sn_@gmx.net>
|
|
* 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 = '<?php' . "\n";
|
|
|
|
if($this->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 {} |