diff --git a/src/Symfony/Component/Translation/CHANGELOG.md b/src/Symfony/Component/Translation/CHANGELOG.md index 41f99cb4e9..f554079693 100644 --- a/src/Symfony/Component/Translation/CHANGELOG.md +++ b/src/Symfony/Component/Translation/CHANGELOG.md @@ -7,6 +7,7 @@ CHANGELOG * deprecated Translator::getMessages(), rely on TranslatorBagInterface::getCatalogue() instead. * added option `json_encoding` to JsonFileDumper * added options `as_tree`, `inline` to YamlFileDumper + * added support for XLIFF 2.0. * added support for XLIFF target and tool attributes. * added message parameters to DataCollectorTranslator. * [DEPRECATION] The `DiffOperation` class has been deprecated and diff --git a/src/Symfony/Component/Translation/Dumper/XliffFileDumper.php b/src/Symfony/Component/Translation/Dumper/XliffFileDumper.php index 668c90d646..d8afa554a2 100644 --- a/src/Symfony/Component/Translation/Dumper/XliffFileDumper.php +++ b/src/Symfony/Component/Translation/Dumper/XliffFileDumper.php @@ -25,12 +25,45 @@ class XliffFileDumper extends FileDumper */ protected function formatCatalogue(MessageCatalogue $messages, $domain, array $options = array()) { + $xliffVersion = '1.2'; + if (array_key_exists('xliff_version', $options)) { + $xliffVersion = $options['xliff_version']; + } + if (array_key_exists('default_locale', $options)) { $defaultLocale = $options['default_locale']; } else { $defaultLocale = \Locale::getDefault(); } + if ('1.2' === $xliffVersion) { + return $this->dumpXliff1($defaultLocale, $messages, $domain, $options); + } + if ('2.0' === $xliffVersion) { + return $this->dumpXliff2($defaultLocale, $messages, $domain, $options); + } + + throw new \InvalidArgumentException(sprintf('No support implemented for dumping XLIFF version "%s".', $xliffVersion)); + } + + /** + * {@inheritdoc} + */ + protected function format(MessageCatalogue $messages, $domain) + { + return $this->formatCatalogue($messages, $domain); + } + + /** + * {@inheritdoc} + */ + protected function getExtension() + { + return 'xlf'; + } + + private function dumpXliff1($defaultLocale, MessageCatalogue $messages, $domain, array $options = array()) + { $toolInfo = array('tool-id' => 'symfony', 'tool-name' => 'Symfony'); if (array_key_exists('tool_info', $options)) { $toolInfo = array_merge($toolInfo, $options['tool_info']); @@ -103,20 +136,46 @@ class XliffFileDumper extends FileDumper return $dom->saveXML(); } - /** - * {@inheritdoc} - */ - protected function format(MessageCatalogue $messages, $domain) + private function dumpXliff2($defaultLocale, MessageCatalogue $messages, $domain, array $options = array()) { - return $this->formatCatalogue($messages, $domain); - } + $dom = new \DOMDocument('1.0', 'utf-8'); + $dom->formatOutput = true; - /** - * {@inheritdoc} - */ - protected function getExtension() - { - return 'xlf'; + $xliff = $dom->appendChild($dom->createElement('xliff')); + $xliff->setAttribute('xmlns', 'urn:oasis:names:tc:xliff:document:2.0'); + $xliff->setAttribute('version', '2.0'); + $xliff->setAttribute('srcLang', str_replace('_', '-', $defaultLocale)); + $xliff->setAttribute('trgLang', str_replace('_', '-', $messages->getLocale())); + + $xliffFile = $xliff->appendChild($dom->createElement('file')); + $xliffFile->setAttribute('id', $domain.'.'.$messages->getLocale()); + + foreach ($messages->all($domain) as $source => $target) { + $translation = $dom->createElement('unit'); + $translation->setAttribute('id', md5($source)); + + $segment = $translation->appendChild($dom->createElement('segment')); + + $s = $segment->appendChild($dom->createElement('source')); + $s->appendChild($dom->createTextNode($source)); + + // Does the target contain characters requiring a CDATA section? + $text = 1 === preg_match('/[&<>]/', $target) ? $dom->createCDATASection($target) : $dom->createTextNode($target); + + $targetElement = $dom->createElement('target'); + $metadata = $messages->getMetadata($source, $domain); + if ($this->hasMetadataArrayInfo('target-attributes', $metadata)) { + foreach ($metadata['target-attributes'] as $name => $value) { + $targetElement->setAttribute($name, $value); + } + } + $t = $segment->appendChild($targetElement); + $t->appendChild($text); + + $xliffFile->appendChild($translation); + } + + return $dom->saveXML(); } /** diff --git a/src/Symfony/Component/Translation/Loader/XliffFileLoader.php b/src/Symfony/Component/Translation/Loader/XliffFileLoader.php index 67c96eb168..4b5940cd42 100644 --- a/src/Symfony/Component/Translation/Loader/XliffFileLoader.php +++ b/src/Symfony/Component/Translation/Loader/XliffFileLoader.php @@ -41,10 +41,49 @@ class XliffFileLoader implements LoaderInterface throw new NotFoundResourceException(sprintf('File "%s" not found.', $resource)); } - list($xml, $encoding) = $this->parseFile($resource); - $xml->registerXPathNamespace('xliff', 'urn:oasis:names:tc:xliff:document:1.2'); - $catalogue = new MessageCatalogue($locale); + $this->extract($resource, $catalogue, $domain); + + if (class_exists('Symfony\Component\Config\Resource\FileResource')) { + $catalogue->addResource(new FileResource($resource)); + } + + return $catalogue; + } + + private function extract($resource, MessageCatalogue $catalogue, $domain) + { + try { + $dom = XmlUtils::loadFile($resource); + } catch (\InvalidArgumentException $e) { + throw new InvalidResourceException(sprintf('Unable to load "%s": %s', $resource, $e->getMessage()), $e->getCode(), $e); + } + + $xliffVersion = $this->getVersionNumber($dom); + $this->validateSchema($xliffVersion, $dom, $this->getSchema($xliffVersion)); + + if ('1.2' === $xliffVersion) { + $this->extractXliff1($dom, $catalogue, $domain); + } + + if ('2.0' === $xliffVersion) { + $this->extractXliff2($dom, $catalogue, $domain); + } + } + + /** + * Extract messages and metadata from DOMDocument into a MessageCatalogue. + * + * @param \DOMDocument $dom Source to extract messages and metadata + * @param MessageCatalogue $catalogue Catalogue where we'll collect messages and metadata + * @param string $domain The domain + */ + private function extractXliff1(\DOMDocument $dom, MessageCatalogue $catalogue, $domain) + { + $xml = simplexml_import_dom($dom); + $encoding = strtoupper($dom->encoding); + + $xml->registerXPathNamespace('xliff', 'urn:oasis:names:tc:xliff:document:1.2'); foreach ($xml->xpath('//xliff:trans-unit') as $translation) { $attributes = $translation->attributes(); @@ -64,17 +103,47 @@ class XliffFileLoader implements LoaderInterface $metadata['notes'] = $notes; } if (isset($translation->target) && $translation->target->attributes()) { - $metadata['target-attributes'] = $translation->target->attributes(); + $metadata['target-attributes'] = array(); + foreach ($translation->target->attributes() as $key => $value) { + $metadata['target-attributes'][$key] = (string) $value; + } } $catalogue->setMetadata((string) $source, $metadata, $domain); } + } - if (class_exists('Symfony\Component\Config\Resource\FileResource')) { - $catalogue->addResource(new FileResource($resource)); + /** + * @param \DOMDocument $dom + * @param MessageCatalogue $catalogue + * @param string $domain + */ + private function extractXliff2(\DOMDocument $dom, MessageCatalogue $catalogue, $domain) + { + $xml = simplexml_import_dom($dom); + $encoding = strtoupper($dom->encoding); + + $xml->registerXPathNamespace('xliff', 'urn:oasis:names:tc:xliff:document:2.0'); + + foreach ($xml->xpath('//xliff:unit/xliff:segment') as $segment) { + $source = $segment->source; + + // If the xlf file has another encoding specified, try to convert it because + // simple_xml will always return utf-8 encoded values + $target = $this->utf8ToCharset((string) (isset($segment->target) ? $segment->target : $source), $encoding); + + $catalogue->set((string) $source, $target, $domain); + + $metadata = array(); + if (isset($segment->target) && $segment->target->attributes()) { + $metadata['target-attributes'] = array(); + foreach ($segment->target->attributes() as $key => $value) { + $metadata['target-attributes'][$key] = (string) $value; + } + } + + $catalogue->setMetadata((string) $source, $metadata, $domain); } - - return $catalogue; } /** @@ -103,42 +172,17 @@ class XliffFileLoader implements LoaderInterface } /** - * Validates and parses the given file into a SimpleXMLElement. - * - * @param string $file - * - * @throws \RuntimeException - * - * @return \SimpleXMLElement + * @param string $file + * @param \DOMDocument $dom + * @param string $schema source of the schema * * @throws InvalidResourceException */ - private function parseFile($file) + private function validateSchema($file, \DOMDocument $dom, $schema) { - try { - $dom = XmlUtils::loadFile($file); - } catch (\InvalidArgumentException $e) { - throw new InvalidResourceException(sprintf('Unable to load "%s": %s', $file, $e->getMessage()), $e->getCode(), $e); - } - $internalErrors = libxml_use_internal_errors(true); - $location = str_replace('\\', '/', __DIR__).'/schema/dic/xliff-core/xml.xsd'; - $parts = explode('/', $location); - if (0 === stripos($location, 'phar://')) { - $tmpfile = tempnam(sys_get_temp_dir(), 'sf2'); - if ($tmpfile) { - copy($location, $tmpfile); - $parts = explode('/', str_replace('\\', '/', $tmpfile)); - } - } - $drive = '\\' === DIRECTORY_SEPARATOR ? array_shift($parts).'/' : ''; - $location = 'file:///'.$drive.implode('/', array_map('rawurlencode', $parts)); - - $source = file_get_contents(__DIR__.'/schema/dic/xliff-core/xliff-core-1.2-strict.xsd'); - $source = str_replace('http://www.w3.org/2001/xml.xsd', $location, $source); - - if (!@$dom->schemaValidateSource($source)) { + if (!@$dom->schemaValidateSource($schema)) { throw new InvalidResourceException(sprintf('Invalid resource provided: "%s"; Errors: %s', $file, implode("\n", $this->getXmlErrors($internalErrors)))); } @@ -146,8 +190,46 @@ class XliffFileLoader implements LoaderInterface libxml_clear_errors(); libxml_use_internal_errors($internalErrors); + } - return array(simplexml_import_dom($dom), strtoupper($dom->encoding)); + private function getSchema($xliffVersion) + { + if ('1.2' === $xliffVersion) { + $schemaSource = file_get_contents(__DIR__.'/schema/dic/xliff-core/xliff-core-1.2-strict.xsd'); + $xmlUri = 'http://www.w3.org/2001/xml.xsd'; + } elseif ('2.0' === $xliffVersion) { + $schemaSource = file_get_contents(__DIR__.'/schema/dic/xliff-core/xliff-core-2.0.xsd'); + $xmlUri = 'informativeCopiesOf3rdPartySchemas/w3c/xml.xsd'; + } else { + throw new \InvalidArgumentException(sprintf('No support implemented for loading XLIFF version "%s".', $xliffVersion)); + } + + return $this->fixXmlLocation($schemaSource, $xmlUri); + } + + /** + * Internally changes the URI of a dependent xsd to be loaded locally. + * + * @param string $schemaSource Current content of schema file + * @param string $xmlUri External URI of XML to convert to local + * + * @return string + */ + private function fixXmlLocation($schemaSource, $xmlUri) + { + $newPath = str_replace('\\', '/', __DIR__).'/schema/dic/xliff-core/xml.xsd'; + $parts = explode('/', $newPath); + if (0 === stripos($newPath, 'phar://')) { + $tmpfile = tempnam(sys_get_temp_dir(), 'sf2'); + if ($tmpfile) { + copy($newPath, $tmpfile); + $parts = explode('/', str_replace('\\', '/', $tmpfile)); + } + } + $drive = '\\' === DIRECTORY_SEPARATOR ? array_shift($parts).'/' : ''; + $newPath = 'file:///'.$drive.implode('/', array_map('rawurlencode', $parts)); + + return str_replace($xmlUri, $newPath, $schemaSource); } /** @@ -178,6 +260,39 @@ class XliffFileLoader implements LoaderInterface } /** + * Gets xliff file version based on the root "version" attribute. + * Defaults to 1.2 for backwards compatibility. + * + * @param \DOMDocument $dom + * + * @throws \InvalidArgumentException + * + * @return string + */ + private function getVersionNumber(\DOMDocument $dom) + { + /** @var \DOMNode $xliff */ + foreach ($dom->getElementsByTagName('xliff') as $xliff) { + $version = $xliff->attributes->getNamedItem('version'); + if ($version) { + return $version->nodeValue; + } + + $namespace = $xliff->attributes->getNamedItem('xmlns'); + if ($namespace) { + if (substr_compare('urn:oasis:names:tc:xliff:document:', $namespace->nodeValue, 0, 34) !== 0) { + throw new \InvalidArgumentException(sprintf('Not a valid XLIFF namespace "%s"', $namespace)); + } + + return substr($namespace, 34); + } + } + + // Falls back to v1.2 + return '1.2'; + } + + /* * @param \SimpleXMLElement|null $noteElement * @param string|null $encoding * diff --git a/src/Symfony/Component/Translation/Loader/schema/dic/xliff-core/xliff-core-2.0.xsd b/src/Symfony/Component/Translation/Loader/schema/dic/xliff-core/xliff-core-2.0.xsd new file mode 100644 index 0000000000..f429bb0f37 --- /dev/null +++ b/src/Symfony/Component/Translation/Loader/schema/dic/xliff-core/xliff-core-2.0.xsd @@ -0,0 +1,411 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Component/Translation/Tests/Dumper/XliffFileDumperTest.php b/src/Symfony/Component/Translation/Tests/Dumper/XliffFileDumperTest.php index d2cbf4668d..1d7306266b 100644 --- a/src/Symfony/Component/Translation/Tests/Dumper/XliffFileDumperTest.php +++ b/src/Symfony/Component/Translation/Tests/Dumper/XliffFileDumperTest.php @@ -45,6 +45,27 @@ class XliffFileDumperTest extends \PHPUnit_Framework_TestCase unlink($this->tempDir.'/messages.en_US.xlf'); } + public function testDumpXliff2() + { + $catalogue = new MessageCatalogue('en_US'); + $catalogue->add(array( + 'foo' => 'bar', + 'key' => '', + 'key.with.cdata' => ' & ', + )); + $catalogue->setMetadata('key', array('target-attributes' => array('order' => 1))); + + $dumper = new XliffFileDumper(); + $dumper->dump($catalogue, array('path' => $this->tempDir, 'default_locale' => 'fr_FR', 'xliff_version' => '2.0')); + + $this->assertEquals( + file_get_contents(__DIR__.'/../fixtures/resources-2.0-clean.xlf'), + file_get_contents($this->tempDir.'/messages.en_US.xlf') + ); + + unlink($this->tempDir.'/messages.en_US.xlf'); + } + public function testDumpWithCustomToolInfo() { $options = array( diff --git a/src/Symfony/Component/Translation/Tests/Loader/XliffFileLoaderTest.php b/src/Symfony/Component/Translation/Tests/Loader/XliffFileLoaderTest.php index a67af1a340..ea0da6d503 100644 --- a/src/Symfony/Component/Translation/Tests/Loader/XliffFileLoaderTest.php +++ b/src/Symfony/Component/Translation/Tests/Loader/XliffFileLoaderTest.php @@ -149,4 +149,22 @@ class XliffFileLoaderTest extends \PHPUnit_Framework_TestCase // message with empty target $this->assertEquals(array('notes' => array(array('content' => 'baz'), array('priority' => 2, 'from' => 'bar', 'content' => 'qux'))), $catalogue->getMetadata('key', 'domain1')); } + + public function testLoadVersion2() + { + $loader = new XliffFileLoader(); + $resource = __DIR__.'/../fixtures/resources-2.0.xlf'; + $catalogue = $loader->load($resource, 'en', 'domain1'); + + $this->assertEquals('en', $catalogue->getLocale()); + $this->assertEquals(array(new FileResource($resource)), $catalogue->getResources()); + $this->assertSame(array(), libxml_get_errors()); + + $domains = $catalogue->all(); + $this->assertCount(3, $domains['domain1']); + $this->assertContainsOnly('string', $catalogue->all('domain1')); + + // target attributes + $this->assertEquals(array('target-attributes' => array('order' => 1)), $catalogue->getMetadata('bar', 'domain1')); + } } diff --git a/src/Symfony/Component/Translation/Tests/fixtures/resources-2.0-clean.xlf b/src/Symfony/Component/Translation/Tests/fixtures/resources-2.0-clean.xlf new file mode 100644 index 0000000000..2efa155e65 --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/fixtures/resources-2.0-clean.xlf @@ -0,0 +1,23 @@ + + + + + + foo + bar + + + + + key + + + + + + key.with.cdata + & ]]> + + + + diff --git a/src/Symfony/Component/Translation/Tests/fixtures/resources-2.0.xlf b/src/Symfony/Component/Translation/Tests/fixtures/resources-2.0.xlf new file mode 100644 index 0000000000..166172a84d --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/fixtures/resources-2.0.xlf @@ -0,0 +1,25 @@ + + + + + + Quetzal + Quetzal + + + + + + foo + XLIFF 文書を編集、または処理 するアプリケーションです。 + + + + + bar + XLIFF データ・マネージャ + + + + +