po.php 15 KB

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