[ Index ]

PHP Cross Reference of BackPress

title

Body

[close]

/includes/pomo/ -> po.php (source)

   1  <?php
   2  /**
   3   * Class for working with PO files
   4   *
   5   * @version $Id: po.php 1183 2026-03-30 00:28:59Z dd32 $
   6   * @package pomo
   7   * @subpackage po
   8   */
   9  
  10  require_once  __DIR__ . '/translations.php';
  11  
  12  if ( ! defined( 'PO_MAX_LINE_LEN' ) ) {
  13      define( 'PO_MAX_LINE_LEN', 79 );
  14  }
  15  
  16  /**
  17   * Routines for working with PO files
  18   */
  19  if ( ! class_exists( 'PO', false ) ) :
  20      class PO extends Gettext_Translations {
  21  
  22          public $comments_before_headers = '';
  23  
  24          /**
  25           * Exports headers to a PO entry
  26           *
  27           * @return string msgid/msgstr PO entry for this PO file headers, doesn't contain newline at the end
  28           */
  29  		public function export_headers() {
  30              $header_string = '';
  31              foreach ( $this->headers as $header => $value ) {
  32                  $header_string .= "$header: $value\n";
  33              }
  34              $poified = PO::poify( $header_string );
  35              if ( $this->comments_before_headers ) {
  36                  $before_headers = $this->prepend_each_line( rtrim( $this->comments_before_headers ) . "\n", '# ' );
  37              } else {
  38                  $before_headers = '';
  39              }
  40              return rtrim( "{$before_headers}msgid \"\"\nmsgstr $poified" );
  41          }
  42  
  43          /**
  44           * Exports all entries to PO format
  45           *
  46           * @return string sequence of msgid/msgstr PO strings, doesn't contain a newline at the end
  47           */
  48  		public function export_entries() {
  49              // TODO: Sorting.
  50              return implode( "\n\n", array_map( array( 'PO', 'export_entry' ), $this->entries ) );
  51          }
  52  
  53          /**
  54           * Exports the whole PO file as a string
  55           *
  56           * @param bool $include_headers whether to include the headers in the export
  57           * @return string ready for inclusion in PO file string for headers and all the entries
  58           */
  59  		public function export( $include_headers = true ) {
  60              $res = '';
  61              if ( $include_headers ) {
  62                  $res .= $this->export_headers();
  63                  $res .= "\n\n";
  64              }
  65              $res .= $this->export_entries();
  66              return $res;
  67          }
  68  
  69          /**
  70           * Same as {@link export}, but writes the result to a file
  71           *
  72           * @param string $filename        Where to write the PO string.
  73           * @param bool   $include_headers Whether to include the headers in the export.
  74           * @return bool true on success, false on error
  75           */
  76  		public function export_to_file( $filename, $include_headers = true ) {
  77              $fh = fopen( $filename, 'w' );
  78              if ( false === $fh ) {
  79                  return false;
  80              }
  81              $export = $this->export( $include_headers );
  82              $res    = fwrite( $fh, $export );
  83              if ( false === $res ) {
  84                  return false;
  85              }
  86              return fclose( $fh );
  87          }
  88  
  89          /**
  90           * Text to include as a comment before the start of the PO contents
  91           *
  92           * Doesn't need to include # in the beginning of lines, these are added automatically
  93           *
  94           * @param string $text Text to include as a comment.
  95           */
  96  		public function set_comment_before_headers( $text ) {
  97              $this->comments_before_headers = $text;
  98          }
  99  
 100          /**
 101           * Formats a string in PO-style
 102           *
 103           * @param string $input_string the string to format
 104           * @return string the poified string
 105           */
 106  		public static function poify( $input_string ) {
 107              $quote   = '"';
 108              $slash   = '\\';
 109              $newline = "\n";
 110  
 111              $replaces = array(
 112                  "$slash" => "$slash$slash",
 113                  "$quote" => "$slash$quote",
 114                  "\t"     => '\t',
 115              );
 116  
 117              $input_string = str_replace( array_keys( $replaces ), array_values( $replaces ), $input_string );
 118  
 119              $po = $quote . implode( "{$slash}n{$quote}{$newline}{$quote}", explode( $newline, $input_string ) ) . $quote;
 120              // Add empty string on first line for readability.
 121              if ( str_contains( $input_string, $newline ) &&
 122                  ( substr_count( $input_string, $newline ) > 1 || substr( $input_string, -strlen( $newline ) ) !== $newline ) ) {
 123                  $po = "$quote$quote$newline$po";
 124              }
 125              // Remove empty strings.
 126              $po = str_replace( "$newline$quote$quote", '', $po );
 127              return $po;
 128          }
 129  
 130          /**
 131           * Gives back the original string from a PO-formatted string
 132           *
 133           * @param string $input_string PO-formatted string
 134           * @return string unescaped string
 135           */
 136  		public static function unpoify( $input_string ) {
 137              $escapes               = array(
 138                  't'  => "\t",
 139                  'n'  => "\n",
 140                  'r'  => "\r",
 141                  '\\' => '\\',
 142              );
 143              $lines                 = array_map( 'trim', explode( "\n", $input_string ) );
 144              $lines                 = array_map( array( 'PO', 'trim_quotes' ), $lines );
 145              $unpoified             = '';
 146              $previous_is_backslash = false;
 147              foreach ( $lines as $line ) {
 148                  preg_match_all( '/./u', $line, $chars );
 149                  $chars = $chars[0];
 150                  foreach ( $chars as $char ) {
 151                      if ( ! $previous_is_backslash ) {
 152                          if ( '\\' === $char ) {
 153                              $previous_is_backslash = true;
 154                          } else {
 155                              $unpoified .= $char;
 156                          }
 157                      } else {
 158                          $previous_is_backslash = false;
 159                          $unpoified            .= $escapes[ $char ] ?? $char;
 160                      }
 161                  }
 162              }
 163  
 164              // Standardize the line endings on imported content, technically PO files shouldn't contain \r.
 165              $unpoified = str_replace( array( "\r\n", "\r" ), "\n", $unpoified );
 166  
 167              return $unpoified;
 168          }
 169  
 170          /**
 171           * Inserts $with in the beginning of every new line of $input_string and
 172           * returns the modified string
 173           *
 174           * @param string $input_string prepend lines in this string
 175           * @param string $with         prepend lines with this string
 176           */
 177  		public static function prepend_each_line( $input_string, $with ) {
 178              $lines  = explode( "\n", $input_string );
 179              $append = '';
 180              if ( "\n" === substr( $input_string, -1 ) && '' === end( $lines ) ) {
 181                  /*
 182                   * Last line might be empty because $input_string was terminated
 183                   * with a newline, remove it from the $lines array,
 184                   * we'll restore state by re-terminating the string at the end.
 185                   */
 186                  array_pop( $lines );
 187                  $append = "\n";
 188              }
 189              foreach ( $lines as &$line ) {
 190                  $line = $with . $line;
 191              }
 192              unset( $line );
 193              return implode( "\n", $lines ) . $append;
 194          }
 195  
 196          /**
 197           * Prepare a text as a comment -- wraps the lines and prepends #
 198           * and a special character to each line
 199           *
 200           * @access private
 201           * @param string $text the comment text
 202           * @param string $char character to denote a special PO comment,
 203           *  like :, default is a space
 204           */
 205  		public static function comment_block( $text, $char = ' ' ) {
 206              $text = wordwrap( $text, PO_MAX_LINE_LEN - 3 );
 207              return PO::prepend_each_line( $text, "#$char " );
 208          }
 209  
 210          /**
 211           * Builds a string from the entry for inclusion in PO file
 212           *
 213           * @param Translation_Entry $entry the entry to convert to po string.
 214           * @return string|false PO-style formatted string for the entry or
 215           *  false if the entry is empty
 216           */
 217  		public static function export_entry( $entry ) {
 218              if ( null === $entry->singular || '' === $entry->singular ) {
 219                  return false;
 220              }
 221              $po = array();
 222              if ( ! empty( $entry->translator_comments ) ) {
 223                  $po[] = PO::comment_block( $entry->translator_comments );
 224              }
 225              if ( ! empty( $entry->extracted_comments ) ) {
 226                  $po[] = PO::comment_block( $entry->extracted_comments, '.' );
 227              }
 228              if ( ! empty( $entry->references ) ) {
 229                  $po[] = PO::comment_block( implode( ' ', $entry->references ), ':' );
 230              }
 231              if ( ! empty( $entry->flags ) ) {
 232                  $po[] = PO::comment_block( implode( ', ', $entry->flags ), ',' );
 233              }
 234              if ( $entry->context ) {
 235                  $po[] = 'msgctxt ' . PO::poify( $entry->context );
 236              }
 237              $po[] = 'msgid ' . PO::poify( $entry->singular );
 238              if ( ! $entry->is_plural ) {
 239                  $translation = empty( $entry->translations ) ? '' : $entry->translations[0];
 240                  $translation = PO::match_begin_and_end_newlines( $translation, $entry->singular );
 241                  $po[]        = 'msgstr ' . PO::poify( $translation );
 242              } else {
 243                  $po[]         = 'msgid_plural ' . PO::poify( $entry->plural );
 244                  $translations = empty( $entry->translations ) ? array( '', '' ) : $entry->translations;
 245                  foreach ( $translations as $i => $translation ) {
 246                      $translation = PO::match_begin_and_end_newlines( $translation, $entry->plural );
 247                      $po[]        = "msgstr[$i] " . PO::poify( $translation );
 248                  }
 249              }
 250              return implode( "\n", $po );
 251          }
 252  
 253  		public static function match_begin_and_end_newlines( $translation, $original ) {
 254              if ( '' === $translation ) {
 255                  return $translation;
 256              }
 257  
 258              $original_begin    = "\n" === substr( $original, 0, 1 );
 259              $original_end      = "\n" === substr( $original, -1 );
 260              $translation_begin = "\n" === substr( $translation, 0, 1 );
 261              $translation_end   = "\n" === substr( $translation, -1 );
 262  
 263              if ( $original_begin ) {
 264                  if ( ! $translation_begin ) {
 265                      $translation = "\n" . $translation;
 266                  }
 267              } elseif ( $translation_begin ) {
 268                  $translation = ltrim( $translation, "\n" );
 269              }
 270  
 271              if ( $original_end ) {
 272                  if ( ! $translation_end ) {
 273                      $translation .= "\n";
 274                  }
 275              } elseif ( $translation_end ) {
 276                  $translation = rtrim( $translation, "\n" );
 277              }
 278  
 279              return $translation;
 280          }
 281  
 282          /**
 283           * @param string $filename
 284           * @return bool
 285           */
 286  		public function import_from_file( $filename ) {
 287              $f = fopen( $filename, 'r' );
 288              if ( ! $f ) {
 289                  return false;
 290              }
 291              $lineno = 0;
 292              while ( true ) {
 293                  $res = $this->read_entry( $f, $lineno );
 294                  if ( ! $res ) {
 295                      break;
 296                  }
 297                  if ( '' === $res['entry']->singular ) {
 298                      $this->set_headers( $this->make_headers( $res['entry']->translations[0] ) );
 299                  } else {
 300                      $this->add_entry( $res['entry'] );
 301                  }
 302              }
 303              PO::read_line( $f, 'clear' );
 304              if ( false === $res ) {
 305                  return false;
 306              }
 307              if ( ! $this->headers && ! $this->entries ) {
 308                  return false;
 309              }
 310              return true;
 311          }
 312  
 313          /**
 314           * Helper function for read_entry
 315           *
 316           * @param string $context
 317           * @return bool
 318           */
 319  		protected static function is_final( $context ) {
 320              return ( 'msgstr' === $context ) || ( 'msgstr_plural' === $context );
 321          }
 322  
 323          /**
 324           * @param resource $f
 325           * @param int      $lineno
 326           * @return null|false|array
 327           */
 328  		public function read_entry( $f, $lineno = 0 ) {
 329              $entry = new Translation_Entry();
 330              // Where were we in the last step.
 331              // Can be: comment, msgctxt, msgid, msgid_plural, msgstr, msgstr_plural.
 332              $context      = '';
 333              $msgstr_index = 0;
 334              while ( true ) {
 335                  ++$lineno;
 336                  $line = PO::read_line( $f );
 337                  if ( ! $line ) {
 338                      if ( feof( $f ) ) {
 339                          if ( self::is_final( $context ) ) {
 340                              break;
 341                          } elseif ( ! $context ) { // We haven't read a line and EOF came.
 342                              return null;
 343                          } else {
 344                              return false;
 345                          }
 346                      } else {
 347                          return false;
 348                      }
 349                  }
 350                  if ( "\n" === $line ) {
 351                      continue;
 352                  }
 353                  $line = trim( $line );
 354                  if ( preg_match( '/^#/', $line, $m ) ) {
 355                      // The comment is the start of a new entry.
 356                      if ( self::is_final( $context ) ) {
 357                          PO::read_line( $f, 'put-back' );
 358                          --$lineno;
 359                          break;
 360                      }
 361                      // Comments have to be at the beginning.
 362                      if ( $context && 'comment' !== $context ) {
 363                          return false;
 364                      }
 365                      // Add comment.
 366                      $this->add_comment_to_entry( $entry, $line );
 367                  } elseif ( preg_match( '/^msgctxt\s+(".*")/', $line, $m ) ) {
 368                      if ( self::is_final( $context ) ) {
 369                          PO::read_line( $f, 'put-back' );
 370                          --$lineno;
 371                          break;
 372                      }
 373                      if ( $context && 'comment' !== $context ) {
 374                          return false;
 375                      }
 376                      $context         = 'msgctxt';
 377                      $entry->context .= PO::unpoify( $m[1] );
 378                  } elseif ( preg_match( '/^msgid\s+(".*")/', $line, $m ) ) {
 379                      if ( self::is_final( $context ) ) {
 380                          PO::read_line( $f, 'put-back' );
 381                          --$lineno;
 382                          break;
 383                      }
 384                      if ( $context && 'msgctxt' !== $context && 'comment' !== $context ) {
 385                          return false;
 386                      }
 387                      $context          = 'msgid';
 388                      $entry->singular .= PO::unpoify( $m[1] );
 389                  } elseif ( preg_match( '/^msgid_plural\s+(".*")/', $line, $m ) ) {
 390                      if ( 'msgid' !== $context ) {
 391                          return false;
 392                      }
 393                      $context          = 'msgid_plural';
 394                      $entry->is_plural = true;
 395                      $entry->plural   .= PO::unpoify( $m[1] );
 396                  } elseif ( preg_match( '/^msgstr\s+(".*")/', $line, $m ) ) {
 397                      if ( 'msgid' !== $context ) {
 398                          return false;
 399                      }
 400                      $context             = 'msgstr';
 401                      $entry->translations = array( PO::unpoify( $m[1] ) );
 402                  } elseif ( preg_match( '/^msgstr\[(\d+)\]\s+(".*")/', $line, $m ) ) {
 403                      if ( 'msgid_plural' !== $context && 'msgstr_plural' !== $context ) {
 404                          return false;
 405                      }
 406                      $context                      = 'msgstr_plural';
 407                      $msgstr_index                 = $m[1];
 408                      $entry->translations[ $m[1] ] = PO::unpoify( $m[2] );
 409                  } elseif ( preg_match( '/^".*"$/', $line ) ) {
 410                      $unpoified = PO::unpoify( $line );
 411                      switch ( $context ) {
 412                          case 'msgid':
 413                              $entry->singular .= $unpoified;
 414                              break;
 415                          case 'msgctxt':
 416                              $entry->context .= $unpoified;
 417                              break;
 418                          case 'msgid_plural':
 419                              $entry->plural .= $unpoified;
 420                              break;
 421                          case 'msgstr':
 422                              $entry->translations[0] .= $unpoified;
 423                              break;
 424                          case 'msgstr_plural':
 425                              $entry->translations[ $msgstr_index ] .= $unpoified;
 426                              break;
 427                          default:
 428                              return false;
 429                      }
 430                  } else {
 431                      return false;
 432                  }
 433              }
 434  
 435              $have_translations = false;
 436              foreach ( $entry->translations as $t ) {
 437                  if ( $t || ( '0' === $t ) ) {
 438                      $have_translations = true;
 439                      break;
 440                  }
 441              }
 442              if ( false === $have_translations ) {
 443                  $entry->translations = array();
 444              }
 445  
 446              return array(
 447                  'entry'  => $entry,
 448                  'lineno' => $lineno,
 449              );
 450          }
 451  
 452          /**
 453           * @param resource $f
 454           * @param string   $action
 455           * @return bool
 456           */
 457  		public function read_line( $f, $action = 'read' ) {
 458              static $last_line     = '';
 459              static $use_last_line = false;
 460              if ( 'clear' === $action ) {
 461                  $last_line = '';
 462                  return true;
 463              }
 464              if ( 'put-back' === $action ) {
 465                  $use_last_line = true;
 466                  return true;
 467              }
 468  
 469              if ( $use_last_line ) {
 470                  $line = $last_line;
 471              } else {
 472                  $line = fgets( $f );
 473                  if ( false === $line ) {
 474                      return $line;
 475                  }
 476  
 477                  // Handle \r-only terminated lines after the deprecation of auto_detect_line_endings in PHP 8.1.
 478                  $r = strpos( $line, "\r" );
 479                  if ( false !== $r ) {
 480                      if ( strlen( $line ) === $r + 1
 481                          && "\r\n" === substr( $line, $r )
 482                      ) {
 483                          $line = rtrim( $line, "\r\n" ) . "\n";
 484                      } else {
 485                          // The lines are terminated by just \r, so we end the line there and rewind.
 486                          $rewind = strlen( $line ) - $r - 1;
 487                          $line   = substr( $line, 0, $r ) . "\n";
 488                          fseek( $f, - $rewind, SEEK_CUR );
 489                      }
 490                  }
 491              }
 492  
 493              $last_line     = $line;
 494              $use_last_line = false;
 495              return $line;
 496          }
 497  
 498          /**
 499           * @param Translation_Entry $entry
 500           * @param string            $po_comment_line
 501           */
 502  		public function add_comment_to_entry( &$entry, $po_comment_line ) {
 503              $first_two = substr( $po_comment_line, 0, 2 );
 504              $comment   = trim( substr( $po_comment_line, 2 ) );
 505              if ( '#:' === $first_two ) {
 506                  $entry->references = array_merge( $entry->references, preg_split( '/\s+/', $comment ) );
 507              } elseif ( '#.' === $first_two ) {
 508                  $entry->extracted_comments = trim( $entry->extracted_comments . "\n" . $comment );
 509              } elseif ( '#,' === $first_two ) {
 510                  $entry->flags = array_merge( $entry->flags, preg_split( '/,\s*/', $comment ) );
 511              } else {
 512                  $entry->translator_comments = trim( $entry->translator_comments . "\n" . $comment );
 513              }
 514          }
 515  
 516          /**
 517           * @param string $s
 518           * @return string
 519           */
 520  		public static function trim_quotes( $s ) {
 521              if ( str_starts_with( $s, '"' ) ) {
 522                  $s = substr( $s, 1 );
 523              }
 524              if ( str_ends_with( $s, '"' ) ) {
 525                  $s = substr( $s, 0, -1 );
 526              }
 527              return $s;
 528          }
 529      }
 530  endif;


Generated: Tue Apr 14 01:00:15 2026 Cross-referenced by PHPXref 0.7.1