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 $values * @return array> */ 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 $batch * @return array */ 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} */ 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 $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; } }