├── VERSION ├── src ├── DeepLClientOptions.php ├── DeepLException.php ├── NotFoundException.php ├── AuthorizationException.php ├── GlossaryNotFoundException.php ├── TooManyRequestsException.php ├── QuotaExceededException.php ├── DocumentNotReadyException.php ├── InvalidContentException.php ├── AppInfo.php ├── DocumentDeminificationException.php ├── DocumentMinificationException.php ├── DocumentHandle.php ├── ConnectionException.php ├── DocumentTranslationException.php ├── UsageDetail.php ├── GlossaryLanguagePair.php ├── CustomInstruction.php ├── Language.php ├── RephraseTextResult.php ├── GlossaryEntries.php ├── TextResult.php ├── TranslateDocumentOptions.php ├── RephraseTextOptions.php ├── BackoffTimer.php ├── MultilingualGlossaryDictionaryInfo.php ├── ConfiguredRules.php ├── DocumentStatus.php ├── MultilingualGlossaryDictionaryEntries.php ├── MultilingualGlossaryInfo.php ├── Usage.php ├── GlossaryUtils.php ├── GlossaryInfo.php ├── StyleRuleInfo.php ├── TranslateTextOptions.php ├── TranslatorOptions.php ├── LanguageCode.php ├── DeepLClient.php ├── HttpClientWrapper.php ├── DocumentMinifier.php └── Translator.php ├── LICENSE └── composer.json /VERSION: -------------------------------------------------------------------------------- 1 | 1.16.0 2 | -------------------------------------------------------------------------------- /src/DeepLClientOptions.php: -------------------------------------------------------------------------------- 1 | getMessage(), $exception->getCode(), $exception); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/AppInfo.php: -------------------------------------------------------------------------------- 1 | appName = $appName; 22 | $this->appVersion = $appVersion; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/DocumentDeminificationException.php: -------------------------------------------------------------------------------- 1 | documentId = $documentId; 28 | $this->documentKey = $documentKey; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/ConnectionException.php: -------------------------------------------------------------------------------- 1 | shouldRetry = $shouldRetry; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/DocumentTranslationException.php: -------------------------------------------------------------------------------- 1 | handle = $handle; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/UsageDetail.php: -------------------------------------------------------------------------------- 1 | count = $count; 27 | $this->limit = $limit; 28 | } 29 | 30 | /** 31 | * @return bool True if the amount used has already reached or passed the allowable amount, otherwise false. 32 | */ 33 | public function limitReached(): bool 34 | { 35 | return $this->count >= $this->limit; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2022 DeepL SE (https://www.deepl.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/GlossaryLanguagePair.php: -------------------------------------------------------------------------------- 1 | sourceLang = $sourceLang; 30 | $this->targetLang = $targetLang; 31 | } 32 | 33 | public function __toString(): string 34 | { 35 | return "$this->sourceLang->$this->targetLang"; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/CustomInstruction.php: -------------------------------------------------------------------------------- 1 | label = $label; 29 | $this->prompt = $prompt; 30 | $this->sourceLanguage = $sourceLanguage; 31 | } 32 | 33 | /** 34 | * @throws InvalidContentException 35 | */ 36 | public static function fromJson(array $json): CustomInstruction 37 | { 38 | return new CustomInstruction( 39 | $json['label'], 40 | $json['prompt'], 41 | $json['source_language'] ?? null 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Language.php: -------------------------------------------------------------------------------- 1 | name = $name; 35 | $this->code = $code; 36 | $this->supportsFormality = $supportsFormality; 37 | } 38 | 39 | public function __toString(): string 40 | { 41 | return "$this->name ($this->code)"; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/RephraseTextResult.php: -------------------------------------------------------------------------------- 1 | text = $text; 40 | $this->detectedSourceLanguage = LanguageCode::standardizeLanguageCode($detectedSourceLanguage); 41 | $this->targetLanguage = LanguageCode::standardizeLanguageCode($targetLanguage); 42 | } 43 | 44 | public function __toString(): string 45 | { 46 | return $this->text; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/GlossaryEntries.php: -------------------------------------------------------------------------------- 1 | implEntries; 20 | } 21 | 22 | /** 23 | * @throws DeepLException 24 | */ 25 | public static function fromTsv(string $tsv): GlossaryEntries 26 | { 27 | return new GlossaryEntries(GlossaryUtils::fromTsv($tsv)); 28 | } 29 | 30 | /** 31 | * @throws DeepLException 32 | */ 33 | public static function fromEntries(array $entries): GlossaryEntries 34 | { 35 | GlossaryUtils::validateEntries($entries); 36 | return new GlossaryEntries($entries); 37 | } 38 | 39 | public function convertToTsv(): string 40 | { 41 | return GlossaryUtils::convertToTsv($this->implEntries); 42 | } 43 | 44 | /** 45 | * @param array $entries 46 | */ 47 | private function __construct(array $entries) 48 | { 49 | $this->implEntries = $entries; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/TextResult.php: -------------------------------------------------------------------------------- 1 | text = $text; 46 | $this->detectedSourceLang = LanguageCode::standardizeLanguageCode($detectedSourceLang); 47 | $this->billedCharacters = $billedCharacters; 48 | $this->modelTypeUsed = $modelTypeUsed; 49 | } 50 | 51 | public function __toString(): string 52 | { 53 | return $this->text; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deeplcom/deepl-php", 3 | "type": "library", 4 | "description": "Official DeepL API Client Library", 5 | "keywords": [ 6 | "deepl", 7 | "translation", 8 | "translator", 9 | "api" 10 | ], 11 | "require": { 12 | "php": ">=7.3.0", 13 | "psr/log": "^1.1 || ^2.0 || ^3.0", 14 | "psr/http-client": "^1.0", 15 | "php-http/discovery": "^1.18", 16 | "ext-json": "*", 17 | "ext-curl": "*", 18 | "ext-mbstring": "*", 19 | "psr/http-client-implementation": "*", 20 | "psr/http-factory-implementation": "*", 21 | "php-http/multipart-stream-builder": "^1.3" 22 | }, 23 | "require-dev": { 24 | "phpunit/phpunit": "^9", 25 | "ramsey/uuid": "^4.2", 26 | "squizlabs/php_codesniffer": "^3.3", 27 | "friendsofphp/php-cs-fixer": "^3", 28 | "php-mock/php-mock-phpunit": "^2.6", 29 | "guzzlehttp/guzzle": "^7.7.0" 30 | }, 31 | "license": "MIT", 32 | "autoload": { 33 | "psr-4": { 34 | "DeepL\\": "src/" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "DeepL\\": "tests/" 40 | } 41 | }, 42 | "authors": [ 43 | { 44 | "name": "DeepL SE", 45 | "email": "open-source@deepl.com" 46 | } 47 | ], 48 | "config": { 49 | "allow-plugins": { 50 | "php-http/discovery": false 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/TranslateDocumentOptions.php: -------------------------------------------------------------------------------- 1 | numRetries = 0; 26 | $this->backoff = self::BACKOFF_INITIAL; 27 | $this->deadline = microtime(true) + $this->backoff; 28 | } 29 | 30 | public function getNumRetries(): int 31 | { 32 | return $this->numRetries; 33 | } 34 | 35 | public function getTimeUntilDeadline(): float 36 | { 37 | $now = microtime(true); 38 | return max($this->deadline - $now, 0.0); 39 | } 40 | 41 | public function sleepUntilDeadline() 42 | { 43 | $timeUntilDeadline = $this->getTimeUntilDeadline(); 44 | // Note: usleep() with values larger than 1000000 (1 second) may not be supported by the operating system. 45 | if ($timeUntilDeadline > 1.0) { 46 | sleep(floor($timeUntilDeadline)); 47 | $timeUntilDeadline = $this->getTimeUntilDeadline(); 48 | } 49 | usleep(floor($timeUntilDeadline * 1e6)); 50 | 51 | // Apply multiplier to current backoff time 52 | $this->backoff = min($this->backoff * self::BACKOFF_MULTIPLIER, self::BACKOFF_MAX); 53 | 54 | // Get deadline by applying jitter as a proportion of backoff: 55 | // if jitter is 0.1, then multiply backoff by random value in [0.9, 1.1] 56 | $now = microtime(true); 57 | $this->deadline = $now + $this->backoff * (1 + self::BACKOFF_JITTER * (2 * (rand() / getrandmax()) - 1)); 58 | $this->numRetries++; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/MultilingualGlossaryDictionaryInfo.php: -------------------------------------------------------------------------------- 1 | sourceLang = $sourceLang; 32 | $this->targetLang = $targetLang; 33 | $this->entryCount = $entryCount; 34 | } 35 | 36 | /** 37 | * @throws InvalidContentException 38 | */ 39 | public static function parseJson(string $content): MultilingualGlossaryDictionaryInfo 40 | { 41 | try { 42 | $object = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); 43 | } catch (JsonException $exception) { 44 | throw new InvalidContentException($exception); 45 | } 46 | 47 | return self::parseObject($object); 48 | } 49 | 50 | /** 51 | * @throws InvalidContentException 52 | */ 53 | public static function parseList(array $list): array 54 | { 55 | $result = []; 56 | foreach ($list as $object) { 57 | $result[] = self::parseObject($object); 58 | } 59 | return $result; 60 | } 61 | 62 | /** 63 | * @throws \Exception 64 | */ 65 | private static function parseObject($object): MultilingualGlossaryDictionaryInfo 66 | { 67 | return new MultilingualGlossaryDictionaryInfo( 68 | $object['source_lang'] ?? null, 69 | $object['target_lang'] ?? null, 70 | $object['entry_count'] ?? null 71 | ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/ConfiguredRules.php: -------------------------------------------------------------------------------- 1 | |null Date and time formatting rules. */ 15 | public $datesAndTimes; 16 | 17 | /** @var array|null Text formatting rules. */ 18 | public $formatting; 19 | 20 | /** @var array|null Number formatting rules. */ 21 | public $numbers; 22 | 23 | /** @var array|null Punctuation rules. */ 24 | public $punctuation; 25 | 26 | /** @var array|null Spelling and grammar rules. */ 27 | public $spellingAndGrammar; 28 | 29 | /** @var array|null Style and tone rules. */ 30 | public $styleAndTone; 31 | 32 | /** @var array|null Vocabulary rules. */ 33 | public $vocabulary; 34 | 35 | public function __construct( 36 | ?array $datesAndTimes = null, 37 | ?array $formatting = null, 38 | ?array $numbers = null, 39 | ?array $punctuation = null, 40 | ?array $spellingAndGrammar = null, 41 | ?array $styleAndTone = null, 42 | ?array $vocabulary = null 43 | ) { 44 | $this->datesAndTimes = $datesAndTimes; 45 | $this->formatting = $formatting; 46 | $this->numbers = $numbers; 47 | $this->punctuation = $punctuation; 48 | $this->spellingAndGrammar = $spellingAndGrammar; 49 | $this->styleAndTone = $styleAndTone; 50 | $this->vocabulary = $vocabulary; 51 | } 52 | 53 | /** 54 | * @throws InvalidContentException 55 | */ 56 | public static function fromJson(?array $json): ?ConfiguredRules 57 | { 58 | if ($json === null) { 59 | return null; 60 | } 61 | 62 | return new ConfiguredRules( 63 | $json['dates_and_times'] ?? null, 64 | $json['formatting'] ?? null, 65 | $json['numbers'] ?? null, 66 | $json['punctuation'] ?? null, 67 | $json['spelling_and_grammar'] ?? null, 68 | $json['style_and_tone'] ?? null, 69 | $json['vocabulary'] ?? null 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/DocumentStatus.php: -------------------------------------------------------------------------------- 1 | status = $json['status']; 53 | $this->secondsRemaining = $json['seconds_remaining'] ?? null; 54 | $this->billedCharacters = $json['billed_characters'] ?? null; 55 | $this->errorMessage = $json['error_message'] ?? null; 56 | } 57 | 58 | /** 59 | * @return bool True if the document translation completed successfully, otherwise false. 60 | */ 61 | public function done(): bool 62 | { 63 | return $this->status === 'done'; 64 | } 65 | 66 | /** 67 | * @return bool True if no error has occurred, otherwise false. 68 | * Note that while the document translation is in progress, this returns true. 69 | */ 70 | public function ok(): bool 71 | { 72 | return $this->status !== 'error'; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/MultilingualGlossaryDictionaryEntries.php: -------------------------------------------------------------------------------- 1 | sourceLang = $sourceLang; 35 | $this->targetLang = $targetLang; 36 | $this->entries = $entries; 37 | } 38 | 39 | /** 40 | * @throws DeepLException 41 | */ 42 | public static function parseJsonList(string $content): array 43 | { 44 | try { 45 | $object = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); 46 | } catch (JsonException $exception) { 47 | throw new InvalidContentException($exception); 48 | } 49 | 50 | return self::parseList($object['dictionaries']); 51 | } 52 | 53 | /** 54 | * @throws DeepLException 55 | */ 56 | public static function parseList(array $list): array 57 | { 58 | $result = []; 59 | foreach ($list as $object) { 60 | $result[] = self::parseObject($object); 61 | } 62 | return $result; 63 | } 64 | 65 | /** 66 | * @throws DeepLException 67 | */ 68 | public static function parseObject($object): MultilingualGlossaryDictionaryEntries 69 | { 70 | $entries_format = $object['entries_format']; 71 | if ($entries_format === "tsv") { 72 | $entries = GlossaryUtils::fromTsv($object['entries']); 73 | } else { 74 | throw new DeepLException("Unsupported entries_format: $entries_format"); 75 | } 76 | 77 | return new MultilingualGlossaryDictionaryEntries($object['source_lang'], $object['target_lang'], $entries); 78 | } 79 | 80 | public function toObject(): array 81 | { 82 | return [ 83 | "source_lang" => $this->sourceLang, 84 | "target_lang" => $this->targetLang, 85 | "entries" => $this->convertToTsv(), 86 | "entries_format" => "tsv", 87 | ]; 88 | } 89 | 90 | public function convertToTsv(): string 91 | { 92 | return GlossaryUtils::convertToTsv($this->entries); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/MultilingualGlossaryInfo.php: -------------------------------------------------------------------------------- 1 | glossaryId = $glossaryId; 37 | $this->name = $name; 38 | $this->creationTime = $creationTime; 39 | $this->dictionaries = $dictionaries; 40 | } 41 | 42 | /** 43 | * @param string|MultilingualGlossaryInfo $glossary Glossary ID or MultilingualGlossaryInfo of glossary. 44 | */ 45 | public static function getGlossaryId($glossary): string 46 | { 47 | return is_string($glossary) ? $glossary : $glossary->glossaryId; 48 | } 49 | 50 | /** 51 | * @throws InvalidContentException 52 | */ 53 | public static function parseJson(string $content): MultilingualGlossaryInfo 54 | { 55 | try { 56 | $object = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); 57 | } catch (JsonException $exception) { 58 | throw new InvalidContentException($exception); 59 | } 60 | 61 | return self::parseObject($object); 62 | } 63 | 64 | /** 65 | * @throws InvalidContentException 66 | */ 67 | public static function parseListJson(string $content): array 68 | { 69 | try { 70 | $decoded = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); 71 | } catch (JsonException $exception) { 72 | throw new InvalidContentException($exception); 73 | } 74 | 75 | $result = []; 76 | foreach ($decoded['glossaries'] as $object) { 77 | $result[] = self::parseObject($object); 78 | } 79 | return $result; 80 | } 81 | 82 | /** 83 | * @throws \Exception 84 | */ 85 | private static function parseObject($object): MultilingualGlossaryInfo 86 | { 87 | return new MultilingualGlossaryInfo( 88 | $object['glossary_id'], 89 | $object['name'] ?? null, 90 | new DateTime($object['creation_time']) ?? null, 91 | MultilingualGlossaryDictionaryInfo::parseList($object['dictionaries']) 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Usage.php: -------------------------------------------------------------------------------- 1 | character !== null && $this->character->limitReached()) || 42 | ($this->document !== null && $this->document->limitReached()) || 43 | ($this->teamDocument !== null && $this->teamDocument->limitReached()); 44 | } 45 | 46 | public function __toString(): string 47 | { 48 | $list = [ 49 | 'Characters' => $this->character, 50 | 'Documents' => $this->document, 51 | 'Team documents' => $this->teamDocument, 52 | ]; 53 | $result = 'Usage this billing period:'; 54 | foreach ($list as $label => $detail) { 55 | if ($detail !== null) { 56 | $result .= "\n$label: $detail->count of $detail->limit"; 57 | } 58 | } 59 | return $result; 60 | } 61 | 62 | /** 63 | * @throws InvalidContentException 64 | */ 65 | public function __construct(string $content) 66 | { 67 | try { 68 | $json = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); 69 | } catch (JsonException $exception) { 70 | throw new InvalidContentException($exception); 71 | } 72 | 73 | $this->character = $this->buildUsageDetail('character', $json); 74 | $this->document = $this->buildUsageDetail('document', $json); 75 | $this->teamDocument = $this->buildUsageDetail('team_document', $json); 76 | } 77 | 78 | private function buildUsageDetail(string $prefix, array $json): ?UsageDetail 79 | { 80 | $count = "{$prefix}_count"; 81 | $limit = "{$prefix}_limit"; 82 | if (array_key_exists($count, $json) && array_key_exists($limit, $json)) { 83 | return new UsageDetail($json[$count], $json[$limit]); 84 | } 85 | return null; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/GlossaryUtils.php: -------------------------------------------------------------------------------- 1 | 2) { 28 | throw new DeepLException("Entry on line $lineNumber contains more than one term separator: $line"); 29 | } 30 | $source = $terms[0]; 31 | $target = $terms[1]; 32 | self::validateGlossaryTerm($source); 33 | self::validateGlossaryTerm($target); 34 | if (array_key_exists($source, $entries)) { 35 | throw new DeepLException("Entry on line $lineNumber duplicates source term \"$source\""); 36 | } 37 | $entries[$source] = $target; 38 | } 39 | if (count($entries) == 0) { 40 | throw new DeepLException('Input contains no entries'); 41 | } 42 | return $entries; 43 | } 44 | 45 | public static function convertToTsv(array $entries): string 46 | { 47 | $terms = array_map(function ($k, $v) { 48 | return "$k\t$v"; 49 | }, array_keys($entries), array_values($entries)); 50 | return implode("\n", $terms); 51 | } 52 | 53 | /** 54 | * @throws DeepLException 55 | */ 56 | public static function validateEntries(array $entries) 57 | { 58 | if (count($entries) == 0) { 59 | throw new DeepLException('Input contains no entries'); 60 | } 61 | foreach ($entries as $source => $target) { 62 | self::validateGlossaryTerm($source); 63 | self::validateGlossaryTerm($target); 64 | } 65 | } 66 | 67 | /** 68 | * @throws DeepLException 69 | */ 70 | public static function validateGlossaryTerm(string $term) 71 | { 72 | $termTrimmed = trim($term); 73 | if (strlen($termTrimmed) == 0) { 74 | throw new DeepLException("Term \"$term\" contains no non-whitespace characters"); 75 | } 76 | foreach (mb_str_split($termTrimmed, 1, 'utf-8') as $ch) { 77 | $ord = mb_ord($ch); 78 | if ($ord === false || // Conversion failed 79 | (0 <= $ord && $ord <= 31) || // C0 control characters 80 | (128 <= $ord && $ord <= 159) || // C1 control characters 81 | $ord == 0x2028 || $ord == 0x2029 // Unicode newlines 82 | ) { 83 | $hex = dechex($ord); 84 | throw new DeepLException("Term \"$term\" contains invalid characters: '$ch' (0x$hex)"); 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/GlossaryInfo.php: -------------------------------------------------------------------------------- 1 | glossaryId = $glossaryId; 48 | $this->name = $name; 49 | $this->ready = $ready; 50 | $this->sourceLang = $sourceLang; 51 | $this->targetLang = $targetLang; 52 | $this->creationTime = $creationTime; 53 | $this->entryCount = $entryCount; 54 | } 55 | 56 | /** 57 | * @throws InvalidContentException 58 | */ 59 | public static function parse(string $content): GlossaryInfo 60 | { 61 | try { 62 | $object = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); 63 | } catch (JsonException $exception) { 64 | throw new InvalidContentException($exception); 65 | } 66 | 67 | return self::parseJsonObject($object); 68 | } 69 | 70 | /** 71 | * @throws InvalidContentException 72 | */ 73 | public static function parseList(string $content): array 74 | { 75 | try { 76 | $decoded = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); 77 | } catch (JsonException $exception) { 78 | throw new InvalidContentException($exception); 79 | } 80 | 81 | $result = []; 82 | foreach ($decoded['glossaries'] as $object) { 83 | $result[] = self::parseJsonObject($object); 84 | } 85 | return $result; 86 | } 87 | 88 | /** 89 | * @throws \Exception 90 | */ 91 | private static function parseJsonObject($object): GlossaryInfo 92 | { 93 | return new GlossaryInfo( 94 | $object['glossary_id'], 95 | $object['name'] ?? null, 96 | $object['ready'] ?? null, 97 | $object['source_lang'] ?? null, 98 | $object['target_lang'] ?? null, 99 | new DateTime($object['creation_time']) ?? null, 100 | $object['entry_count'] ?? null 101 | ); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/StyleRuleInfo.php: -------------------------------------------------------------------------------- 1 | styleId = $styleId; 52 | $this->name = $name; 53 | $this->creationTime = $creationTime; 54 | $this->updatedTime = $updatedTime; 55 | $this->language = $language; 56 | $this->version = $version; 57 | $this->configuredRules = $configuredRules; 58 | $this->customInstructions = $customInstructions; 59 | } 60 | 61 | /** 62 | * @param string|StyleRuleInfo $styleRule Style rule ID or StyleRuleInfo of style rule. 63 | */ 64 | public static function getStyleId($styleRule): string 65 | { 66 | return is_string($styleRule) ? $styleRule : $styleRule->styleId; 67 | } 68 | 69 | /** 70 | * @throws InvalidContentException 71 | */ 72 | public static function fromJson(array $json): StyleRuleInfo 73 | { 74 | $configuredRules = null; 75 | if (isset($json['configured_rules'])) { 76 | $configuredRules = ConfiguredRules::fromJson($json['configured_rules']); 77 | } 78 | 79 | $customInstructions = null; 80 | if (isset($json['custom_instructions']) && is_array($json['custom_instructions'])) { 81 | $customInstructions = []; 82 | foreach ($json['custom_instructions'] as $instruction) { 83 | $customInstructions[] = CustomInstruction::fromJson($instruction); 84 | } 85 | } 86 | 87 | return new StyleRuleInfo( 88 | $json['style_id'], 89 | $json['name'], 90 | new DateTime($json['creation_time']), 91 | new DateTime($json['updated_time']), 92 | $json['language'], 93 | $json['version'], 94 | $configuredRules, 95 | $customInstructions 96 | ); 97 | } 98 | 99 | /** 100 | * @throws InvalidContentException 101 | */ 102 | public static function parseList(string $content): array 103 | { 104 | try { 105 | $decoded = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); 106 | } catch (JsonException $exception) { 107 | throw new InvalidContentException($exception); 108 | } 109 | 110 | $result = []; 111 | $styleRules = $decoded['style_rules'] ?? []; 112 | foreach ($styleRules as $object) { 113 | $result[] = self::fromJson($object); 114 | } 115 | return $result; 116 | } 117 | 118 | public function __toString(): string 119 | { 120 | return "StyleRule \"{$this->name}\" ({$this->styleId})"; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/TranslateTextOptions.php: -------------------------------------------------------------------------------- 1 | true, 26 | TranslatorOptions::HEADERS => true, 27 | TranslatorOptions::TIMEOUT => true, 28 | TranslatorOptions::MAX_RETRIES => true, 29 | TranslatorOptions::PROXY => true, 30 | TranslatorOptions::LOGGER => true, 31 | TranslatorOptions::HTTP_CLIENT => true, 32 | TranslatorOptions::SEND_PLATFORM_INFO => true, 33 | TranslatorOptions::APP_INFO => true, 34 | ]; 35 | 36 | /** List of all options that are ignored when using a custom HTTP client. */ 37 | private const IGNORED_OPTIONS_WITH_CUSTOM_HTTP_CLIENT = [ 38 | TranslatorOptions::TIMEOUT, 39 | TranslatorOptions::PROXY, 40 | ]; 41 | 42 | /** 43 | * Base URL of DeepL API, can be overridden for example for testing purposes. By default, the correct DeepL API URL 44 | * is selected based on the user account type (free or paid). 45 | * @see DEFAULT_SERVER_URL 46 | * @see DEFAULT_SERVER_URL_FREE 47 | */ 48 | public const SERVER_URL = 'server_url'; 49 | 50 | /** 51 | * HTTP headers attached to every HTTP request. By default, no extra headers are used. Note that during Translator 52 | * initialization headers for Authorization and User-Agent are added, unless they are overridden in this option. 53 | */ 54 | public const HEADERS = 'headers'; 55 | 56 | /** 57 | * Connection timeout used for each HTTP request retry, as a float in seconds. 58 | * @see DEFAULT_TIMEOUT 59 | */ 60 | public const TIMEOUT = 'timeout'; 61 | 62 | /** 63 | * The maximum number of failed attempts that Translator will retry, per request. Note: only errors due to 64 | * transient conditions are retried. 65 | * @see DEFAULT_MAX_RETRIES 66 | */ 67 | public const MAX_RETRIES = 'max_retries'; 68 | 69 | /** 70 | * Proxy server URL, for example 'https://user:pass@10.10.1.10:3128'. 71 | */ 72 | public const PROXY = 'proxy'; 73 | 74 | /** 75 | * The PSR-3 compatible logger to log messages to. 76 | * @see LoggerInterface 77 | */ 78 | public const LOGGER = 'logger'; 79 | 80 | /** 81 | * The PSR-18 compatible HTTP client used to make HTTP requests, or null to use the default client. 82 | */ 83 | public const HTTP_CLIENT = 'http_client'; 84 | 85 | /** The default server URL used for DeepL API Pro accounts (if SERVER_URL is unspecified). */ 86 | public const DEFAULT_SERVER_URL = 'https://api.deepl.com'; 87 | 88 | /** The default server URL used for DeepL API Free accounts (if SERVER_URL is unspecified). */ 89 | public const DEFAULT_SERVER_URL_FREE = 'https://api-free.deepl.com'; 90 | 91 | /** The default timeout (if TIMEOUT is unspecified) is 10 seconds. */ 92 | public const DEFAULT_TIMEOUT = 10.0; 93 | 94 | /** The default maximum number of request retries (if MAX_RETRIES is unspecified) is 5. */ 95 | public const DEFAULT_MAX_RETRIES = 5; 96 | 97 | /** 98 | * Flag that determines if the library sends more detailed information about the platform it runs 99 | * on with each API call. This is overriden if the User-Agent header is set in the HEADERS field. 100 | * @see HEADERS 101 | */ 102 | public const SEND_PLATFORM_INFO = 'send_platform_info'; 103 | 104 | /** Name and version of the application that uses this client library. */ 105 | public const APP_INFO = 'app_info'; 106 | 107 | /** 108 | * Validates the options array passed to the Translator object. 109 | */ 110 | public static function isValid(array $options): bool 111 | { 112 | $is_valid = true; 113 | $maybe_logger = $options[TranslatorOptions::LOGGER] ?? null; 114 | if (isset($options[TranslatorOptions::HTTP_CLIENT])) { 115 | foreach (TranslatorOptions::IGNORED_OPTIONS_WITH_CUSTOM_HTTP_CLIENT as $ignored_option) { 116 | $is_valid &= !TranslatorOptions::isIgnoredHttpOptionSet($ignored_option, $options, $maybe_logger); 117 | } 118 | } 119 | foreach ($options as $option_key => $option_value) { 120 | if (!array_key_exists($option_key, TranslatorOptions::OPTIONS_KEYS)) { 121 | if ($maybe_logger !== null) { 122 | $maybe_logger->warning("Option $option_key is not recognized and thus ignored."); 123 | } 124 | $is_valid = false; 125 | } 126 | } 127 | return $is_valid; 128 | } 129 | 130 | private static function isIgnoredHttpOptionSet( 131 | string $keyToCheck, 132 | array $options, 133 | ?LoggerInterface $maybe_logger 134 | ): bool { 135 | if (array_key_exists($keyToCheck, $options)) { 136 | if ($maybe_logger !== null) { 137 | $maybe_logger->warning("Option $keyToCheck is ignored as a custom HTTP client is used."); 138 | } 139 | return true; 140 | } 141 | return false; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/LanguageCode.php: -------------------------------------------------------------------------------- 1 | buildRephraseBodyParams( 32 | $targetLang, 33 | $options[RephraseTextOptions::WRITING_STYLE] ?? null, 34 | $options[RephraseTextOptions::TONE] ?? null 35 | ); 36 | $this->validateAndAppendTexts($params, $texts); 37 | 38 | $response = $this->client->sendRequestWithBackoff( 39 | 'POST', 40 | '/v2/write/rephrase', 41 | [HttpClientWrapper::OPTION_PARAMS => $params] 42 | ); 43 | $this->checkStatusCode($response); 44 | list(, $content) = $response; 45 | 46 | try { 47 | $json = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); 48 | } catch (JsonException $exception) { 49 | throw new InvalidContentException($exception); 50 | } 51 | 52 | $improvements = isset($json['improvements']) && is_array($json['improvements']) ? $json['improvements'] : []; 53 | $output = []; 54 | foreach ($improvements as $improvement) { 55 | $text = $improvement['text'] ?? ''; 56 | $detectedSourceLanguage = $improvement['detected_source_language'] ?? ''; 57 | $targetLanguage = $improvement['target_language'] ?? ''; 58 | $output[] = new RephraseTextResult($text, $detectedSourceLanguage, $targetLanguage); 59 | } 60 | 61 | return is_array($texts) ? $output : $output[0]; 62 | } 63 | 64 | /** 65 | * Creates a new glossary on DeepL server with given name with all the specified 66 | * dictionaries, each with their own language pair and entries. 67 | * @param string $name User-defined name to assign to the glossary. 68 | * @param MultilingualGlossaryDictionaryEntries[] $dictionaries A list of MultilingualGlossaryDictionaryEntries 69 | * which each contains entries for a particular language pair 70 | * @return MultilingualGlossaryInfo Details about the created glossary. 71 | * @throws DeepLException 72 | */ 73 | public function createMultilingualGlossary( 74 | string $name, 75 | array $dictionaries 76 | ): MultilingualGlossaryInfo { 77 | 78 | if (strlen($name) === 0) { 79 | throw new DeepLException('glossary name must be a non-empty string'); 80 | } 81 | 82 | $params = [ 83 | 'name' => $name, 84 | 'dictionaries' => array_map(function (MultilingualGlossaryDictionaryEntries $entries) { 85 | return $entries->toObject(); 86 | }, $dictionaries), 87 | ]; 88 | 89 | $response = $this->client->sendRequestWithBackoff( 90 | 'POST', 91 | '/v3/glossaries', 92 | [HttpClientWrapper::OPTION_JSON => json_encode($params)] 93 | ); 94 | $this->checkStatusCode($response, false, true); 95 | list(, $content) = $response; 96 | return MultilingualGlossaryInfo::parseJson($content); 97 | } 98 | 99 | /** 100 | * Creates a new glossary on DeepL server with given name, languages, and entries. 101 | * @param string $name User-defined name to assign to the glossary. 102 | * @param string $sourceLang Language code of the glossary source terms. 103 | * @param string $targetLang Language code of the glossary target terms. 104 | * @param string $csvContent String containing CSV content. 105 | * @return MultilingualGlossaryInfo Details about the created glossary. 106 | * @throws DeepLException 107 | */ 108 | public function createMultilingualGlossaryFromCsv( 109 | string $name, 110 | string $sourceLang, 111 | string $targetLang, 112 | string $csvContent 113 | ): MultilingualGlossaryInfo { 114 | 115 | if (strlen($name) === 0) { 116 | throw new DeepLException('glossary name must be a non-empty string'); 117 | } 118 | 119 | $params = [ 120 | 'name' => $name, 121 | 'dictionaries' => [ 122 | [ 123 | 'source_lang' => $sourceLang, 124 | 'target_lang' => $targetLang, 125 | 'entries_format' => 'csv', 126 | 'entries' => $csvContent, 127 | ] 128 | ] 129 | ]; 130 | 131 | $response = $this->client->sendRequestWithBackoff( 132 | 'POST', 133 | '/v3/glossaries', 134 | [HttpClientWrapper::OPTION_JSON => json_encode($params)] 135 | ); 136 | $this->checkStatusCode($response, false, true); 137 | list(, $content) = $response; 138 | return MultilingualGlossaryInfo::parseJson($content); 139 | } 140 | 141 | /** 142 | * Updates or creates a glossary dictionary with given entries for the 143 | * source and target languages. Either updates entries if they exist for 144 | * the given language pair, or adds new ones to the dictionary if not. 145 | * @param string|MultilingualGlossaryInfo $glossary Glossary ID or MultilingualGlossaryInfo of glossary to update. 146 | * @param string|null $name Optional, new name for glossary. 147 | * @param array|null $dictionaries Optional, array of MultilingualGlossaryDictionaryEntries to update or add to 148 | * glossary. 149 | * @return MultilingualGlossaryInfo Info about the updated glossary. 150 | * @throws DeepLException 151 | */ 152 | public function updateMultilingualGlossary( 153 | $glossary, 154 | ?string $name, 155 | ?array $dictionaries 156 | ): MultilingualGlossaryInfo { 157 | $glossaryId = MultilingualGlossaryInfo::getGlossaryId($glossary); 158 | $params = []; 159 | if (isset($name)) { 160 | $params['name'] = $name; 161 | } 162 | if (isset($dictionaries) and count($dictionaries) > 0) { 163 | $params['dictionaries'] = array_map(function (MultilingualGlossaryDictionaryEntries $entries) { 164 | return $entries->toObject(); 165 | }, $dictionaries); 166 | } 167 | 168 | $response = $this->client->sendRequestWithBackoff( 169 | 'PATCH', 170 | "/v3/glossaries/$glossaryId", 171 | [HttpClientWrapper::OPTION_JSON => json_encode($params)] 172 | ); 173 | $this->checkStatusCode($response, false, true); 174 | list(, $content) = $response; 175 | return MultilingualGlossaryInfo::parseJson($content); 176 | } 177 | 178 | /** 179 | * Replaces a glossary dictionary with given entries for the 180 | * source and target languages. Either replaces dictionary if one exists for 181 | * the given language pair, or adds new dictionary if not. 182 | * @param string|MultilingualGlossaryInfo $glossary Glossary ID or MultilingualGlossaryInfo of glossary to 183 | * update. 184 | * @param MultilingualGlossaryDictionaryEntries $dictionaryEntries Replacement dictionary with entries. 185 | * @return MultilingualGlossaryDictionaryInfo Info about the dictionary. 186 | * @throws DeepLException 187 | */ 188 | public function replaceMultilingualGlossaryDictionary( 189 | $glossary, 190 | MultilingualGlossaryDictionaryEntries $dictionaryEntries 191 | ): MultilingualGlossaryDictionaryInfo { 192 | $glossaryId = MultilingualGlossaryInfo::getGlossaryId($glossary); 193 | $params = $dictionaryEntries->toObject(); 194 | 195 | $response = $this->client->sendRequestWithBackoff( 196 | 'PUT', 197 | "/v3/glossaries/$glossaryId/dictionaries", 198 | [HttpClientWrapper::OPTION_JSON => json_encode($params)] 199 | ); 200 | $this->checkStatusCode($response, false, true); 201 | list(, $content) = $response; 202 | return MultilingualGlossaryDictionaryInfo::parseJson($content); 203 | } 204 | 205 | /** 206 | * Gets information about an existing glossary. 207 | * @param string|MultilingualGlossaryInfo $glossary Glossary ID or MultilingualGlossaryInfo of glossary to get 208 | * info. 209 | * @return MultilingualGlossaryInfo MultilingualGlossaryInfo containing details about the glossary. 210 | * @throws DeepLException 211 | */ 212 | public function getMultilingualGlossary($glossary): MultilingualGlossaryInfo 213 | { 214 | $glossaryId = MultilingualGlossaryInfo::getGlossaryId($glossary); 215 | $response = $this->client->sendRequestWithBackoff('GET', "/v3/glossaries/$glossaryId"); 216 | $this->checkStatusCode($response, false, true); 217 | list(, $content) = $response; 218 | return MultilingualGlossaryInfo::parseJson($content); 219 | } 220 | 221 | /** 222 | * Gets information about all existing glossaries. 223 | * @return MultilingualGlossaryInfo[] Array of MultilingualGlossaryInfos containing details about all existing 224 | * glossaries. 225 | * @throws DeepLException 226 | */ 227 | public function listMultilingualGlossaries(): array 228 | { 229 | $response = $this->client->sendRequestWithBackoff('GET', '/v3/glossaries'); 230 | $this->checkStatusCode($response, false, true); 231 | list(, $content) = $response; 232 | return MultilingualGlossaryInfo::parseListJson($content); 233 | } 234 | 235 | /** 236 | * Retrieves the dictionary entries for a given source and target language in the given glossary. 237 | * @param string|MultilingualGlossaryInfo $glossary Glossary ID or MultilingualGlossaryInfo of glossary to 238 | * retrieve entries of. 239 | * @param string $sourceLang Language code of the glossary source terms. 240 | * @param string $targetLang Language code of the glossary target terms. 241 | * @return MultilingualGlossaryDictionaryEntries[] The entries stored in the dictionary. 242 | * @throws DeepLException 243 | */ 244 | public function getMultilingualGlossaryEntries($glossary, string $sourceLang, string $targetLang): array 245 | { 246 | $glossaryId = MultilingualGlossaryInfo::getGlossaryId($glossary); 247 | $url = "/v3/glossaries/$glossaryId/entries?source_lang=$sourceLang&target_lang=$targetLang"; 248 | $response = $this->client->sendRequestWithBackoff('GET', $url); 249 | $this->checkStatusCode($response, false, true); 250 | list(, $content) = $response; 251 | return MultilingualGlossaryDictionaryEntries::parseJsonList($content); 252 | } 253 | 254 | /** 255 | * Deletes the glossary with the given glossary ID or MultilingualGlossaryInfo. 256 | * @param string|MultilingualGlossaryInfo $glossary Glossary ID or MultilingualGlossaryInfo of glossary to 257 | * be deleted. 258 | * @throws DeepLException 259 | */ 260 | public function deleteMultilingualGlossary($glossary): void 261 | { 262 | $glossaryId = MultilingualGlossaryInfo::getGlossaryId($glossary); 263 | $response = $this->client->sendRequestWithBackoff('DELETE', "/v3/glossaries/$glossaryId"); 264 | $this->checkStatusCode($response, false, true); 265 | } 266 | 267 | /** 268 | * Deletes specified glossary dictionary. 269 | * @param string|MultilingualGlossaryInfo $glossary Glossary ID or MultilingualGlossaryInfo of glossary to 270 | * be deleted. 271 | * @param MultilingualGlossaryDictionaryInfo|null $dictionary The dictionary to delete. Either the 272 | * MultilingualGlossaryDictionaryInfo or both the source_lang and target_lang 273 | * can be provided to identify the dictionary. 274 | * @param string|null $sourceLang Optional parameter representing the source language of the dictionary 275 | * @param string|null $targetLang Optional parameter representing the target language of the dictionary. 276 | * @throws DeepLException 277 | */ 278 | public function deleteMultilingualGlossaryDictionary( 279 | $glossary, 280 | ?MultilingualGlossaryDictionaryInfo $dictionary, 281 | ?string $sourceLang = null, 282 | ?string $targetLang = null 283 | ): void { 284 | $glossaryId = MultilingualGlossaryInfo::getGlossaryId($glossary); 285 | 286 | if (is_null($dictionary)) { 287 | if (is_null($sourceLang) or is_null($targetLang)) { 288 | throw new DeepLException('must provide dictionary or both source_lang and target_lang'); 289 | } 290 | } else { 291 | $sourceLang = $dictionary->sourceLang; 292 | $targetLang = $dictionary->targetLang; 293 | } 294 | 295 | $url = "/v3/glossaries/$glossaryId/dictionaries?source_lang=$sourceLang&target_lang=$targetLang"; 296 | $response = $this->client->sendRequestWithBackoff('DELETE', $url); 297 | $this->checkStatusCode($response, false, true); 298 | } 299 | 300 | /** 301 | * Validates and prepares HTTP parameters for rephrase requests. 302 | * @param string|string[] $texts Text(s) to rephrase 303 | * @param string|null $targetLang Target language code, or null to use default 304 | * @param string|null $style Writing style option, or null if not specified 305 | * @param string|null $tone Tone option, or null if not specified 306 | * @return array Associative array of HTTP parameters 307 | * @throws DeepLException 308 | */ 309 | public function buildRephraseBodyParams( 310 | ?string $targetLang = null, 311 | ?string $style = null, 312 | ?string $tone = null 313 | ): array { 314 | if ($targetLang !== null) { 315 | $targetLang = LanguageCode::standardizeLanguageCode($targetLang); 316 | if ($targetLang === 'en') { 317 | throw new DeepLException('targetLang="en" is deprecated, please use "en-GB" or "en-US" instead.'); 318 | } elseif ($targetLang === 'pt') { 319 | throw new DeepLException('targetLang="pt" is deprecated, please use "pt-PT" or "pt-BR" instead.'); 320 | } 321 | $params['target_lang'] = $targetLang; 322 | } 323 | 324 | if ($style !== null) { 325 | $params['writing_style'] = $style; 326 | } 327 | 328 | if ($tone !== null) { 329 | $params['tone'] = $tone; 330 | } 331 | 332 | return $params; 333 | } 334 | 335 | /** 336 | * Retrieves a list of StyleRuleInfo for all available style rules. 337 | * @param int|null $page Page number for pagination, 0-indexed (optional). 338 | * @param int|null $pageSize Number of items per page (optional). 339 | * @param bool|null $detailed Whether to include detailed configuration rules (optional). 340 | * @return StyleRuleInfo[] List of StyleRuleInfo objects for all available style rules. 341 | * @throws DeepLException 342 | */ 343 | public function getAllStyleRules( 344 | ?int $page = null, 345 | ?int $pageSize = null, 346 | ?bool $detailed = null 347 | ): array { 348 | $params = []; 349 | if ($page !== null) { 350 | $params['page'] = (string)$page; 351 | } 352 | if ($pageSize !== null) { 353 | $params['page_size'] = (string)$pageSize; 354 | } 355 | if ($detailed !== null) { 356 | $params['detailed'] = $detailed ? 'true' : 'false'; 357 | } 358 | 359 | $queryString = ''; 360 | if (!empty($params)) { 361 | $queryString = '?' . http_build_query($params); 362 | } 363 | 364 | $response = $this->client->sendRequestWithBackoff('GET', "/v3/style_rules$queryString"); 365 | $this->checkStatusCode($response); 366 | list(, $content) = $response; 367 | 368 | return StyleRuleInfo::parseList($content); 369 | } 370 | } 371 | -------------------------------------------------------------------------------- /src/HttpClientWrapper.php: -------------------------------------------------------------------------------- 1 | serverUrl = $serverUrl; 61 | $this->maxRetries = $maxRetries; 62 | $this->minTimeout = $timeout; 63 | $this->headers = $headers; 64 | $this->logger = $logger; 65 | $this->proxy = $proxy; 66 | $this->customHttpClient = $customHttpClient; 67 | $this->requestFactory = Psr17FactoryDiscovery::findRequestFactory(); 68 | $this->streamClient = new Psr18Client(); 69 | $this->streamFactory = Psr17FactoryDiscovery::findStreamFactory(); 70 | $this->curlHandle = $customHttpClient === null ? \curl_init() : null; 71 | } 72 | 73 | public function __destruct() 74 | { 75 | if ($this->customHttpClient === null) { 76 | \curl_close($this->curlHandle); 77 | } 78 | } 79 | 80 | /** 81 | * Makes API request retrying if necessary, and returns (as Promise) response. 82 | * @param string $method HTTP method, for example 'GET'. 83 | * @param string $url Path to endpoint, excluding base server URL. 84 | * @param array|null $options Array of options, possible arguments are given by OPTIONS_ constants. 85 | * @return array Status code and content. 86 | * @throws DeepLException 87 | */ 88 | public function sendRequestWithBackoff(string $method, string $url, ?array $options = []): array 89 | { 90 | $url = $this->serverUrl . $url; 91 | $headers = array_replace( 92 | $this->headers, 93 | $options[self::OPTION_HEADERS] ?? [] 94 | ); 95 | $file = $options[self::OPTION_FILE] ?? null; 96 | $params = $options[self::OPTION_PARAMS] ?? []; 97 | $this->logInfo("Request to DeepL API $method $url"); 98 | $this->logDebug('Request details: ' . json_encode($params)); 99 | $backoff = new BackoffTimer(); 100 | $response = null; 101 | $exception = null; 102 | while ($backoff->getNumRetries() <= $this->maxRetries) { 103 | $outFile = isset($options[self::OPTION_OUTFILE]) ? fopen($options[self::OPTION_OUTFILE], 'w') : null; 104 | $timeout = max($this->minTimeout, $backoff->getTimeUntilDeadline()); 105 | $response = null; 106 | $exception = null; 107 | $json = isset($options[self::OPTION_JSON]) ? $options[self::OPTION_JSON] : null; 108 | try { 109 | $response = $this->sendRequest($method, $url, $timeout, $headers, $params, $file, $outFile, $json); 110 | } catch (ConnectionException $e) { 111 | $exception = $e; 112 | } 113 | 114 | if ($outFile) { 115 | fclose($outFile); 116 | } 117 | 118 | if (!$this->shouldRetry($response, $exception) || $backoff->getNumRetries() + 1 >= $this->maxRetries) { 119 | break; 120 | } 121 | 122 | if ($exception !== null) { 123 | $this->logDebug("Encountered a retryable-error: {$exception->getMessage()}"); 124 | } 125 | 126 | $this->logInfo('Starting retry ' . ($backoff->getNumRetries() + 1) . 127 | " for request $method $url after sleeping for {$backoff->getTimeUntilDeadline()} seconds."); 128 | $backoff->sleepUntilDeadline(); 129 | } 130 | 131 | if ($exception !== null) { 132 | throw $exception; 133 | } else { 134 | list($statusCode, $content) = $response; 135 | $this->logInfo("DeepL API response $method $url $statusCode"); 136 | $this->logDebug("Response details: $content"); 137 | return $response; 138 | } 139 | } 140 | 141 | /** 142 | * Sends a HTTP request. Note that in the case of a custom HTTP client, some of these options are 143 | * ignored in favor of whatever is set in the client (e.g. timeouts and proxy). If we fall back to cURL, 144 | * those options are respected. 145 | * @param string $method HTTP method to use. 146 | * @param string $url Absolute URL to query. 147 | * @param float $timeout Time to wait before triggering timeout, in seconds. 148 | * @param array $headers Array of headers to include in request. 149 | * @param array $params Array of parameters to include in body. 150 | * @param string|null $filePath If not null, path to file to upload with request. 151 | * @param resource|null $outFile If not null, file to write output to. 152 | * @param string|null $json If not null, JSON content to include in body. 153 | * @return array Array where the first element is the HTTP status code and the second element is the response body. 154 | * @throws ConnectionException 155 | */ 156 | private function sendRequest( 157 | string $method, 158 | string $url, 159 | float $timeout, 160 | array $headers, 161 | array $params, 162 | ?string $filePath, 163 | $outFile, 164 | ?string $json 165 | ): array { 166 | if ($this->customHttpClient !== null) { 167 | return $this->sendCustomHttpRequest($method, $url, $headers, $params, $filePath, $outFile, $json); 168 | } else { 169 | return $this->sendCurlRequest($method, $url, $timeout, $headers, $params, $filePath, $outFile, $json); 170 | } 171 | } 172 | 173 | /** 174 | * Creates a PSR-7 compliant HTTP request with the given arguments. 175 | * @param string $method HTTP method to use 176 | * @param string $uri The URI for the request 177 | * @param array $headers Array of headers for the request 178 | * @param StreamInterface $body body to be used for the request. 179 | * @return RequestInterface HTTP request object 180 | */ 181 | private function createHttpRequest(string $method, string $url, array $headers, StreamInterface $body) 182 | { 183 | $request = $this->requestFactory->createRequest($method, $url); 184 | foreach ($headers as $header_key => $header_val) { 185 | $request = $request->withHeader($header_key, $header_val); 186 | } 187 | $request = $request->withBody($body); 188 | return $request; 189 | } 190 | 191 | /** 192 | * Sends a HTTP request using the custom HTTP client. 193 | * @param string $method HTTP method to use. 194 | * @param string $url Absolute URL to query. 195 | * @param array $headers Array of headers to include in request. 196 | * @param array $params Array of parameters to include in body. 197 | * @param string|null $filePath If not null, path to file to upload with request. 198 | * @param resource|null $outFile If not null, file to write output to. 199 | * @param string|null $json If not null, JSON content to include in body. 200 | * @return array Array where the first element is the HTTP status code and the second element is the response body. 201 | * @throws ConnectionException 202 | */ 203 | private function sendCustomHttpRequest( 204 | string $method, 205 | string $url, 206 | array $headers, 207 | array $params, 208 | ?string $filePath, 209 | $outFile, 210 | ?string $json 211 | ): array { 212 | $body = null; 213 | if ($filePath !== null) { 214 | $builder = new MultipartStreamBuilder($this->streamClient); 215 | $builder->addResource('file', fopen($filePath, 'r')); 216 | foreach ($params as $param_name => $value) { 217 | $builder->addResource($param_name, $value); 218 | } 219 | $body = $builder->build(); 220 | $boundary = $builder->getBoundary(); 221 | $headers['Content-Type'] = "multipart/form-data; boundary=\"$boundary\""; 222 | } elseif (count($params) > 0) { 223 | $headers['Content-Type'] = 'application/x-www-form-urlencoded'; 224 | $body = $this->streamFactory->createStream( 225 | $this->urlEncodeWithRepeatedParams($params) 226 | ); 227 | } elseif (isset($json)) { 228 | $headers['Content-Type'] = 'application/json'; 229 | $body = $this->streamFactory->createStream($json); 230 | } else { 231 | $body = $this->streamFactory->createStream(''); 232 | } 233 | $request = $this->createHttpRequest($method, $url, $headers, $body); 234 | try { 235 | $response = $this->customHttpClient->sendRequest($request); 236 | $response_data = (string) $response->getBody(); 237 | if ($outFile) { 238 | fwrite($outFile, $response_data); 239 | } 240 | return [$response->getStatusCode(), $response_data]; 241 | } catch (RequestExceptionInterface $e) { 242 | throw new ConnectionException($e->getMessage(), $e->getCode(), null, false); 243 | } catch (ClientExceptionInterface $e) { 244 | throw new ConnectionException($e->getMessage(), $e->getCode(), null, true); 245 | } 246 | } 247 | 248 | /** 249 | * Sends a HTTP request using cURL 250 | * @param string $method HTTP method to use. 251 | * @param string $url Absolute URL to query. 252 | * @param float $timeout Time to wait before triggering timeout, in seconds. 253 | * @param array $headers Array of headers to include in request. 254 | * @param array $params Array of parameters to include in body. 255 | * @param string|null $filePath If not null, path to file to upload with request. 256 | * @param resource|null $outFile If not null, file to write output to. 257 | * @param string|null $json If not null, JSON content to include in body. 258 | * @return array Array where the first element is the HTTP status code and the second element is the response body. 259 | * @throws ConnectionException 260 | */ 261 | private function sendCurlRequest( 262 | string $method, 263 | string $url, 264 | float $timeout, 265 | array $headers, 266 | array $params, 267 | ?string $filePath, 268 | $outFile, 269 | ?string $json 270 | ): array { 271 | $curlOptions = []; 272 | $curlOptions[\CURLOPT_HEADER] = false; 273 | 274 | switch ($method) { 275 | case "POST": 276 | $curlOptions[\CURLOPT_POST] = true; 277 | break; 278 | case "GET": 279 | $curlOptions[\CURLOPT_HTTPGET] = true; 280 | break; 281 | default: 282 | $curlOptions[\CURLOPT_CUSTOMREQUEST] = $method; 283 | break; 284 | } 285 | 286 | $curlOptions[\CURLOPT_URL] = $url; 287 | $curlOptions[\CURLOPT_CONNECTTIMEOUT] = $timeout; 288 | $curlOptions[\CURLOPT_TIMEOUT_MS] = $timeout * 1000; 289 | 290 | if ($this->proxy !== null) { 291 | $curlOptions[\CURLOPT_PROXY] = $this->proxy; 292 | } 293 | 294 | // Convert headers from an associative array to an array of "key: value" elements 295 | $curlOptions[\CURLOPT_HTTPHEADER] = \array_map(function (string $key, string $value): string { 296 | return "$key: $value"; 297 | }, array_keys($headers), array_values($headers)); 298 | 299 | if ($filePath !== null) { 300 | // If a file is to be uploaded, add it to the list of body parameters 301 | $params['file'] = \curl_file_create($filePath); 302 | $curlOptions[\CURLOPT_POSTFIELDS] = $params; 303 | } elseif (count($params) > 0) { 304 | // If there are repeated parameters, passing the parameters directly to cURL will index the repeated 305 | // parameters which is not what we need, so instead we encode the parameters without indexes. 306 | // This case only occurs if no file is uploaded. 307 | $curlOptions[\CURLOPT_POSTFIELDS] = $this->urlEncodeWithRepeatedParams($params); 308 | } elseif (!is_null($json)) { 309 | $curlOptions[\CURLOPT_POSTFIELDS] = $json; 310 | $curlOptions[\CURLOPT_HTTPHEADER][] = 'Content-Type: application/json'; 311 | } 312 | 313 | if ($outFile) { 314 | // Stream response content to specified file 315 | $curlOptions[\CURLOPT_FILE] = $outFile; 316 | } else { 317 | // Return response content as function result 318 | $curlOptions[\CURLOPT_RETURNTRANSFER] = true; 319 | } 320 | 321 | \curl_reset($this->curlHandle); 322 | 323 | // The next 3 curl calls are unqualified so that we can mock them, see 324 | // https://github.com/php-mock/php-mock-phpunit#restrictions 325 | curl_setopt_array($this->curlHandle, $curlOptions); 326 | 327 | $result = curl_exec($this->curlHandle); 328 | if ($result !== false) { 329 | $statusCode = curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE); 330 | return [$statusCode, $result]; 331 | } else { 332 | $errorMessage = \curl_error($this->curlHandle); 333 | $errorCode = \curl_errno($this->curlHandle); 334 | switch ($errorCode) { 335 | case \CURLE_UNSUPPORTED_PROTOCOL: 336 | case \CURLE_URL_MALFORMAT: 337 | case \CURLE_URL_MALFORMAT_USER: 338 | $shouldRetry = false; 339 | $errorMessage = "Invalid server URL. $errorMessage"; 340 | break; 341 | case \CURLE_OPERATION_TIMEOUTED: 342 | case \CURLE_COULDNT_CONNECT: 343 | case \CURLE_GOT_NOTHING: 344 | $shouldRetry = true; 345 | break; 346 | default: 347 | $shouldRetry = false; 348 | break; 349 | } 350 | throw new ConnectionException($errorMessage, $errorCode, null, $shouldRetry); 351 | } 352 | } 353 | 354 | private function shouldRetry(?array $response, ?ConnectionException $exception): bool 355 | { 356 | if ($exception !== null) { 357 | return $exception->shouldRetry; 358 | } 359 | list($statusCode, ) = $response; 360 | 361 | // Retry on Too-Many-Requests error and internal errors 362 | return $statusCode === 429 || $statusCode >= 500; 363 | } 364 | 365 | public function logDebug(string $message): void 366 | { 367 | if ($this->logger) { 368 | $this->logger->debug($message); 369 | } 370 | } 371 | 372 | public function logInfo(string $message): void 373 | { 374 | if ($this->logger) { 375 | $this->logger->info($message); 376 | } 377 | } 378 | 379 | private static function urlEncodeWithRepeatedParams(?array $params): string 380 | { 381 | $params = $params ?? []; 382 | $fields = []; 383 | foreach ($params as $key => $value) { 384 | $name = \urlencode($key); 385 | if (is_array($value)) { 386 | $fields[] = implode( 387 | '&', 388 | array_map( 389 | function (string $textElement) use ($name): string { 390 | return $name . '=' . \urlencode($textElement); 391 | }, 392 | $value 393 | ) 394 | ); 395 | } elseif (is_null($value)) { 396 | // Parameters with null value are skipped 397 | } else { 398 | $fields[] = $name . '=' . \urlencode($value); 399 | } 400 | } 401 | 402 | return implode("&", $fields); 403 | } 404 | } 405 | -------------------------------------------------------------------------------- /src/DocumentMinifier.php: -------------------------------------------------------------------------------- 1 | canMinifyFile($inputFile)) { 43 | * try { 44 | * $minifier->minifyDocument($inputFile, true); 45 | * $minifiedFile = $minifier->getMinifiedDocFile($inputFile); 46 | * // process file $minifiedFile, e.g. translate it with DeepL 47 | * $minifier->deminifyDocument($inputFile, $outputFile, true); 48 | * // process file $outputFile 49 | * } catch (DocumentMinificationException $e) { 50 | * // handle exception during minification, e.g. print list of media, clean up temporary directory, etc 51 | * } catch (DocumentDeminificationException $e) { 52 | * // handle exception during deminification, e.g. save minified document, clean up temporary directory, etc 53 | * } catch (DocumentTranslationException $e) { 54 | * // handle general DocTrans exception (mostly useful if document is translated between minnification 55 | * // and deminification) 56 | * } 57 | * } 58 | */ 59 | class DocumentMinifier 60 | { 61 | /** 62 | * Which input document types are supported for minification. 63 | */ 64 | const SUPPORTED_DOCUMENT_TYPES = ['pptx' => true, 'docx' => true]; 65 | /** 66 | * Which media formats in the documents are supported for minification. 67 | */ 68 | const SUPPORTED_MEDIA_FORMATS = [ 69 | // Image formats 70 | 'png' => true, 71 | 'jpg' => true, 72 | 'jpeg' => true, 73 | 'emf' => true, 74 | 'bmp' => true, 75 | 'tiff' => true, 76 | 'wdp' => true, 77 | 'svg' => true, 78 | 'gif' => true, 79 | // Video formats 80 | // Taken from https://support.microsoft.com/en-gb/office/video-and-audio-file-formats-supported-in-powerpoint-d8b12450-26db-4c7b-a5c1-593d3418fb59 81 | 'mp4' => true, 82 | 'asf' => true, 83 | 'avi' => true, 84 | 'm4v' => true, 85 | 'mpg' => true, 86 | 'mpeg' => true, 87 | 'wmv' => true, 88 | 'mov' => true, 89 | // Audio formats, taken from the same URL as video 90 | 'aiff' => true, 91 | 'au' => true, 92 | 'mid' => true, 93 | 'midi' => true, 94 | 'mp3' => true, 95 | 'm4a' => true, 96 | 'wav' => true, 97 | 'wma' => true 98 | ]; 99 | const EXTRACTED_DOC_DIR_NAME = 'extracted_doc'; 100 | const ORIGINAL_MEDIA_DIR_NAME = 'original_media'; 101 | const MINIFIED_DOC_FILE_BASE_NAME = 'minifiedDoc'; 102 | const MINIFIED_DOC_SIZE_LIMIT_WARNING = 5000000; 103 | 104 | private $tempDir; 105 | 106 | public function __construct(?string $tempDir = null) 107 | { 108 | $this->tempDir = $tempDir; 109 | if ($this->tempDir === null) { 110 | $this->tempDir = DocumentMinifier::createTemporaryDirectory(); 111 | } 112 | } 113 | 114 | public static function canMinifyFile(string $inputFile): bool 115 | { 116 | return array_key_exists( 117 | DocumentMinifier::getFileExtension($inputFile), 118 | DocumentMinifier::SUPPORTED_DOCUMENT_TYPES 119 | ); 120 | } 121 | 122 | public function getMinifiedDocFile(string $inputFileName): string 123 | { 124 | return $this->tempDir . '/' . DocumentMinifier::MINIFIED_DOC_FILE_BASE_NAME . '.' . 125 | DocumentMinifier::getFileExtension($inputFileName); 126 | } 127 | 128 | public function getExtractedDocDirectory(): string 129 | { 130 | return $this->tempDir . '/' . DocumentMinifier::EXTRACTED_DOC_DIR_NAME; 131 | } 132 | 133 | public function getOriginalMediaDirectory(): string 134 | { 135 | return $this->tempDir . '/' . DocumentMinifier::ORIGINAL_MEDIA_DIR_NAME; 136 | } 137 | 138 | /** 139 | * Minifies a given document using the given `tempDir`, by extracting it as a ZIP file and 140 | * replacing all supported media files with a small placeholder. 141 | * Created file will be inside the `tempDir`, the filename can be retrieved by calling 142 | * `DocumentMinifier::getMinifiedDocFile($tempDir)`. 143 | * Note that this method will minify the file without any checks, you should first call 144 | * `DocumentMinifier::canMinifyFile` on the input file. 145 | * If `cleanup` is set to `true`, the extracted document will be deleted afterwards, and only 146 | * the original media and the minified file will remain in the `tempDir`. 147 | * @param string inputFilePath file to be minified 148 | * @param bool cleanup if `true`, will delete the extracted document files from the temporary directory. 149 | * Otherwise, the files will remain (useful for debugging). 150 | * @return string the path of the minified document. Can also be retrieved by calling `getMinifiedDocFile` 151 | */ 152 | public function minifyDocument(string $inputFilePath, $cleanup = false): string 153 | { 154 | $extractedDocDirectory = $this->getExtractedDocDirectory(); 155 | $mediaDir = $this->getOriginalMediaDirectory(); 156 | $minifiedDocFilePath = $this->getMinifiedDocFile($inputFilePath); 157 | 158 | $this->extractZipTo($inputFilePath, $extractedDocDirectory, DocumentMinificationException::class); 159 | $this->exportMediaToMediaDirAndReplace($extractedDocDirectory, $mediaDir); 160 | $this->createZippedDocumentFromUnzippedDirectory( 161 | $extractedDocDirectory, 162 | $minifiedDocFilePath, 163 | DocumentMinificationException::class 164 | ); 165 | if ($cleanup) { 166 | $this->recursivelyDeleteDirectory($extractedDocDirectory, DocumentMinificationException::class); 167 | } 168 | $filesizeResponse = filesize($minifiedDocFilePath); 169 | if ($filesizeResponse !== false && $filesizeResponse) { 170 | if ($filesizeResponse > DocumentMinifier::MINIFIED_DOC_SIZE_LIMIT_WARNING) { 171 | trigger_error( 172 | 'The input file could not be minified below 5 MB, likely a media type is unsupported. This might ' 173 | .'cause translation to fail.', 174 | E_USER_WARNING 175 | ); 176 | } 177 | } 178 | return $minifiedDocFilePath; 179 | } 180 | 181 | /** 182 | * Deminifies a given file at `inputFilePath` by reinserting its original media in `tempDir` and stores 183 | * the resulting document in `outputFilePath`. If `cleanup` is set to `true`, it will delete the 184 | * `tempDir` afterwards, otherwise nothing will happen after the deminification. 185 | * 186 | * @param string inputFilePath Document to be deminified with its media. 187 | * @param string outputFilePath Where the final (deminified) document will be stored. 188 | * @param bool cleanup Determines if the `tempDir` is deleted at the end of this method. 189 | */ 190 | public function deminifyDocument( 191 | string $inputFilePath, 192 | string $outputFilePath, 193 | bool $cleanup = false 194 | ): void { 195 | $extractedDocDirectory = $this->getExtractedDocDirectory(); 196 | $mediaDir = $this->getOriginalMediaDirectory(); 197 | if (!mkdir($extractedDocDirectory)) { 198 | throw new DocumentDeminificationException( 199 | "Exception when deminifying, could not create directory at $extractedDocDirectory." 200 | ); 201 | } 202 | 203 | $this->extractZipTo($inputFilePath, $extractedDocDirectory, DocumentDeminificationException::class); 204 | $this->replaceImagesInDir($extractedDocDirectory, $mediaDir); 205 | $this->createZippedDocumentFromUnzippedDirectory( 206 | $extractedDocDirectory, 207 | $outputFilePath, 208 | DocumentDeminificationException::class 209 | ); 210 | if ($cleanup) { 211 | $this->recursivelyDeleteDirectory($this->tempDir, DocumentDeminificationException::class); 212 | } 213 | } 214 | 215 | /** 216 | * Creates a temporary directory for use in the `DocumentMinifier` class. Uses the system's temporary directory. 217 | * @return string The path of the created temporary directory 218 | */ 219 | public static function createTemporaryDirectory(): string 220 | { 221 | $tempDir = sys_get_temp_dir() . '/document_minification_' . uniqid(); 222 | while (file_exists($tempDir)) { 223 | usleep(1); 224 | $tempDir = sys_get_temp_dir() . '/document_minification_' . uniqid(); 225 | } 226 | if (!mkdir($tempDir)) { 227 | throw new DocumentMinificationException("Failed creating temporary directory at $tempDir."); 228 | } 229 | return $tempDir; 230 | } 231 | 232 | /** 233 | * Deletes all temporary files that the `DocumentMinifier` created. Can be used when an exception occurs 234 | * during (De)Minification to ensure no data is left over. 235 | */ 236 | public function cleanupCreatedFiles(): void 237 | { 238 | $this->recursivelyDeleteDirectory($this->tempDir); 239 | } 240 | 241 | private function extractZipTo(string $zippedDocumentPath, string $extractionDir, string $exceptionClass) 242 | { 243 | if (!is_dir($extractionDir)) { 244 | if (!mkdir($extractionDir, 0777, true)) { 245 | throw new $exceptionClass( 246 | "Exception when extracting document: Failed to create directory $extractionDir" 247 | ); 248 | } 249 | } 250 | $zip = new \ZipArchive(); 251 | $openResult = $zip->open($zippedDocumentPath); 252 | if ($openResult !== true) { 253 | throw new $exceptionClass( 254 | "Exception when extracting document: Failed to open $zippedDocumentPath as a ZIP file." 255 | ); 256 | } 257 | if (!$zip->extractTo($extractionDir)) { 258 | throw new $exceptionClass( 259 | "Exception when extracting document: Failed to extract $zippedDocumentPath to $extractionDir." 260 | ); 261 | } 262 | $zip->close(); 263 | } 264 | 265 | private function exportMediaToMediaDirAndReplace(string $inputDirectory, string $mediaDirectory) 266 | { 267 | $imageData = array(); 268 | $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($inputDirectory)); 269 | foreach ($iterator as $file) { 270 | if ($file->isFile() && array_key_exists($file->getExtension(), DocumentMinifier::SUPPORTED_MEDIA_FORMATS)) { 271 | $curFilePath = $file->getPathname(); 272 | $relativePath = substr($curFilePath, strlen($inputDirectory) + 1); 273 | $mediaPath = $mediaDirectory . '/' . $relativePath; 274 | $mediaDir = dirname($mediaPath); 275 | if (!file_exists($mediaDir)) { 276 | if (!mkdir($mediaDir, 0777, true)) { 277 | throw new DocumentMinificationException( 278 | "Exception when extracting document: Failed to create directory at $mediaDir." 279 | ); 280 | } 281 | } 282 | if (!rename($curFilePath, $mediaPath)) { 283 | throw new DocumentMinificationException( 284 | "Exception when backing up document media: Failed to move $curFilePath to $mediaPath." 285 | ); 286 | } 287 | if (!$this->storePlaceholderAt($curFilePath)) { 288 | throw new DocumentMinificationException( 289 | "Exception when minifying document: Failed to store replacement data at $curFilePath." 290 | ); 291 | } 292 | } 293 | } 294 | return $imageData; 295 | } 296 | 297 | private function storePlaceholderAt(string $filename): bool 298 | { 299 | $putContentsResp = file_put_contents($filename, 'DeepL Media Placeholder'); 300 | if ($putContentsResp === false) { 301 | return false; 302 | } 303 | return true; 304 | } 305 | 306 | private function replaceImagesInDir(string $directory, string $mediaDirectory) 307 | { 308 | $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($mediaDirectory)); 309 | foreach ($iterator as $file) { 310 | if ($file->isFile() && array_key_exists($file->getExtension(), DocumentMinifier::SUPPORTED_MEDIA_FORMATS)) { 311 | $relativePath = substr($file->getPathname(), strlen($mediaDirectory) + 1); 312 | $curMediumPath = $directory . '/' . $relativePath; 313 | $curMediumDir = dirname($curMediumPath); 314 | if (!file_exists($curMediumDir)) { 315 | if (!mkdir($curMediumDir, 0777, true)) { 316 | throw new DocumentDeminificationException( 317 | "Exception when reinserting images. Failed to create directory at $curMediumDir." 318 | ); 319 | } 320 | } 321 | if (!rename($file->getPathname(), $curMediumPath)) { 322 | throw new DocumentDeminificationException( 323 | "Exception when reinserting images. Failed to move media back to $curMediumPath." 324 | ); 325 | } 326 | } 327 | } 328 | } 329 | 330 | private function createZippedDocumentFromUnzippedDirectory( 331 | string $directory, 332 | string $outputFilePath, 333 | string $exceptionClass 334 | ) { 335 | $zip = new \ZipArchive(); 336 | $openResult = $zip->open($outputFilePath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); 337 | if ($openResult !== true) { 338 | throw new $exceptionClass("Failed creating a zip file at $outputFilePath"); 339 | } 340 | $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory)); 341 | foreach ($files as $_ => $file) { 342 | if ($file->isFile()) { 343 | if (!$zip->addFile($file, str_replace($directory . '/', '', $file))) { 344 | $filePathname = $file->getPathname(); 345 | throw new $exceptionClass("Failed adding file at $filePathname to zip at $outputFilePath."); 346 | } 347 | } 348 | } 349 | if (!$zip->close()) { 350 | throw new $exceptionClass("Failed closing ZIP file at $outputFilePath."); 351 | } 352 | } 353 | 354 | private static function getFileExtension(string $filePath) 355 | { 356 | return pathinfo($filePath, PATHINFO_EXTENSION); 357 | } 358 | 359 | public static function recursivelyDeleteDirectory( 360 | string $dir, 361 | $exceptionToThrow = DocumentMinificationException::class 362 | ) { 363 | if (is_dir($dir)) { 364 | $objects = scandir($dir); 365 | if ($objects === false) { 366 | throw new $exceptionToThrow( 367 | "Failed scanning directory $dir when recursively deleting that directory." 368 | ); 369 | } 370 | foreach ($objects as $object) { 371 | if ($object !== '.' && $object !== '..') { 372 | if (filetype($dir . '/' . $object) === 'dir') { 373 | DocumentMinifier::recursivelyDeleteDirectory($dir . '/' . $object, $exceptionToThrow); 374 | } else { 375 | $objectToDelete = $dir . '/' . $object; 376 | if (!unlink($objectToDelete)) { 377 | throw new $exceptionToThrow( 378 | "Failed deleting $objectToDelete when recursively deleting directory $dir" 379 | ); 380 | } 381 | } 382 | } 383 | } 384 | reset($objects); 385 | if (!rmdir($dir)) { 386 | throw new $exceptionToThrow( 387 | "Failed deleting empty directory $dir when recursively deleting that directory" 388 | ); 389 | } 390 | } 391 | } 392 | } 393 | -------------------------------------------------------------------------------- /src/Translator.php: -------------------------------------------------------------------------------- 1 | "DeepL-Auth-Key $authKey", 56 | 'User-Agent' => self::constructUserAgentString( 57 | $options[TranslatorOptions::SEND_PLATFORM_INFO] ?? true, 58 | $options[TranslatorOptions::APP_INFO] ?? null 59 | ), 60 | ], 61 | $options[TranslatorOptions::HEADERS] ?? [] 62 | ); 63 | 64 | $timeout = $options[TranslatorOptions::TIMEOUT] ?? TranslatorOptions::DEFAULT_TIMEOUT; 65 | 66 | $maxRetries = $options[TranslatorOptions::MAX_RETRIES] ?? TranslatorOptions::DEFAULT_MAX_RETRIES; 67 | 68 | $logger = $options[TranslatorOptions::LOGGER] ?? null; 69 | 70 | $proxy = $options[TranslatorOptions::PROXY] ?? null; 71 | 72 | $http_client = $options[TranslatorOptions::HTTP_CLIENT] ?? null; 73 | 74 | $this->client = new HttpClientWrapper( 75 | $serverUrl, 76 | $headers, 77 | $timeout, 78 | $maxRetries, 79 | $logger, 80 | $proxy, 81 | $http_client 82 | ); 83 | } 84 | 85 | /** 86 | * Queries character and document usage during the current billing period. 87 | * @return Usage 88 | * @throws DeepLException 89 | */ 90 | public function getUsage(): Usage 91 | { 92 | $response = $this->client->sendRequestWithBackoff('GET', '/v2/usage'); 93 | $this->checkStatusCode($response); 94 | list(, $content) = $response; 95 | return new Usage($content); 96 | } 97 | 98 | /** 99 | * Queries source languages supported by the DeepL API. 100 | * @return Language[] Array of Language objects containing available source languages. 101 | * @throws DeepLException 102 | */ 103 | public function getSourceLanguages(): array 104 | { 105 | return $this->getLanguages(false); 106 | } 107 | 108 | /** 109 | * Queries target languages supported by the DeepL API. 110 | * @return Language[] Array of Language objects containing available target languages. 111 | * @throws DeepLException 112 | */ 113 | public function getTargetLanguages(): array 114 | { 115 | return $this->getLanguages(true); 116 | } 117 | 118 | /** 119 | * Queries languages supported for glossaries by the DeepL API. 120 | * @return GlossaryLanguagePair[] Array of GlossaryLanguagePair objects containing available glossary languages. 121 | * @throws DeepLException 122 | */ 123 | public function getGlossaryLanguages(): array 124 | { 125 | $response = $this->client->sendRequestWithBackoff('GET', '/v2/glossary-language-pairs'); 126 | $this->checkStatusCode($response); 127 | list(, $content) = $response; 128 | 129 | try { 130 | $decoded = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); 131 | } catch (JsonException $exception) { 132 | throw new InvalidContentException($exception); 133 | } 134 | 135 | $result = []; 136 | foreach ($decoded['supported_languages'] as $lang) { 137 | $sourceLang = $lang['source_lang']; 138 | $targetLang = $lang['target_lang']; 139 | $result[] = new GlossaryLanguagePair($sourceLang, $targetLang); 140 | } 141 | return $result; 142 | } 143 | 144 | /** 145 | * Translates specified text string or array of text strings into the target language. 146 | * @param string|string[] $texts A single string or array of strings containing input texts to translate. 147 | * @param string|null $sourceLang Language code of input text language, or null to use auto-detection. 148 | * @param string $targetLang Language code of language to translate into. 149 | * @param array $options Translation options to apply. See \DeepL\TranslateTextOptions. 150 | * @return TextResult|TextResult[] A TextResult or array of TextResult objects containing translated texts. 151 | * @phpstan-return ($texts is array ? TextResult[] : TextResult) 152 | * @throws DeepLException 153 | * @see \DeepL\TranslateTextOptions 154 | */ 155 | public function translateText($texts, ?string $sourceLang, string $targetLang, array $options = []) 156 | { 157 | $params = $this->buildBodyParams( 158 | $sourceLang, 159 | $targetLang, 160 | $options[TranslateTextOptions::FORMALITY] ?? null, 161 | $options[TranslateTextOptions::GLOSSARY] ?? null 162 | ); 163 | // Always send show_billed_characters=1, remove when the API default is changed to true 164 | $params["show_billed_characters"] = "1"; 165 | $this->validateAndAppendTexts($params, $texts); 166 | $this->validateAndAppendTextOptions($params, $options); 167 | 168 | $response = $this->client->sendRequestWithBackoff( 169 | 'POST', 170 | '/v2/translate', 171 | [HttpClientWrapper::OPTION_PARAMS => $params] 172 | ); 173 | $this->checkStatusCode($response); 174 | 175 | list(, $content) = $response; 176 | 177 | // Deepl API responses might have invalid UTF8 sequence 178 | // @see https://github.com/DeepLcom/deepl-php/pull/43 179 | mb_substitute_character(0xFFFD); 180 | $content = mb_convert_encoding($content, 'UTF-8', 'UTF-8'); 181 | 182 | try { 183 | $decoded = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); 184 | } catch (JsonException $exception) { 185 | throw new InvalidContentException($exception); 186 | } 187 | 188 | $textResults = []; 189 | foreach ($decoded['translations'] as $textResult) { 190 | $textField = $textResult['text']; 191 | $detectedSourceLang = $textResult['detected_source_language']; 192 | $billedCharacters = $textResult['billed_characters']; 193 | $modelTypeUsed = $textResult['model_type_used'] ?? null; 194 | $textResults[] = new TextResult($textField, $detectedSourceLang, $billedCharacters, $modelTypeUsed); 195 | } 196 | return is_array($texts) ? $textResults : $textResults[0]; 197 | } 198 | 199 | /** 200 | * Uploads specified document to DeepL to translate into given target language, waits for translation to complete, 201 | * then downloads translated document to specified output path. 202 | * @param string $inputFile String containing file path of document to be translated. 203 | * @param string $outputFile String containing file path to create translated document. 204 | * @param string|null $sourceLang Language code of input document, or null to use auto-detection. 205 | * @param string $targetLang Language code of language to translate into. 206 | * @param array $options Translation options to apply. See \DeepL\TranslateDocumentOptions. 207 | * @return DocumentStatus DocumentStatus object for the completed translation. You can use the billedCharacters 208 | * property to check how many characters were billed for the document. 209 | * @throws DocumentTranslationException If a file already exists at the output file path, or if any error occurs 210 | * during document upload, translation or download. The `documentHandle` property of the exception, if not null, 211 | * may be used to recover the document ID and key of an in-progress translation. 212 | * @see \DeepL\TranslateDocumentOptions 213 | */ 214 | public function translateDocument( 215 | string $inputFile, 216 | string $outputFile, 217 | ?string $sourceLang, 218 | string $targetLang, 219 | array $options = [] 220 | ): DocumentStatus { 221 | $handle = null; 222 | if (file_exists($outputFile)) { 223 | throw new DocumentTranslationException("File already exists at output file path $outputFile"); 224 | } 225 | try { 226 | $minifier = null; 227 | $willMinify = ($options[TranslateDocumentOptions::ENABLE_DOCUMENT_MINIFICATION] ?? false) && 228 | DocumentMinifier::canMinifyFile($inputFile); 229 | $fileToUpload = $inputFile; 230 | if ($willMinify) { 231 | $minifier = new DocumentMinifier(); 232 | $minifier->minifyDocument($inputFile, true); 233 | $fileToUpload = $minifier->getMinifiedDocFile($inputFile); 234 | } 235 | $handle = $this->uploadDocument($fileToUpload, $sourceLang, $targetLang, $options); 236 | $status = $this->waitUntilDocumentTranslationComplete($handle); 237 | $this->downloadDocument($handle, $outputFile); 238 | if ($willMinify) { 239 | // Translated minified file is at `$outputFile`. Reinsert media (deminify) before returning 240 | $minifier->deminifyDocument($outputFile, $outputFile, true); 241 | } 242 | return $status; 243 | } catch (DeepLException $error) { 244 | if (file_exists($outputFile)) { 245 | unlink($outputFile); 246 | } 247 | $message = 'Error occurred while translating document: ' . ($error->getMessage() ?? 'unknown error'); 248 | throw new DocumentTranslationException($message, $error->getCode(), $error, $handle); 249 | } 250 | } 251 | 252 | /** 253 | * Uploads specified document to DeepL to translate into target language, and returns handle associated with the 254 | * document. 255 | * @param string $inputFile String containing file path of document to be translated. 256 | * @param string|null $sourceLang Language code of input document, or null to use auto-detection. 257 | * @param string $targetLang Language code of language to translate into. 258 | * @param array $options Translation options to apply. See \DeepL\TranslateDocumentOptions. 259 | * @return DocumentHandle Handle associated with the in-progress document translation. 260 | * @throws DeepLException 261 | */ 262 | public function uploadDocument( 263 | string $inputFile, 264 | ?string $sourceLang, 265 | string $targetLang, 266 | array $options = [] 267 | ): DocumentHandle { 268 | $params = $this->buildBodyParams( 269 | $sourceLang, 270 | $targetLang, 271 | $options[TranslateDocumentOptions::FORMALITY] ?? null, 272 | $options[TranslateDocumentOptions::GLOSSARY] ?? null 273 | ); 274 | 275 | $this->applyExtraBodyParameters( 276 | $params, 277 | $options[TranslateDocumentOptions::EXTRA_BODY_PARAMETERS] ?? null 278 | ); 279 | 280 | $response = $this->client->sendRequestWithBackoff( 281 | 'POST', 282 | '/v2/document', 283 | [ 284 | HttpClientWrapper::OPTION_PARAMS => $params, 285 | HttpClientWrapper::OPTION_FILE => $inputFile, 286 | ] 287 | ); 288 | $this->checkStatusCode($response); 289 | 290 | list(, $content) = $response; 291 | try { 292 | $json = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); 293 | } catch (JsonException $exception) { 294 | throw new InvalidContentException($exception); 295 | } 296 | 297 | $documentId = $json['document_id']; 298 | $documentKey = $json['document_key']; 299 | return new DocumentHandle($documentId, $documentKey); 300 | } 301 | 302 | /** 303 | * Retrieves the status of the document translation associated with the given document handle. 304 | * @param DocumentHandle $handle Document handle associated with document. 305 | * @return DocumentStatus The document translation status. 306 | * @throws DeepLException 307 | */ 308 | public function getDocumentStatus(DocumentHandle $handle): DocumentStatus 309 | { 310 | $response = $this->client->sendRequestWithBackoff( 311 | 'POST', 312 | "/v2/document/$handle->documentId", 313 | [HttpClientWrapper::OPTION_PARAMS => ['document_key' => $handle->documentKey]] 314 | ); 315 | $this->checkStatusCode($response); 316 | list(, $content) = $response; 317 | return new DocumentStatus($content); 318 | } 319 | 320 | /** 321 | * Downloads the translated document associated with the given document handle to the specified output file path. 322 | * @param DocumentHandle $handle Document handle associated with document. 323 | * @param string $outputFile String containing file path to create translated document. 324 | * @throws DeepLException 325 | */ 326 | public function downloadDocument(DocumentHandle $handle, string $outputFile): void 327 | { 328 | if (file_exists($outputFile)) { 329 | throw new DeepLException("File already exists at output file path $outputFile"); 330 | } 331 | try { 332 | $response = $this->client->sendRequestWithBackoff( 333 | 'POST', 334 | "/v2/document/$handle->documentId/result", 335 | [ 336 | HttpClientWrapper::OPTION_PARAMS => ['document_key' => $handle->documentKey], 337 | HttpClientWrapper::OPTION_OUTFILE => $outputFile, 338 | ] 339 | ); 340 | $this->checkStatusCode($response, true); 341 | } catch (DeepLException $error) { 342 | if (file_exists($outputFile)) { 343 | unlink($outputFile); 344 | } 345 | throw $error; 346 | } 347 | } 348 | 349 | /** 350 | * Returns when the given document translation completes, or throws an exception if there was an error 351 | * communicating with the DeepL API or the document translation failed. 352 | * @param DocumentHandle $handle Handle to the document translation. 353 | * @return DocumentStatus DocumentStatus object for the completed translation. You can use the billedCharacters 354 | * property to check how many characters were billed for the document. 355 | * @throws DeepLException 356 | */ 357 | public function waitUntilDocumentTranslationComplete(DocumentHandle $handle): DocumentStatus 358 | { 359 | $status = $this->getDocumentStatus($handle); 360 | while (!$status->done() && $status->ok()) { 361 | // secondsRemaining is currently unreliable, so just poll equidistantly 362 | $secs = 5.0; 363 | usleep($secs * 1000000); 364 | $this->client->logInfo("Rechecking document translation status after sleeping for $secs seconds."); 365 | $status = $this->getDocumentStatus($handle); 366 | } 367 | if (!$status->ok()) { 368 | throw new DeepLException($status->errorMessage ?? 'unknown error'); 369 | } 370 | return $status; 371 | } 372 | 373 | /** 374 | * Creates a new glossary on DeepL server with given name, languages, and entries. 375 | * @param string $name User-defined name to assign to the glossary. 376 | * @param string $sourceLang Language code of the glossary source terms. 377 | * @param string $targetLang Language code of the glossary target terms. 378 | * @param GlossaryEntries $entries The source- & target-term pairs to add to the glossary. 379 | * @return GlossaryInfo Details about the created glossary. 380 | * @throws DeepLException 381 | */ 382 | public function createGlossary( 383 | string $name, 384 | string $sourceLang, 385 | string $targetLang, 386 | GlossaryEntries $entries 387 | ): GlossaryInfo { 388 | // Glossaries are only supported for base language types 389 | $sourceLang = LanguageCode::removeRegionalVariant($sourceLang); 390 | $targetLang = LanguageCode::removeRegionalVariant($targetLang); 391 | 392 | if (strlen($name) === 0) { 393 | throw new DeepLException('glossary name must be a non-empty string'); 394 | } 395 | 396 | $params = [ 397 | 'name' => $name, 398 | 'source_lang' => $sourceLang, 399 | 'target_lang' => $targetLang, 400 | 'entries_format' => 'tsv', 401 | 'entries' => $entries->convertToTsv(), 402 | ]; 403 | 404 | $response = $this->client->sendRequestWithBackoff( 405 | 'POST', 406 | '/v2/glossaries', 407 | [HttpClientWrapper::OPTION_PARAMS => $params] 408 | ); 409 | $this->checkStatusCode($response, false, true); 410 | list(, $content) = $response; 411 | return GlossaryInfo::parse($content); 412 | } 413 | 414 | /** 415 | * Creates a new glossary on DeepL server with given name, languages, and entries. 416 | * @param string $name User-defined name to assign to the glossary. 417 | * @param string $sourceLang Language code of the glossary source terms. 418 | * @param string $targetLang Language code of the glossary target terms. 419 | * @param string $csvContent String containing CSV content. 420 | * @return GlossaryInfo Details about the created glossary. 421 | * @throws DeepLException 422 | */ 423 | public function createGlossaryFromCsv( 424 | string $name, 425 | string $sourceLang, 426 | string $targetLang, 427 | string $csvContent 428 | ): GlossaryInfo { 429 | // Glossaries are only supported for base language types 430 | $sourceLang = LanguageCode::removeRegionalVariant($sourceLang); 431 | $targetLang = LanguageCode::removeRegionalVariant($targetLang); 432 | 433 | if (strlen($name) === 0) { 434 | throw new DeepLException('glossary name must be a non-empty string'); 435 | } 436 | 437 | $params = [ 438 | 'name' => $name, 439 | 'source_lang' => $sourceLang, 440 | 'target_lang' => $targetLang, 441 | 'entries_format' => 'csv', 442 | 'entries' => $csvContent, 443 | ]; 444 | 445 | $response = $this->client->sendRequestWithBackoff( 446 | 'POST', 447 | '/v2/glossaries', 448 | [HttpClientWrapper::OPTION_PARAMS => $params] 449 | ); 450 | $this->checkStatusCode($response, false, true); 451 | list(, $content) = $response; 452 | return GlossaryInfo::parse($content); 453 | } 454 | 455 | /** 456 | * Gets information about an existing glossary. 457 | * @param string $glossaryId Glossary ID of the glossary. 458 | * @return GlossaryInfo GlossaryInfo containing details about the glossary. 459 | * @throws DeepLException 460 | */ 461 | public function getGlossary(string $glossaryId): GlossaryInfo 462 | { 463 | $response = $this->client->sendRequestWithBackoff('GET', "/v2/glossaries/$glossaryId"); 464 | $this->checkStatusCode($response, false, true); 465 | list(, $content) = $response; 466 | return GlossaryInfo::parse($content); 467 | } 468 | 469 | /** 470 | * Gets information about all existing glossaries. 471 | * @return GlossaryInfo[] Array of GlossaryInfos containing details about all existing glossaries. 472 | * @throws DeepLException 473 | */ 474 | public function listGlossaries(): array 475 | { 476 | $response = $this->client->sendRequestWithBackoff('GET', '/v2/glossaries'); 477 | $this->checkStatusCode($response, false, true); 478 | list(, $content) = $response; 479 | return GlossaryInfo::parseList($content); 480 | } 481 | 482 | /** 483 | * Retrieves the entries stored with the glossary with the given glossary ID or GlossaryInfo. 484 | * @param string|GlossaryInfo $glossary Glossary ID or GlossaryInfo of glossary to retrieve entries of. 485 | * @return GlossaryEntries The entries stored in the glossary. 486 | * @throws DeepLException 487 | */ 488 | public function getGlossaryEntries($glossary): GlossaryEntries 489 | { 490 | $glossaryId = is_string($glossary) ? $glossary : $glossary->glossaryId; 491 | $response = $this->client->sendRequestWithBackoff('GET', "/v2/glossaries/$glossaryId/entries"); 492 | $this->checkStatusCode($response, false, true); 493 | list(, $content) = $response; 494 | return GlossaryEntries::fromTsv($content); 495 | } 496 | 497 | /** 498 | * Deletes the glossary with the given glossary ID or GlossaryInfo. 499 | * @param string|GlossaryInfo $glossary Glossary ID or GlossaryInfo of glossary to be deleted. 500 | * @throws DeepLException 501 | */ 502 | public function deleteGlossary($glossary): void 503 | { 504 | $glossaryId = is_string($glossary) ? $glossary : $glossary->glossaryId; 505 | $response = $this->client->sendRequestWithBackoff('DELETE', "/v2/glossaries/$glossaryId"); 506 | $this->checkStatusCode($response, false, true); 507 | } 508 | 509 | /** 510 | * Queries source or target languages supported by the DeepL API. 511 | * @param bool $target Query target languages if true, source languages otherwise. 512 | * @return Language[] Array of Language objects containing available languages. 513 | * @throws DeepLException 514 | */ 515 | private function getLanguages(bool $target): array 516 | { 517 | $response = $this->client->sendRequestWithBackoff( 518 | 'GET', 519 | '/v2/languages', 520 | [HttpClientWrapper::OPTION_PARAMS => ['type' => $target ? 'target' : 'source']] 521 | ); 522 | $this->checkStatusCode($response); 523 | list(, $content) = $response; 524 | 525 | try { 526 | $decoded = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); 527 | } catch (JsonException $exception) { 528 | throw new InvalidContentException($exception); 529 | } 530 | 531 | $result = []; 532 | foreach ($decoded as $lang) { 533 | $name = $lang['name']; 534 | $code = $lang['language']; 535 | $supportsFormality = array_key_exists('supports_formality', $lang) ? 536 | $lang['supports_formality'] : null; 537 | $result[] = new Language($name, $code, $supportsFormality); 538 | } 539 | return $result; 540 | } 541 | 542 | /** 543 | * Joins given TagList with commas to form a single comma-delimited string. 544 | * @param string[]|string $tagList List of tags to join. 545 | * @return string Tags combined into a comma-delimited string. 546 | */ 547 | private function joinTagList($tagList): string 548 | { 549 | if (is_string($tagList)) { 550 | return $tagList; 551 | } else { 552 | return implode(',', $tagList); 553 | } 554 | } 555 | 556 | /** 557 | * Returns '1' if the argument is truthy, otherwise '0'. 558 | * @param mixed $arg Argument to check. 559 | * @return string '1' or '0'. 560 | */ 561 | private function toBoolString($arg): string 562 | { 563 | return $arg ? '1' : '0'; 564 | } 565 | 566 | /** 567 | * Validates and prepares HTTP parameters for arguments common to text and document translation. 568 | * @param string|null $sourceLang Source language code, or null to use auto-detection. 569 | * @param string $targetLang Target language code. 570 | * @param string|null $formality Formality option, or null if not specified. 571 | * @param string|GlossaryInfo|MultilingualGlossaryInfo|null $glossary Glossary ID, GlossaryInfo, 572 | * MultilingualGlossaryInfo, or null if not specified. 573 | * @return array Associative array of HTTP parameters. 574 | * @throws DeepLException 575 | */ 576 | private function buildBodyParams( 577 | ?string $sourceLang, 578 | string $targetLang, 579 | ?string $formality, 580 | $glossary 581 | ): array { 582 | $targetLang = LanguageCode::standardizeLanguageCode($targetLang); 583 | if (isset($sourceLang)) { 584 | $sourceLang = LanguageCode::standardizeLanguageCode($sourceLang); 585 | } 586 | 587 | if ($targetLang === 'en') { 588 | throw new DeepLException('targetLang="en" is deprecated, please use "en-GB" or "en-US" instead.'); 589 | } elseif ($targetLang === 'pt') { 590 | throw new DeepLException('targetLang="pt" is deprecated, please use "pt-PT" or "pt-BR" instead.'); 591 | } 592 | 593 | $params = ['target_lang' => $targetLang]; 594 | if (isset($sourceLang)) { 595 | $params['source_lang'] = $sourceLang; 596 | } 597 | if (isset($formality)) { 598 | $formality_str = strtolower($formality); 599 | $params['formality'] = $formality_str; 600 | } 601 | if (isset($glossary)) { 602 | if (!isset($sourceLang)) { 603 | throw new DeepLException('sourceLang is required if using a glossary'); 604 | } 605 | if (!is_string($glossary)) { 606 | $glossary = $glossary->glossaryId; 607 | } 608 | $params['glossary_id'] = $glossary; 609 | } 610 | return $params; 611 | } 612 | 613 | /** 614 | * Validates and appends texts to HTTP request parameters. 615 | * @param array $params Parameters for HTTP request. 616 | * @param string|string[] $texts User-supplied texts to be checked. 617 | * @throws DeepLException 618 | */ 619 | protected function validateAndAppendTexts(array &$params, $texts) 620 | { 621 | if (is_array($texts)) { 622 | foreach ($texts as $text) { 623 | if (!is_string($text)) { 624 | throw new DeepLException( 625 | 'texts parameter must be a string or array of strings', 626 | ); 627 | } 628 | } 629 | } else { 630 | if (!is_string($texts)) { 631 | throw new DeepLException( 632 | 'texts parameter must be a string or array of strings', 633 | ); 634 | } 635 | } 636 | $params['text'] = $texts; 637 | } 638 | 639 | /** 640 | * Validates and appends text options to HTTP request parameters. 641 | * @param array $params Parameters for HTTP request. 642 | * @param array|null $options Options for translate text request. 643 | * Note the formality and glossary options are handled separately, because these options overlap with document 644 | * translation. 645 | * @throws DeepLException 646 | */ 647 | private function validateAndAppendTextOptions(array &$params, ?array $options): void 648 | { 649 | if ($options === null) { 650 | return; 651 | } 652 | if (isset($options[TranslateTextOptions::SPLIT_SENTENCES])) { 653 | $split_sentences = strtolower($options[TranslateTextOptions::SPLIT_SENTENCES]); 654 | switch ($split_sentences) { 655 | case 'on': 656 | case 'default': 657 | $params[TranslateTextOptions::SPLIT_SENTENCES] = '1'; 658 | break; 659 | case 'off': 660 | $params[TranslateTextOptions::SPLIT_SENTENCES] = '0'; 661 | break; 662 | default: 663 | $params[TranslateTextOptions::SPLIT_SENTENCES] = $split_sentences; 664 | break; 665 | } 666 | } 667 | if (isset($options[TranslateTextOptions::PRESERVE_FORMATTING])) { 668 | $params[TranslateTextOptions::PRESERVE_FORMATTING] = 669 | $this->toBoolString($options[TranslateTextOptions::PRESERVE_FORMATTING]); 670 | } 671 | if (isset($options[TranslateTextOptions::TAG_HANDLING])) { 672 | $params[TranslateTextOptions::TAG_HANDLING] = $options[TranslateTextOptions::TAG_HANDLING]; 673 | } 674 | if (isset($options[TranslateTextOptions::TAG_HANDLING_VERSION])) { 675 | $params[TranslateTextOptions::TAG_HANDLING_VERSION] = $options[TranslateTextOptions::TAG_HANDLING_VERSION]; 676 | } 677 | if (isset($options[TranslateTextOptions::OUTLINE_DETECTION])) { 678 | $params[TranslateTextOptions::OUTLINE_DETECTION] = 679 | $this->toBoolString($options[TranslateTextOptions::OUTLINE_DETECTION]); 680 | } 681 | if (isset($options[TranslateTextOptions::CONTEXT])) { 682 | $params[TranslateTextOptions::CONTEXT] = $options[TranslateTextOptions::CONTEXT]; 683 | } 684 | if (isset($options[TranslateTextOptions::MODEL_TYPE])) { 685 | $params[TranslateTextOptions::MODEL_TYPE] = $options[TranslateTextOptions::MODEL_TYPE]; 686 | } 687 | if (isset($options[TranslateTextOptions::NON_SPLITTING_TAGS])) { 688 | $params[TranslateTextOptions::NON_SPLITTING_TAGS] = 689 | $this->joinTagList($options[TranslateTextOptions::NON_SPLITTING_TAGS]); 690 | } 691 | if (isset($options[TranslateTextOptions::SPLITTING_TAGS])) { 692 | $params[TranslateTextOptions::SPLITTING_TAGS] = 693 | $this->joinTagList($options[TranslateTextOptions::SPLITTING_TAGS]); 694 | } 695 | if (isset($options[TranslateTextOptions::IGNORE_TAGS])) { 696 | $params[TranslateTextOptions::IGNORE_TAGS] = 697 | $this->joinTagList($options[TranslateTextOptions::IGNORE_TAGS]); 698 | } 699 | if (isset($options[TranslateTextOptions::STYLE_ID])) { 700 | $styleRule = $options[TranslateTextOptions::STYLE_ID]; 701 | if (is_string($styleRule)) { 702 | $params['style_id'] = $styleRule; 703 | } elseif ($styleRule instanceof StyleRuleInfo) { 704 | $params['style_id'] = $styleRule->styleId; 705 | } else { 706 | throw new DeepLException('style_id must be a string or StyleRuleInfo object'); 707 | } 708 | } 709 | if (isset($options[TranslateTextOptions::CUSTOM_INSTRUCTIONS])) { 710 | $params[TranslateTextOptions::CUSTOM_INSTRUCTIONS] = $options[TranslateTextOptions::CUSTOM_INSTRUCTIONS]; 711 | } 712 | $this->applyExtraBodyParameters( 713 | $params, 714 | $options[TranslateTextOptions::EXTRA_BODY_PARAMETERS] ?? null 715 | ); 716 | } 717 | 718 | /** 719 | * Adds extra body parameters to the params array. Extra parameters can override existing keys. 720 | * Values are converted to strings. 721 | */ 722 | private function applyExtraBodyParameters(array &$params, ?array $extraParams): void 723 | { 724 | if ($extraParams !== null) { 725 | foreach ($extraParams as $key => $value) { 726 | $params[$key] = (string)$value; 727 | } 728 | } 729 | } 730 | 731 | /** 732 | * Checks the HTTP status code, and in case of failure, throws an exception with diagnostic information. 733 | * @throws DeepLException 734 | */ 735 | protected function checkStatusCode(array $response, bool $inDocumentDownload = false, bool $usingGlossary = false) 736 | { 737 | list($statusCode, $content) = $response; 738 | 739 | if (200 <= $statusCode && $statusCode < 400) { 740 | return; 741 | } 742 | 743 | $message = ''; 744 | try { 745 | $json = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); 746 | if (isset($json['message'])) { 747 | $message .= ", message: {$json['message']}"; 748 | } 749 | if (isset($json['detail'])) { 750 | $message .= ", detail: {$json['detail']}"; 751 | } 752 | } catch (\Exception $e) { 753 | // JSON parsing errors are ignored, and we fall back to the raw response 754 | $message = ", $content"; 755 | } 756 | 757 | switch ($statusCode) { 758 | case 403: 759 | throw new AuthorizationException("Authorization failure, check authentication key$message"); 760 | case 456: 761 | throw new QuotaExceededException("Quota for this billing period has been exceeded$message"); 762 | case 404: 763 | if ($usingGlossary) { 764 | throw new GlossaryNotFoundException("Glossary not found$message"); 765 | } 766 | throw new NotFoundException("Not found, check server_url$message"); 767 | case 400: 768 | throw new DeepLException("Bad request$message"); 769 | case 429: 770 | throw new TooManyRequestsException( 771 | "Too many requests, DeepL servers are currently experiencing high load$message" 772 | ); 773 | case 503: 774 | if ($inDocumentDownload) { 775 | throw new DocumentNotReadyException("Document not ready$message"); 776 | } else { 777 | throw new DeepLException("Service unavailable$message"); 778 | } 779 | break; // break required by phpcs although it is unnecessary 780 | default: 781 | throw new DeepLException( 782 | "Unexpected status code: $statusCode $message, content: $content." 783 | ); 784 | } 785 | } 786 | 787 | /** 788 | * Returns true if the specified DeepL Authentication Key is associated with a free account, 789 | * otherwise false. 790 | * @param string $authKey The authentication key to check. 791 | * @return bool True if the key is associated with a free account, otherwise false. 792 | */ 793 | public static function isAuthKeyFreeAccount(string $authKey): bool 794 | { 795 | return substr($authKey, -3) === ':fx'; 796 | } 797 | 798 | private static function constructUserAgentString(bool $sendPlatformInfo, ?AppInfo $appInfo): string 799 | { 800 | $libraryVersion = self::VERSION; 801 | $libraryInfoStr = "deepl-php/$libraryVersion"; 802 | try { 803 | if ($sendPlatformInfo) { 804 | $platformStr = php_uname('s') . ' ' . php_uname('r') . ' ' . php_uname('v') . php_uname('m'); 805 | $phpVersion = phpversion(); 806 | $libraryInfoStr .= " ($platformStr) php/$phpVersion"; 807 | $curlVer = curl_version()['version']; 808 | $libraryInfoStr .= " curl/$curlVer"; 809 | } 810 | if (!is_null($appInfo)) { 811 | $libraryInfoStr .= " $appInfo->appName/$appInfo->appVersion"; 812 | } 813 | } catch (\Exception $e) { 814 | // Do not fail request, simply send req with an incomplete user agent string 815 | } 816 | return $libraryInfoStr; 817 | } 818 | } 819 | --------------------------------------------------------------------------------