Files
esun3d/app/code/Esun/MultiLocaleTranslation/Model/Provider/DeepL.php
T
2026-09-15 10:35:56 +08:00

299 lines
9.8 KiB
PHP

<?php
declare(strict_types=1);
namespace Esun\MultiLocaleTranslation\Model\Provider;
use Esun\MultiLocaleTranslation\Api\TranslationProviderInterface;
use Esun\MultiLocaleTranslation\Model\Html\TranslatableTextExtractor;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Encryption\EncryptorInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\HTTP\Client\CurlFactory;
class DeepL implements TranslationProviderInterface
{
private const FREE_API_URL = 'https://api-free.deepl.com/v2/translate';
private const PRO_API_URL = 'https://api.deepl.com/v2/translate';
private const XML_PATH_ENABLED = 'esun_multilocale_translation/deepl/enabled';
private const XML_PATH_API_URL = 'esun_multilocale_translation/deepl/api_url';
private const XML_PATH_AUTH_KEY = 'esun_multilocale_translation/deepl/auth_key';
private const XML_PATH_FORMALITY = 'esun_multilocale_translation/deepl/formality';
private const XML_PATH_TIMEOUT = 'esun_multilocale_translation/deepl/timeout';
private const MAX_BATCH_BYTES = 90000;
private const MAX_BATCH_ITEMS = 50;
private const MAX_HTML_FRAGMENT_BYTES = 30000;
public function __construct(
private readonly ScopeConfigInterface $scopeConfig,
private readonly EncryptorInterface $encryptor,
private readonly CurlFactory $curlFactory,
private readonly TranslatableTextExtractor $textExtractor
) {
}
public function translate(array $fields, string $sourceLanguage, string $targetLanguage): array
{
if (!$this->scopeConfig->isSetFlag(self::XML_PATH_ENABLED)) {
throw new LocalizedException(__('DeepL automatic translation is disabled in configuration.'));
}
$authKey = $this->getAuthKey();
if ($authKey === '') {
throw new LocalizedException(__('DeepL Auth Key is not configured.'));
}
$textFields = [];
$htmlFields = [];
$placeholderMaps = [];
foreach ($fields as $code => $field) {
if (!is_array($field)) {
continue;
}
$value = (string)($field['value'] ?? '');
if ($value === '') {
continue;
}
$format = !empty($field['is_html']) ? 'html' : 'text';
[$preparedValue, $placeholderMaps[(string)$code]] = $this->protectMagentoDirectives($value);
if ($format === 'html') {
$htmlFields[(string)$code] = $preparedValue;
} else {
$textFields[(string)$code] = $preparedValue;
}
}
$translated = [];
foreach ($this->createBatches($textFields) as $batch) {
$translated += $this->translateBatch(
$batch,
$sourceLanguage,
$targetLanguage,
'text',
$authKey
);
}
foreach ($htmlFields as $code => $value) {
$translated[$code] = $this->translateHtml(
$value,
$sourceLanguage,
$targetLanguage,
$authKey
);
}
foreach ($translated as $code => $value) {
$translated[$code] = $this->restoreMagentoDirectives(
$value,
$placeholderMaps[$code] ?? [],
$code
);
}
return $translated;
}
private function translateHtml(
string $html,
string $sourceLanguage,
string $targetLanguage,
string $authKey
): string {
$fragments = $this->textExtractor->extract($html, self::MAX_HTML_FRAGMENT_BYTES);
if ($fragments === []) {
return $html;
}
$values = [];
foreach ($fragments as $index => $fragment) {
$values[(string)$index] = $fragment['text'];
}
$translations = [];
foreach ($this->createBatches($values) as $batch) {
$translations += $this->translateBatch(
$batch,
$sourceLanguage,
$targetLanguage,
'html',
$authKey
);
}
for ($index = count($fragments) - 1; $index >= 0; $index--) {
$fragment = $fragments[$index];
$translation = $translations[(string)$index] ?? null;
if ($translation === null) {
throw new LocalizedException(__('DeepL did not return every HTML text fragment. Nothing was saved.'));
}
$html = substr($html, 0, $fragment['offset'])
. $translation
. substr($html, $fragment['offset'] + $fragment['length']);
}
return $html;
}
private function getAuthKey(): string
{
$encrypted = trim((string)$this->scopeConfig->getValue(self::XML_PATH_AUTH_KEY));
if ($encrypted === '') {
return '';
}
try {
return trim((string)$this->encryptor->decrypt($encrypted));
} catch (\Throwable) {
return '';
}
}
/**
* @param array<string, string> $values
* @return array<int, array<string, string>>
*/
private function createBatches(array $values): array
{
$batches = [];
$batch = [];
$bytes = 0;
foreach ($values as $code => $value) {
$valueBytes = strlen($value);
if ($valueBytes > self::MAX_BATCH_BYTES) {
throw new LocalizedException(__('Field "%1" is too large for DeepL translation.', $code));
}
if ($batch !== [] && ($bytes + $valueBytes > self::MAX_BATCH_BYTES
|| count($batch) >= self::MAX_BATCH_ITEMS)
) {
$batches[] = $batch;
$batch = [];
$bytes = 0;
}
$batch[$code] = $value;
$bytes += $valueBytes;
}
if ($batch !== []) {
$batches[] = $batch;
}
return $batches;
}
/**
* @param array<string, string> $batch
* @return array<string, string>
*/
private function translateBatch(
array $batch,
string $sourceLanguage,
string $targetLanguage,
string $format,
string $authKey
): array {
$requestData = [
'text' => array_values($batch),
'source_lang' => $sourceLanguage,
'target_lang' => $targetLanguage,
'preserve_formatting' => true,
'show_billed_characters' => true,
'model_type' => 'prefer_quality_optimized',
];
if ($format === 'html') {
$requestData['tag_handling'] = 'html';
$requestData['tag_handling_version'] = 'v2';
}
$formality = (string)$this->scopeConfig->getValue(self::XML_PATH_FORMALITY);
if (in_array($formality, ['prefer_more', 'prefer_less'], true)) {
$requestData['formality'] = $formality;
}
$client = $this->curlFactory->create();
$client->setTimeout(max(5, min(120, (int)$this->scopeConfig->getValue(self::XML_PATH_TIMEOUT))));
$client->setHeaders([
'Authorization' => 'DeepL-Auth-Key ' . $authKey,
'Content-Type' => 'application/json; charset=utf-8',
]);
$client->post(
$this->getApiUrl(),
json_encode(
$requestData,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
)
);
$response = json_decode((string)$client->getBody(), true);
if ($client->getStatus() < 200 || $client->getStatus() >= 300) {
$message = is_array($response) ? (string)($response['message'] ?? '') : '';
throw new LocalizedException(
__('DeepL translation failed (HTTP %1): %2',
$client->getStatus(),
$message ?: 'Unknown error')
);
}
$items = is_array($response) ? ($response['translations'] ?? null) : null;
if (!is_array($items) || count($items) !== count($batch)) {
throw new LocalizedException(__('DeepL returned an invalid translation response.'));
}
$result = [];
$codes = array_keys($batch);
foreach ($items as $index => $item) {
$result[$codes[$index]] = is_array($item) ? (string)($item['text'] ?? '') : '';
}
return $result;
}
private function getApiUrl(): string
{
$url = (string)$this->scopeConfig->getValue(self::XML_PATH_API_URL);
if ($url === self::PRO_API_URL) {
return self::PRO_API_URL;
}
return self::FREE_API_URL;
}
/**
* @return array{0:string,1:array<string,string>}
*/
private function protectMagentoDirectives(string $value): array
{
$placeholders = [];
$prepared = preg_replace_callback(
'/\{\{.*?\}\}/s',
static function (array $matches) use (&$placeholders): string {
$token = 'ESUNMLT' . strtoupper(bin2hex(random_bytes(10)));
$placeholders[$token] = $matches[0];
return $token;
},
$value
);
return [$prepared ?? $value, $placeholders];
}
/**
* @param array<string, string> $placeholders
*/
private function restoreMagentoDirectives(string $value, array $placeholders, string $fieldCode): string
{
foreach ($placeholders as $token => $directive) {
if (!str_contains($value, $token)) {
throw new LocalizedException(
__('DeepL changed a Magento directive placeholder in field "%1". Nothing was saved.', $fieldCode)
);
}
$value = str_replace($token, $directive, $value);
}
return $value;
}
}