372 lines
16 KiB
PHP
372 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/*
|
|
* This file is part of the TYPO3 CMS project.
|
|
*
|
|
* It is free software; you can redistribute it and/or modify it under
|
|
* the terms of the GNU General Public License, either version 2
|
|
* of the License, or any later version.
|
|
*
|
|
* For the full copyright and license information, please read the
|
|
* LICENSE.txt file that was distributed with this source code.
|
|
*
|
|
* The TYPO3 project - inspiring people to share!
|
|
*/
|
|
|
|
namespace TYPO3\CMS\Core\Localization\Loader;
|
|
|
|
use Symfony\Component\Translation\Exception\InvalidResourceException;
|
|
use Symfony\Component\Translation\Loader\LoaderInterface;
|
|
use Symfony\Component\Translation\MessageCatalogue;
|
|
use TYPO3\CMS\Core\Localization\Exception\InvalidXmlFileException;
|
|
|
|
/**
|
|
* TYPO3-specific XLIFF file loader that implements Symfony's LoaderInterface
|
|
* while maintaining TYPO3's specific features like approval states and plurals.
|
|
*
|
|
* This loader only converts ONE SINGLE FILE. It is not responsible for detecting the file location of
|
|
* translation! This is done - currently - by the LocalizationFactory and should be moved to a dedicated class.
|
|
*
|
|
* This loader expects that the source is always english, and the translation is something else.
|
|
*
|
|
* @internal This class is not part of the TYPO3 Core API.
|
|
*/
|
|
final readonly class XliffLoader implements LoaderInterface
|
|
{
|
|
public function load(mixed $resource, string $locale, string $domain = 'messages'): MessageCatalogue
|
|
{
|
|
if (!is_string($resource)) {
|
|
throw new InvalidXmlFileException('XliffLoader only accepts string types (file names, XML content).', 1757588428);
|
|
}
|
|
|
|
if (!$this->isXmlString($resource)) {
|
|
if (!file_exists($resource)) {
|
|
throw new InvalidXmlFileException('File "' . $resource . '" not found.', 1757588429);
|
|
}
|
|
|
|
if (!is_file($resource)) {
|
|
throw new InvalidResourceException('The given resource is neither a file nor an XLIFF string "' . $resource . '".', 1757588430);
|
|
}
|
|
}
|
|
|
|
$rootXmlNode = $this->convertResourceToXml($resource);
|
|
return $this->parseXliffFromRoot($rootXmlNode, $locale, $domain);
|
|
}
|
|
|
|
/**
|
|
* Parse XLIFF file and return TYPO3-compatible data structure
|
|
*/
|
|
private function convertResourceToXml(string $resource): \SimpleXMLElement
|
|
{
|
|
if ($this->isXmlString($resource)) {
|
|
$xmlContent = $resource;
|
|
} else {
|
|
$xmlContent = @file_get_contents($resource);
|
|
if ($xmlContent === false) {
|
|
throw new InvalidXmlFileException('The path provided does not point to an existing and accessible file.', 1757537341);
|
|
}
|
|
}
|
|
$rootXmlNode = @simplexml_load_string($xmlContent, \SimpleXMLElement::class, LIBXML_NOWARNING);
|
|
if ($rootXmlNode === false) {
|
|
$xmlError = libxml_get_last_error();
|
|
throw new InvalidXmlFileException(
|
|
'The path provided does not point to an existing and accessible well-formed XML file. Reason: ' . $xmlError->message . ' in ' . $resource . ', line ' . $xmlError->line,
|
|
1757537342
|
|
);
|
|
}
|
|
return $rootXmlNode;
|
|
}
|
|
|
|
/**
|
|
* Parse XLIFF content from root element and build a catalogue
|
|
*/
|
|
private function parseXliffFromRoot(\SimpleXMLElement $root, string $locale, string $domain): MessageCatalogue
|
|
{
|
|
$catalogue = new MessageCatalogue($locale);
|
|
$version = $this->getXliffVersion($root);
|
|
|
|
if ($version === '2.0') {
|
|
$this->parseXliff2($root, $catalogue, $domain);
|
|
} else {
|
|
// Default to XLIFF 1.2 parsing
|
|
$this->parseXliff1($root, $catalogue, $domain);
|
|
}
|
|
|
|
return $catalogue;
|
|
}
|
|
|
|
/**
|
|
* Detect XLIFF version from the root element
|
|
*/
|
|
private function getXliffVersion(\SimpleXMLElement $root): string
|
|
{
|
|
$namespaces = $root->getNamespaces(true);
|
|
|
|
// Check if XLIFF 2.x namespace is present (matches 2.0, 2.1, 2.2, etc.)
|
|
foreach ($namespaces as $namespace) {
|
|
if (str_starts_with($namespace, 'urn:oasis:names:tc:xliff:document:2.')) {
|
|
return '2.0';
|
|
}
|
|
}
|
|
|
|
// Check version attribute
|
|
$version = (string)$root['version'];
|
|
if (str_starts_with($version, '2.')) {
|
|
return '2.0';
|
|
}
|
|
|
|
// Default to 1.2
|
|
return '1.2';
|
|
}
|
|
|
|
/**
|
|
* Parse XLIFF 1.2 format
|
|
*/
|
|
private function parseXliff1(\SimpleXMLElement $root, MessageCatalogue $catalogue, string $domain): void
|
|
{
|
|
$fileTag = $root->file;
|
|
$isDefaultLanguage = !isset($fileTag['target-language']); // Default language from XLIFF template (no target element)
|
|
$bodyOfFileTag = $root->file->body;
|
|
$requireApprovedLocalizations = (bool)($GLOBALS['TYPO3_CONF_VARS']['LANG']['requireApprovedLocalizations'] ?? true);
|
|
|
|
if ($bodyOfFileTag instanceof \SimpleXMLElement) {
|
|
foreach ($bodyOfFileTag->children() as $translationElement) {
|
|
$deprecated = false;
|
|
/** @var \SimpleXMLElement $translationElement */
|
|
if ($translationElement->getName() === 'trans-unit' && !isset($translationElement['restype'])) {
|
|
// Regular translation unit
|
|
$id = (string)$translationElement['id'];
|
|
$deprecated = isset($translationElement['x-unused-since']);
|
|
if ($isDefaultLanguage) {
|
|
// Default language from XLIFF template (no target element)
|
|
$sourceElement = $translationElement->source;
|
|
$translation = $this->extractText($sourceElement, $translationElement);
|
|
$catalogue->set($id . ($deprecated ? '.x-unused' : ''), $translation, $domain);
|
|
} else {
|
|
$approved = (string)($translationElement['approved'] ?? 'yes');
|
|
if (!$requireApprovedLocalizations || $approved === 'yes') {
|
|
$catalogue->set($id . ($deprecated ? '.x-unused' : ''), $this->extractText($translationElement->target, $translationElement), $domain);
|
|
}
|
|
}
|
|
} elseif ($translationElement->getName() === 'group' && isset($translationElement['restype']) && (string)$translationElement['restype'] === 'x-gettext-plurals') {
|
|
// Translation with plural forms
|
|
$parsedTranslationElement = [];
|
|
foreach ($translationElement->children() as $translationPluralForm) {
|
|
/** @var \SimpleXMLElement $translationPluralForm */
|
|
if ($translationPluralForm->getName() === 'trans-unit') {
|
|
$deprecated = isset($translationPluralForm['x-unused-since']);
|
|
// Extract plural form index from ID like "1[0]", "1[1]"
|
|
$formIndex = substr((string)$translationPluralForm['id'], strpos((string)$translationPluralForm['id'], '[') + 1, -1);
|
|
if ($isDefaultLanguage) {
|
|
// Default language from XLIFF template (no target element)
|
|
$parsedTranslationElement[(int)$formIndex] = $this->extractText($translationPluralForm->source, $translationPluralForm);
|
|
} else {
|
|
$approved = (string)($translationPluralForm['approved'] ?? 'yes');
|
|
if (!$requireApprovedLocalizations || $approved === 'yes') {
|
|
$parsedTranslationElement[(int)$formIndex] = $this->extractText($translationPluralForm->target, $translationPluralForm);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if ($parsedTranslationElement !== []) {
|
|
if (isset($translationElement['id'])) {
|
|
$id = (string)$translationElement['id'];
|
|
} else {
|
|
$id = (string)$translationElement->{'trans-unit'}[0]['id'];
|
|
$id = substr($id, 0, (int)strpos($id, '['));
|
|
}
|
|
// Handle plurals - Symfony uses ICU format
|
|
$catalogue->set($id . ($deprecated ? '.x-unused' : ''), $this->convertToIcuPlural(array_values($parsedTranslationElement)), $domain);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse XLIFF 2.0 format
|
|
*/
|
|
private function parseXliff2(\SimpleXMLElement $root, MessageCatalogue $catalogue, string $domain): void
|
|
{
|
|
$requireApprovedLocalizations = (bool)($GLOBALS['TYPO3_CONF_VARS']['LANG']['requireApprovedLocalizations'] ?? true);
|
|
|
|
$ns = $root->getDocNamespaces();
|
|
$ns = reset($ns) ?: 'urn:oasis:names:tc:xliff:document:2.0';
|
|
// Register the XLIFF 2.0 namespace
|
|
$root->registerXPathNamespace('xliff', $ns);
|
|
$isDefaultLanguage = !isset($root['trgLang']); // Default language from XLIFF template (no target element)
|
|
|
|
// Get all file elements
|
|
$files = $root->xpath('//xliff:file');
|
|
if ($files === false) {
|
|
return;
|
|
}
|
|
|
|
foreach ($files as $file) {
|
|
$file->registerXPathNamespace('xliff', $ns);
|
|
|
|
// Get all unit elements within this file
|
|
$units = $file->xpath('.//xliff:unit');
|
|
if ($units === false) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($units as $unit) {
|
|
$unit->registerXPathNamespace('xliff', $ns);
|
|
$unitId = (string)$unit['id'];
|
|
|
|
// Check if this is a plural unit (contains multiple segments)
|
|
$segments = $unit->xpath('.//xliff:segment');
|
|
if ($segments === false) {
|
|
continue;
|
|
}
|
|
|
|
if (count($segments) === 1) {
|
|
// Regular translation unit
|
|
$segment = $segments[0];
|
|
$segment->registerXPathNamespace('xliff', $ns);
|
|
|
|
$source = $segment->xpath('.//xliff:source');
|
|
$target = $segment->xpath('.//xliff:target');
|
|
|
|
$deprecated = ((string)($segment['subState'] ?? '')) === 'deprecated';
|
|
|
|
if ($isDefaultLanguage) {
|
|
// Default language from XLIFF template (no target element)
|
|
$translation = $this->extractText($source[0], $segment);
|
|
$catalogue->set($unitId . ($deprecated ? '.x-unused' : ''), $translation, $domain);
|
|
} else {
|
|
// Check approval state (XLIFF 2.0 uses 'state' attribute on segment)
|
|
$approved = 'yes';
|
|
if (isset($segment['state'])) {
|
|
$state = (string)$segment['state'];
|
|
// XLIFF 2.0 states: initial, translated, reviewed, final
|
|
// We consider 'final' as approved, others depend on config
|
|
if ($state === 'initial' || $state === 'translated') {
|
|
$approved = 'no';
|
|
}
|
|
}
|
|
|
|
if (!$requireApprovedLocalizations || $approved === 'yes') {
|
|
if ($target !== false && isset($target[0])) {
|
|
$catalogue->set($unitId . ($deprecated ? '.x-unused' : ''), $this->extractText($target[0], $segment), $domain);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Plural forms (multiple segments)
|
|
$parsedTranslationElement = [];
|
|
$formIndex = 0;
|
|
$deprecated = false;
|
|
|
|
foreach ($segments as $segment) {
|
|
$segment->registerXPathNamespace('xliff', $ns);
|
|
|
|
$source = $segment->xpath('.//xliff:source');
|
|
$target = $segment->xpath('.//xliff:target');
|
|
$deprecated = $deprecated || ((string)($segment['subState'] ?? '')) === 'deprecated';
|
|
|
|
if ($isDefaultLanguage) {
|
|
// Default language from XLIFF template (no target element)
|
|
$parsedTranslationElement[$formIndex] = $this->extractText($source[0], $segment);
|
|
} else {
|
|
$approved = 'yes';
|
|
if (isset($segment['state'])) {
|
|
$state = (string)$segment['state'];
|
|
if ($state === 'initial' || $state === 'translated') {
|
|
$approved = 'no';
|
|
}
|
|
}
|
|
|
|
if (!$requireApprovedLocalizations || $approved === 'yes') {
|
|
if ($target !== false && isset($target[0])) {
|
|
$parsedTranslationElement[$formIndex] = $this->extractText($target[0], $segment);
|
|
}
|
|
}
|
|
}
|
|
$formIndex++;
|
|
}
|
|
|
|
if ($parsedTranslationElement !== []) {
|
|
$catalogue->set($unitId . ($deprecated ? '.x-unused' : ''), $this->convertToIcuPlural(array_values($parsedTranslationElement)), $domain);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert plural forms to ICU plural format
|
|
* This is a simplified conversion - could be enhanced based on actual usage
|
|
*/
|
|
private function convertToIcuPlural(array $pluralValues): string
|
|
{
|
|
if (count($pluralValues) === 1) {
|
|
return $pluralValues[0];
|
|
}
|
|
|
|
// Simple mapping: [0] = one, [1] = other
|
|
$icuFormat = '';
|
|
if (isset($pluralValues[0])) {
|
|
$icuFormat .= '{0, plural, one {' . $pluralValues[0] . '}';
|
|
}
|
|
if (isset($pluralValues[1])) {
|
|
$icuFormat .= ' other {' . $pluralValues[1] . '}';
|
|
}
|
|
$icuFormat .= '}';
|
|
|
|
return $icuFormat;
|
|
}
|
|
|
|
private function isXmlString(string $resource): bool
|
|
{
|
|
return str_starts_with($resource, '<?xml');
|
|
}
|
|
|
|
/**
|
|
* Extract text content from an XML element, respecting xml:space attribute.
|
|
* Per XML spec: xml:space="preserve" keeps whitespace as-is,
|
|
* otherwise whitespace should be normalized (multiple spaces/newlines collapsed to single space).
|
|
* see https://www.w3.org/TR/xml/#sec-white-space
|
|
*/
|
|
private function extractText(\SimpleXMLElement $element, ?\SimpleXMLElement $parentElement = null): string
|
|
{
|
|
$text = (string)$element;
|
|
|
|
// Check xml:space on the element itself or parent (trans-unit/unit/segment)
|
|
$xmlSpace = $this->getXmlSpaceAttribute($element, $parentElement);
|
|
|
|
if ($xmlSpace !== 'preserve') {
|
|
// Normalize whitespace: collapse multiple whitespace characters to single space
|
|
$text = preg_replace('/\s+/', ' ', $text);
|
|
$text = trim($text);
|
|
}
|
|
|
|
return $text;
|
|
}
|
|
|
|
/**
|
|
* Get the xml:space attribute value, checking element and parent (attribute is inherited per XML spec).
|
|
*/
|
|
private function getXmlSpaceAttribute(\SimpleXMLElement $element, ?\SimpleXMLElement $parent): string
|
|
{
|
|
// Check element's xml:space attribute
|
|
$attributes = $element->attributes('xml', true);
|
|
if (isset($attributes['space'])) {
|
|
return (string)$attributes['space'];
|
|
}
|
|
|
|
// Check parent's xml:space attribute (inherited per XML spec)
|
|
if ($parent !== null) {
|
|
$parentAttributes = $parent->attributes('xml', true);
|
|
if (isset($parentAttributes['space'])) {
|
|
return (string)$parentAttributes['space'];
|
|
}
|
|
}
|
|
|
|
return 'default';
|
|
}
|
|
}
|