Chris@0: langcode; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function setLangcode($langcode) { Chris@17: $this->langcode = $langcode; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function getHeader() { Chris@17: return $this->header; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Implements Drupal\Component\Gettext\PoMetadataInterface::setHeader(). Chris@0: * Chris@0: * Not applicable to stream reading and therefore not implemented. Chris@0: */ Chris@0: public function setHeader(PoHeader $header) { Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function getURI() { Chris@17: return $this->uri; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function setURI($uri) { Chris@17: $this->uri = $uri; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Implements Drupal\Component\Gettext\PoStreamInterface::open(). Chris@0: * Chris@0: * Opens the stream and reads the header. The stream is ready for reading Chris@0: * items after. Chris@0: * Chris@14: * @throws \Exception Chris@0: * If the URI is not yet set. Chris@0: */ Chris@0: public function open() { Chris@17: if (!empty($this->uri)) { Chris@17: $this->fd = fopen($this->uri, 'rb'); Chris@0: $this->readHeader(); Chris@0: } Chris@0: else { Chris@0: throw new \Exception('Cannot open stream without URI set.'); Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * Implements Drupal\Component\Gettext\PoStreamInterface::close(). Chris@0: * Chris@14: * @throws \Exception Chris@0: * If the stream is not open. Chris@0: */ Chris@0: public function close() { Chris@17: if ($this->fd) { Chris@17: fclose($this->fd); Chris@0: } Chris@0: else { Chris@0: throw new \Exception('Cannot close stream that is not open.'); Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function readItem() { Chris@0: // Clear out the last item. Chris@17: $this->lastItem = NULL; Chris@0: Chris@0: // Read until finished with the stream or a complete item was identified. Chris@17: while (!$this->finished && is_null($this->lastItem)) { Chris@0: $this->readLine(); Chris@0: } Chris@0: Chris@17: return $this->lastItem; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Sets the seek position for the current PO stream. Chris@0: * Chris@0: * @param int $seek Chris@0: * The new seek position to set. Chris@0: */ Chris@0: public function setSeek($seek) { Chris@17: fseek($this->fd, $seek); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Gets the pointer position of the current PO stream. Chris@0: */ Chris@0: public function getSeek() { Chris@17: return ftell($this->fd); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Read the header from the PO stream. Chris@0: * Chris@0: * The header is a special case PoItem, using the empty string as source and Chris@0: * key-value pairs as translation. We just reuse the item reader logic to Chris@0: * read the header. Chris@0: */ Chris@0: private function readHeader() { Chris@0: $item = $this->readItem(); Chris@0: // Handle the case properly when the .po file is empty (0 bytes). Chris@0: if (!$item) { Chris@0: return; Chris@0: } Chris@0: $header = new PoHeader(); Chris@0: $header->setFromString(trim($item->getTranslation())); Chris@17: $this->header = $header; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Reads a line from the PO stream and stores data internally. Chris@0: * Chris@17: * Expands $this->current_item based on new data for the current item. If Chris@0: * this line ends the current item, it is saved with setItemFromArray() with Chris@17: * data from $this->current_item. Chris@0: * Chris@0: * An internal state machine is maintained in this reader using Chris@17: * $this->context as the reading state. PO items are in between COMMENT Chris@0: * states (when items have at least one line or comment in between them) or Chris@0: * indicated by MSGSTR or MSGSTR_ARR followed immediately by an MSGID or Chris@0: * MSGCTXT (when items closely follow each other). Chris@0: * Chris@0: * @return Chris@0: * FALSE if an error was logged, NULL otherwise. The errors are considered Chris@0: * non-blocking, so reading can continue, while the errors are collected Chris@0: * for later presentation. Chris@0: */ Chris@0: private function readLine() { Chris@0: // Read a line and set the stream finished indicator if it was not Chris@0: // possible anymore. Chris@17: $line = fgets($this->fd); Chris@17: $this->finished = ($line === FALSE); Chris@0: Chris@17: if (!$this->finished) { Chris@0: Chris@17: if ($this->lineNumber == 0) { Chris@0: // The first line might come with a UTF-8 BOM, which should be removed. Chris@0: $line = str_replace("\xEF\xBB\xBF", '', $line); Chris@0: // Current plurality for 'msgstr[]'. Chris@17: $this->currentPluralIndex = 0; Chris@0: } Chris@0: Chris@0: // Track the line number for error reporting. Chris@17: $this->lineNumber++; Chris@0: Chris@0: // Initialize common values for error logging. Chris@0: $log_vars = [ Chris@0: '%uri' => $this->getURI(), Chris@17: '%line' => $this->lineNumber, Chris@0: ]; Chris@0: Chris@0: // Trim away the linefeed. \\n might appear at the end of the string if Chris@0: // another line continuing the same string follows. We can remove that. Chris@0: $line = trim(strtr($line, ["\\\n" => ""])); Chris@0: Chris@0: if (!strncmp('#', $line, 1)) { Chris@0: // Lines starting with '#' are comments. Chris@0: Chris@17: if ($this->context == 'COMMENT') { Chris@0: // Already in comment context, add to current comment. Chris@17: $this->currentItem['#'][] = substr($line, 1); Chris@0: } Chris@17: elseif (($this->context == 'MSGSTR') || ($this->context == 'MSGSTR_ARR')) { Chris@0: // We are currently in string context, save current item. Chris@17: $this->setItemFromArray($this->currentItem); Chris@0: Chris@0: // Start a new entry for the comment. Chris@17: $this->currentItem = []; Chris@17: $this->currentItem['#'][] = substr($line, 1); Chris@0: Chris@17: $this->context = 'COMMENT'; Chris@0: return; Chris@0: } Chris@0: else { Chris@0: // A comment following any other context is a syntax error. Chris@17: $this->errors[] = new FormattableMarkup('The translation stream %uri contains an error: "msgstr" was expected but not found on line %line.', $log_vars); Chris@0: return FALSE; Chris@0: } Chris@0: return; Chris@0: } Chris@0: elseif (!strncmp('msgid_plural', $line, 12)) { Chris@0: // A plural form for the current source string. Chris@0: Chris@17: if ($this->context != 'MSGID') { Chris@0: // A plural form can only be added to an msgid directly. Chris@17: $this->errors[] = new FormattableMarkup('The translation stream %uri contains an error: "msgid_plural" was expected but not found on line %line.', $log_vars); Chris@0: return FALSE; Chris@0: } Chris@0: Chris@0: // Remove 'msgid_plural' and trim away whitespace. Chris@0: $line = trim(substr($line, 12)); Chris@0: Chris@0: // Only the plural source string is left, parse it. Chris@0: $quoted = $this->parseQuoted($line); Chris@0: if ($quoted === FALSE) { Chris@0: // The plural form must be wrapped in quotes. Chris@17: $this->errors[] = new FormattableMarkup('The translation stream %uri contains a syntax error on line %line.', $log_vars); Chris@0: return FALSE; Chris@0: } Chris@0: Chris@0: // Append the plural source to the current entry. Chris@17: if (is_string($this->currentItem['msgid'])) { Chris@0: // The first value was stored as string. Now we know the context is Chris@0: // plural, it is converted to array. Chris@17: $this->currentItem['msgid'] = [$this->currentItem['msgid']]; Chris@0: } Chris@17: $this->currentItem['msgid'][] = $quoted; Chris@0: Chris@17: $this->context = 'MSGID_PLURAL'; Chris@0: return; Chris@0: } Chris@0: elseif (!strncmp('msgid', $line, 5)) { Chris@0: // Starting a new message. Chris@0: Chris@17: if (($this->context == 'MSGSTR') || ($this->context == 'MSGSTR_ARR')) { Chris@0: // We are currently in string context, save current item. Chris@17: $this->setItemFromArray($this->currentItem); Chris@0: Chris@0: // Start a new context for the msgid. Chris@17: $this->currentItem = []; Chris@0: } Chris@17: elseif ($this->context == 'MSGID') { Chris@0: // We are currently already in the context, meaning we passed an id with no data. Chris@17: $this->errors[] = new FormattableMarkup('The translation stream %uri contains an error: "msgid" is unexpected on line %line.', $log_vars); Chris@0: return FALSE; Chris@0: } Chris@0: Chris@0: // Remove 'msgid' and trim away whitespace. Chris@0: $line = trim(substr($line, 5)); Chris@0: Chris@0: // Only the message id string is left, parse it. Chris@0: $quoted = $this->parseQuoted($line); Chris@0: if ($quoted === FALSE) { Chris@0: // The message id must be wrapped in quotes. Chris@17: $this->errors[] = new FormattableMarkup('The translation stream %uri contains an error: invalid format for "msgid" on line %line.', $log_vars, $log_vars); Chris@0: return FALSE; Chris@0: } Chris@0: Chris@17: $this->currentItem['msgid'] = $quoted; Chris@17: $this->context = 'MSGID'; Chris@0: return; Chris@0: } Chris@0: elseif (!strncmp('msgctxt', $line, 7)) { Chris@0: // Starting a new context. Chris@0: Chris@17: if (($this->context == 'MSGSTR') || ($this->context == 'MSGSTR_ARR')) { Chris@0: // We are currently in string context, save current item. Chris@17: $this->setItemFromArray($this->currentItem); Chris@17: $this->currentItem = []; Chris@0: } Chris@17: elseif (!empty($this->currentItem['msgctxt'])) { Chris@0: // A context cannot apply to another context. Chris@17: $this->errors[] = new FormattableMarkup('The translation stream %uri contains an error: "msgctxt" is unexpected on line %line.', $log_vars); Chris@0: return FALSE; Chris@0: } Chris@0: Chris@0: // Remove 'msgctxt' and trim away whitespaces. Chris@0: $line = trim(substr($line, 7)); Chris@0: Chris@0: // Only the msgctxt string is left, parse it. Chris@0: $quoted = $this->parseQuoted($line); Chris@0: if ($quoted === FALSE) { Chris@0: // The context string must be quoted. Chris@17: $this->errors[] = new FormattableMarkup('The translation stream %uri contains an error: invalid format for "msgctxt" on line %line.', $log_vars); Chris@0: return FALSE; Chris@0: } Chris@0: Chris@17: $this->currentItem['msgctxt'] = $quoted; Chris@0: Chris@17: $this->context = 'MSGCTXT'; Chris@0: return; Chris@0: } Chris@0: elseif (!strncmp('msgstr[', $line, 7)) { Chris@0: // A message string for a specific plurality. Chris@0: Chris@17: if (($this->context != 'MSGID') && Chris@17: ($this->context != 'MSGCTXT') && Chris@17: ($this->context != 'MSGID_PLURAL') && Chris@17: ($this->context != 'MSGSTR_ARR')) { Chris@17: // Plural message strings must come after msgid, msgctxt, Chris@0: // msgid_plural, or other msgstr[] entries. Chris@17: $this->errors[] = new FormattableMarkup('The translation stream %uri contains an error: "msgstr[]" is unexpected on line %line.', $log_vars); Chris@0: return FALSE; Chris@0: } Chris@0: Chris@0: // Ensure the plurality is terminated. Chris@0: if (strpos($line, ']') === FALSE) { Chris@17: $this->errors[] = new FormattableMarkup('The translation stream %uri contains an error: invalid format for "msgstr[]" on line %line.', $log_vars); Chris@0: return FALSE; Chris@0: } Chris@0: Chris@0: // Extract the plurality. Chris@0: $frombracket = strstr($line, '['); Chris@17: $this->currentPluralIndex = substr($frombracket, 1, strpos($frombracket, ']') - 1); Chris@0: Chris@0: // Skip to the next whitespace and trim away any further whitespace, Chris@0: // bringing $line to the message text only. Chris@0: $line = trim(strstr($line, " ")); Chris@0: Chris@0: $quoted = $this->parseQuoted($line); Chris@0: if ($quoted === FALSE) { Chris@0: // The string must be quoted. Chris@17: $this->errors[] = new FormattableMarkup('The translation stream %uri contains an error: invalid format for "msgstr[]" on line %line.', $log_vars); Chris@0: return FALSE; Chris@0: } Chris@17: if (!isset($this->currentItem['msgstr']) || !is_array($this->currentItem['msgstr'])) { Chris@17: $this->currentItem['msgstr'] = []; Chris@0: } Chris@0: Chris@17: $this->currentItem['msgstr'][$this->currentPluralIndex] = $quoted; Chris@0: Chris@17: $this->context = 'MSGSTR_ARR'; Chris@0: return; Chris@0: } Chris@0: elseif (!strncmp("msgstr", $line, 6)) { Chris@0: // A string pair for an msgid (with optional context). Chris@0: Chris@17: if (($this->context != 'MSGID') && ($this->context != 'MSGCTXT')) { Chris@0: // Strings are only valid within an id or context scope. Chris@17: $this->errors[] = new FormattableMarkup('The translation stream %uri contains an error: "msgstr" is unexpected on line %line.', $log_vars); Chris@0: return FALSE; Chris@0: } Chris@0: Chris@0: // Remove 'msgstr' and trim away away whitespaces. Chris@0: $line = trim(substr($line, 6)); Chris@0: Chris@0: // Only the msgstr string is left, parse it. Chris@0: $quoted = $this->parseQuoted($line); Chris@0: if ($quoted === FALSE) { Chris@0: // The string must be quoted. Chris@17: $this->errors[] = new FormattableMarkup('The translation stream %uri contains an error: invalid format for "msgstr" on line %line.', $log_vars); Chris@0: return FALSE; Chris@0: } Chris@0: Chris@17: $this->currentItem['msgstr'] = $quoted; Chris@0: Chris@17: $this->context = 'MSGSTR'; Chris@0: return; Chris@0: } Chris@0: elseif ($line != '') { Chris@0: // Anything that is not a token may be a continuation of a previous token. Chris@0: Chris@0: $quoted = $this->parseQuoted($line); Chris@0: if ($quoted === FALSE) { Chris@0: // This string must be quoted. Chris@17: $this->errors[] = new FormattableMarkup('The translation stream %uri contains an error: string continuation expected on line %line.', $log_vars); Chris@0: return FALSE; Chris@0: } Chris@0: Chris@0: // Append the string to the current item. Chris@17: if (($this->context == 'MSGID') || ($this->context == 'MSGID_PLURAL')) { Chris@17: if (is_array($this->currentItem['msgid'])) { Chris@0: // Add string to last array element for plural sources. Chris@17: $last_index = count($this->currentItem['msgid']) - 1; Chris@17: $this->currentItem['msgid'][$last_index] .= $quoted; Chris@0: } Chris@0: else { Chris@0: // Singular source, just append the string. Chris@17: $this->currentItem['msgid'] .= $quoted; Chris@0: } Chris@0: } Chris@17: elseif ($this->context == 'MSGCTXT') { Chris@0: // Multiline context name. Chris@17: $this->currentItem['msgctxt'] .= $quoted; Chris@0: } Chris@17: elseif ($this->context == 'MSGSTR') { Chris@0: // Multiline translation string. Chris@17: $this->currentItem['msgstr'] .= $quoted; Chris@0: } Chris@17: elseif ($this->context == 'MSGSTR_ARR') { Chris@0: // Multiline plural translation string. Chris@17: $this->currentItem['msgstr'][$this->currentPluralIndex] .= $quoted; Chris@0: } Chris@0: else { Chris@0: // No valid context to append to. Chris@17: $this->errors[] = new FormattableMarkup('The translation stream %uri contains an error: unexpected string on line %line.', $log_vars); Chris@0: return FALSE; Chris@0: } Chris@0: return; Chris@0: } Chris@0: } Chris@0: Chris@0: // Empty line read or EOF of PO stream, close out the last entry. Chris@17: if (($this->context == 'MSGSTR') || ($this->context == 'MSGSTR_ARR')) { Chris@17: $this->setItemFromArray($this->currentItem); Chris@17: $this->currentItem = []; Chris@0: } Chris@17: elseif ($this->context != 'COMMENT') { Chris@17: $this->errors[] = new FormattableMarkup('The translation stream %uri ended unexpectedly at line %line.', $log_vars); Chris@0: return FALSE; Chris@0: } Chris@14: Chris@14: return; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Store the parsed values as a PoItem object. Chris@0: */ Chris@0: public function setItemFromArray($value) { Chris@0: $plural = FALSE; Chris@0: Chris@0: $comments = ''; Chris@0: if (isset($value['#'])) { Chris@0: $comments = $this->shortenComments($value['#']); Chris@0: } Chris@0: Chris@0: if (is_array($value['msgstr'])) { Chris@0: // Sort plural variants by their form index. Chris@0: ksort($value['msgstr']); Chris@0: $plural = TRUE; Chris@0: } Chris@0: Chris@0: $item = new PoItem(); Chris@0: $item->setContext(isset($value['msgctxt']) ? $value['msgctxt'] : ''); Chris@0: $item->setSource($value['msgid']); Chris@0: $item->setTranslation($value['msgstr']); Chris@0: $item->setPlural($plural); Chris@0: $item->setComment($comments); Chris@17: $item->setLangcode($this->langcode); Chris@0: Chris@17: $this->lastItem = $item; Chris@0: Chris@17: $this->context = 'COMMENT'; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Parses a string in quotes. Chris@0: * Chris@0: * @param $string Chris@0: * A string specified with enclosing quotes. Chris@0: * Chris@0: * @return Chris@0: * The string parsed from inside the quotes. Chris@0: */ Chris@0: public function parseQuoted($string) { Chris@0: if (substr($string, 0, 1) != substr($string, -1, 1)) { Chris@0: // Start and end quotes must be the same. Chris@0: return FALSE; Chris@0: } Chris@0: $quote = substr($string, 0, 1); Chris@0: $string = substr($string, 1, -1); Chris@0: if ($quote == '"') { Chris@0: // Double quotes: strip slashes. Chris@0: return stripcslashes($string); Chris@0: } Chris@0: elseif ($quote == "'") { Chris@0: // Simple quote: return as-is. Chris@0: return $string; Chris@0: } Chris@0: else { Chris@0: // Unrecognized quote. Chris@0: return FALSE; Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * Generates a short, one-string version of the passed comment array. Chris@0: * Chris@0: * @param $comment Chris@0: * An array of strings containing a comment. Chris@0: * Chris@0: * @return Chris@0: * Short one-string version of the comment. Chris@0: */ Chris@0: private function shortenComments($comment) { Chris@0: $comm = ''; Chris@0: while (count($comment)) { Chris@0: $test = $comm . substr(array_shift($comment), 1) . ', '; Chris@0: if (strlen($comm) < 130) { Chris@0: $comm = $test; Chris@0: } Chris@0: else { Chris@0: break; Chris@0: } Chris@0: } Chris@0: return trim(substr($comm, 0, -2)); Chris@0: } Chris@0: Chris@0: }