| [ Index ] |
PHP Cross Reference of BackPress |
[Summary view] [Print] [Text view]
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;
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated: Tue Apr 14 01:00:15 2026 | Cross-referenced by PHPXref 0.7.1 |