diff --git a/composer.json b/composer.json index aa5ded6f..f5ee58ce 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "laminas/laminas-stdlib": "^2.7 || ^3.0", "laminas/laminas-validator": "^2.10.2", "laminas/laminas-zendframework-bridge": "^1.0", + "symfony/polyfill-mbstring": "^1.12.0", "true/punycode": "^2.1" }, "require-dev": { diff --git a/src/Header/ContentDisposition.php b/src/Header/ContentDisposition.php index 60031f7d..f3b4dad2 100644 --- a/src/Header/ContentDisposition.php +++ b/src/Header/ContentDisposition.php @@ -154,26 +154,34 @@ public function getFieldValue($format = HeaderInterface::FORMAT_RAW) } } else { // Use 'continuation' per RFC 2231 - $maxValueLength = strlen($value); - do { - $maxValueLength = ceil(0.6 * $maxValueLength); - } while ($maxValueLength > self::MAX_PARAMETER_LENGTH); - if ($valueIsEncoded) { - $encodedLength = strlen($value); $value = HeaderWrap::mimeDecodeValue($value); - $decodedLength = strlen($value); - $maxValueLength -= ($encodedLength - $decodedLength); } - $valueParts = str_split($value, $maxValueLength); $i = 0; - foreach ($valueParts as $valuePart) { - $attributePart = $attribute . '*' . $i++; - if ($valueIsEncoded) { - $valuePart = $this->getEncodedValue($valuePart); + $fullLength = mb_strlen($value, 'UTF-8'); + while ($fullLength > 0) { + $attributePart = $attribute . '*' . $i++ . '="'; + $attLen = mb_strlen($attributePart, 'UTF-8'); + + $subPos = 1; + $valuePart = ''; + while ($subPos <= $fullLength) { + $sub = mb_substr($value, 0, $subPos, 'UTF-8'); + if ($valueIsEncoded) { + $sub = $this->getEncodedValue($sub); + } + if ($attLen + mb_strlen($sub, 'UTF-8') >= self::MAX_PARAMETER_LENGTH) { + $subPos--; + break; + } + $subPos++; + $valuePart = $sub; } - $result .= sprintf(';%s%s="%s"', Headers::FOLDING, $attributePart, $valuePart); + + $value = mb_substr($value, $subPos, null, 'UTF-8'); + $fullLength = mb_strlen($value, 'UTF-8'); + $result .= ';' . Headers::FOLDING . $attributePart . $valuePart . '"'; } } } diff --git a/test/Header/ContentDispositionTest.php b/test/Header/ContentDispositionTest.php index 5a4b2140..41f1d842 100644 --- a/test/Header/ContentDispositionTest.php +++ b/test/Header/ContentDispositionTest.php @@ -190,12 +190,20 @@ public function setDispositionProvider(): array // @codingStandardsIgnoreStart $foldingFieldValue = "attachment;\r\n filename=\"this-test-filename-is-long-enough-to-flow-to-two-lines.txt\""; $foldingHeaderLine = "Content-Disposition: $foldingFieldValue"; - $continuationFieldValue = "attachment;\r\n filename*0=\"this-file-name-is-so-long-that-it-does-not-even\";\r\n filename*1=\"-fit-on-a-whole-line-by-itself-so-we-need-to-sp\";\r\n filename*2=\"lit-it-with-value-continuation.txt\""; + $continuationFieldValue = "attachment;\r\n filename*0=\"this-file-name-is-so-long-that-it-does-not-even-fit-on-a-whole-\";\r\n filename*1=\"line-by-itself-so-we-need-to-split-it-with-value-continuation.t\";\r\n filename*2=\"xt\""; $continuationHeaderLine = "Content-Disposition: $continuationFieldValue"; $encodedHeaderLine = 'Content-Disposition: attachment; filename="=?UTF-8?Q?=C3=93?="'; $encodedFieldValue = 'attachment; filename="Ó"'; + $multibyteFilename = '办公.xlsx'; + $multibyteFieldValue = "attachment;\r\n filename=\"=?UTF-8?Q?=E5=8A=9E=E5=85=AC.xlsx?=\""; + $multibyteHeaderLine = "Content-Disposition: $multibyteFieldValue"; + + $multibyteContinuationFilename = '办公用品预约Apply for office supplies online.xlsx'; + $multibyteContinuationFieldValue = "attachment;\r\n filename*0=\"=?UTF-8?Q?=E5=8A=9E=E5=85=AC=E7=94=A8=E5=93=81=E9=A2=84?=\";\r\n filename*1=\"=?UTF-8?Q?=E7=BA=A6Apply=20for=20office=20supplies=20online.x?=\";\r\n filename*2=\"=?UTF-8?Q?lsx?=\""; + $multibyteContinuationHeaderLine = "Content-Disposition: $multibyteContinuationFieldValue"; + return [ // Description => [$disposition, $parameters, $fieldValue, toString()] 'inline with no parameters' => ['inline', [], 'inline', 'Content-Disposition: inline'], @@ -218,8 +226,20 @@ public function setDispositionProvider(): array 'UTF-8 continuation' => [ 'attachment', ['filename' => 'this-file-name-is-so-long-that-it-does-not-even-fit-on-a-whole-line-by-itself-so-we-need-to-split-it-with-value-continuation.also-UTF-8-characters-hērē.txt'], - "attachment;\r\n filename*0=\"this-file-name-is-so-long-that-it-does-not-even-fit-on-a-\";\r\n filename*1=\"whole-line-by-itself-so-we-need-to-split-it-with-value-co\";\r\n filename*2=\"ntinuation.also-UTF-8-characters-hērē.txt\"", - "Content-Disposition: attachment;\r\n filename*0=\"=?UTF-8?Q?this-file-name-is-so-long-that-it-does-not-ev?=\";\r\n filename*1=\"=?UTF-8?Q?en-fit-on-a-whole-line-by-itself-so-we-need-t?=\";\r\n filename*2=\"=?UTF-8?Q?o-split-it-with-value-continuation.also-UTF-8?=\";\r\n filename*3=\"=?UTF-8?Q?-characters-h=C4=93r=C4=93.txt?=\"", + "attachment;\r\n filename*0=\"this-file-name-is-so-long-that-it-does-not-even-fit-on-a-whole-\";\r\n filename*1=\"line-by-itself-so-we-need-to-split-it-with-value-continuation.a\";\r\n filename*2=\"lso-UTF-8-characters-hērē.txt\"", + "Content-Disposition: attachment;\r\n filename*0=\"=?UTF-8?Q?this-file-name-is-so-long-that-it-does-not-even-fit?=\";\r\n filename*1=\"=?UTF-8?Q?-on-a-whole-line-by-itself-so-we-need-to-split-it-w?=\";\r\n filename*2=\"=?UTF-8?Q?ith-value-continuation.also-UTF-8-characters-h?=\";\r\n filename*3=\"=?UTF-8?Q?=C4=93r=C4=93.txt?=\"", + ], + 'UTF-8 multibyte' => [ + 'attachment', + ['filename' => $multibyteFilename], + "attachment; filename=\"$multibyteFilename\"", + $multibyteHeaderLine, + ], + 'UTF-8 multibyte continuation' => [ + 'attachment', + ['filename' => $multibyteContinuationFilename], + "attachment;\r\n filename=\"$multibyteContinuationFilename\"", + $multibyteContinuationHeaderLine, ], ]; // @codingStandardsIgnoreEnd