diff --git a/README.md b/README.md index 92a0b06..097f79f 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Usage use PHPStamp\Templator; use PHPStamp\Document\WordDocument; - $cachePath = 'path/to/writable/directory/'; + $cachePath = 'path/to/writable/directory'; $templator = new Templator($cachePath); // Enable debug mode to re-generate template with every render call. diff --git a/phpunit.xml b/phpunit.xml index 65887ea..49a1b1b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -17,6 +17,9 @@ tests/Integration + + tests/Performance + diff --git a/src/PHPStamp/Document/Document.php b/src/PHPStamp/Document/Document.php index db29329..61ab0a7 100644 --- a/src/PHPStamp/Document/Document.php +++ b/src/PHPStamp/Document/Document.php @@ -57,7 +57,7 @@ public function __construct($documentPath) */ public function extract($to, $overwrite) { - $filePath = $to.$this->getDocumentName().'/'.$this->getContentPath(); + $filePath = $this->composeExtractPath($to); if (!file_exists($filePath) || $overwrite === true) { $zip = new \ZipArchive(); @@ -67,7 +67,7 @@ public function extract($to, $overwrite) throw new InvalidArgumentException('Can`t open archive "'.$this->documentPath.'", code "'.$code.'" returned.'); } - if ($zip->extractTo($to.$this->documentName, $this->getContentPath()) === false) { + if ($zip->extractTo($this->composeExtractDirectory($to), $this->getContentPath()) === false) { throw new InvalidArgumentException('Destination not reachable.'); } } @@ -75,6 +75,39 @@ public function extract($to, $overwrite) return $filePath; } + /** + * @param string $to + * + * @return string + */ + private function composeExtractDirectory($to) + { + return rtrim($to, '/\\').DIRECTORY_SEPARATOR.$this->generateCacheKey(); + } + + /** + * @param string $to + * + * @return string + */ + public function composeExtractPath($to) + { + return $this->composeExtractDirectory($to).DIRECTORY_SEPARATOR.$this->getContentPath(); + } + + /** + * Generate document cache key. + */ + public function generateCacheKey() + { + $path = realpath($this->documentPath); + if ($path === false) { + $path = $this->documentPath; + } + + return hash('sha256', $path); + } + /** * Get MD5 hash to detect original document update. */ diff --git a/src/PHPStamp/Document/DocumentInterface.php b/src/PHPStamp/Document/DocumentInterface.php index 2927d36..cc5e674 100644 --- a/src/PHPStamp/Document/DocumentInterface.php +++ b/src/PHPStamp/Document/DocumentInterface.php @@ -29,6 +29,22 @@ public function __construct($documentPath); */ public function extract($to, $overwrite); + /** + * Compose extracted content file path. + * + * @param string $to + * + * @return string + */ + public function composeExtractPath($to); + + /** + * Generate document cache key. + * + * @return string + */ + public function generateCacheKey(); + /** * Get document file hash. * diff --git a/src/PHPStamp/Document/WordDocument.php b/src/PHPStamp/Document/WordDocument.php index 625f6dc..df8186e 100644 --- a/src/PHPStamp/Document/WordDocument.php +++ b/src/PHPStamp/Document/WordDocument.php @@ -23,7 +23,7 @@ class WordDocument extends Document */ public static function getContentPath() { - return 'word/document.xml'; + return 'word'.DIRECTORY_SEPARATOR.'document.xml'; } /** diff --git a/src/PHPStamp/Processor/Lexer.php b/src/PHPStamp/Processor/Lexer.php index 895e8d9..86b4ea9 100644 --- a/src/PHPStamp/Processor/Lexer.php +++ b/src/PHPStamp/Processor/Lexer.php @@ -141,6 +141,6 @@ public function getInputBetweenPosition(int $position, int $length): string /** @var string $input */ $input = $reflectionProperty->getValue($this); - return mb_substr($input, $position, $length); + return substr($input, $position, $length); } } diff --git a/src/PHPStamp/Result.php b/src/PHPStamp/Result.php index dc680a0..c7e7ef5 100644 --- a/src/PHPStamp/Result.php +++ b/src/PHPStamp/Result.php @@ -91,14 +91,28 @@ public function buildFile() if (copy($this->document->getDocumentPath(), $tempArchive) === true) { $zip = new \ZipArchive(); - $zip->open($tempArchive); + $code = $zip->open($tempArchive); + if ($code !== true) { + unlink($tempArchive); + + throw new TempException('Cannot open temp archive, code "'.$code.'" returned.'); + } $content = $this->output->saveXML(); if ($content === false) { + $zip->close(); + unlink($tempArchive); + throw new XmlException('Print XML error'); } - $zip->addFromString($this->document->getContentPath(), $content); + if ($zip->addFromString($this->document->getContentPath(), $content) !== true) { + $zip->close(); + unlink($tempArchive); + + throw new TempException('Cannot write document content to temp archive.'); + } + $zip->close(); return $tempArchive; diff --git a/src/PHPStamp/Templator.php b/src/PHPStamp/Templator.php index edbc256..722cf53 100644 --- a/src/PHPStamp/Templator.php +++ b/src/PHPStamp/Templator.php @@ -52,7 +52,7 @@ public function __construct(string $cachePath, array $brackets = ['[[', ']]']) throw new Exception\InvalidArgumentException('Brackets are in wrong format.'); } - $this->cachePath = $cachePath; + $this->cachePath = rtrim($cachePath, '/\\'); $this->brackets = $brackets; } @@ -176,8 +176,7 @@ private function searchAndReplace(\DOMNodeList $nodeList, DocumentInterface $doc throw new XmlException('Some node value expected'); } - $decodedValue = utf8_decode($nodeValue); - $lexer->setInput($decodedValue); + $lexer->setInput($nodeValue); while ($tag = $mapper->parse($lexer)) { foreach ($tag->getFunctions() as $function) { @@ -226,7 +225,7 @@ private function compareHash(DocumentInterface $document): bool { $overwrite = false; - $contentPath = $this->cachePath.$document->getDocumentName().'/'.$document->getContentPath(); + $contentPath = $document->composeExtractPath($this->cachePath); if (file_exists($contentPath) === true) { $template = new \DOMDocument('1.0', 'UTF-8'); $template->load($contentPath); diff --git a/src/PHPStamp/XMLHelper.php b/src/PHPStamp/XMLHelper.php index 83de32e..603e883 100644 --- a/src/PHPStamp/XMLHelper.php +++ b/src/PHPStamp/XMLHelper.php @@ -2,6 +2,7 @@ namespace PHPStamp; +use PHPStamp\Exception\EncodeException; use PHPStamp\Exception\ParsingException; use PHPStamp\Exception\XmlException; @@ -218,7 +219,7 @@ public static function xmlEncode($mixed, \DOMNode $domElement, \DOMDocument $dom $tagName = $itemName; } - $node = $domDocument->createElement($tagName); + $node = self::createElement($domDocument, (string) $tagName); $domElement->appendChild($node); self::xmlEncode($mixedElement, $node, $domDocument, $itemName); @@ -227,4 +228,24 @@ public static function xmlEncode($mixed, \DOMNode $domElement, \DOMDocument $dom $domElement->appendChild($domDocument->createTextNode((string) $mixed)); } } + + /** + * @throws EncodeException + */ + private static function createElement(\DOMDocument $document, string $tagName): \DOMElement + { + // PHP 7.4 exposes invalid DOM names as warnings. Bridge those warnings + // into app-level exceptions until PHP 7.4 support can be removed. + set_error_handler(static function (int $severity, string $message) use ($tagName): bool { + throw new EncodeException('Invalid XML element name "'.$tagName.'": '.$message); + }); + + try { + return $document->createElement($tagName); + } catch (\DOMException $exception) { + throw new EncodeException('Invalid XML element name "'.$tagName.'".', 0, $exception); + } finally { + restore_error_handler(); + } + } } diff --git a/tests/BaseCase.php b/tests/BaseCase.php index 098d280..919e7bb 100644 --- a/tests/BaseCase.php +++ b/tests/BaseCase.php @@ -9,23 +9,27 @@ class BaseCase extends \PHPUnit\Framework\TestCase { public static function makeMockDocument(string $content, string $instance, string $filename): DocumentInterface { - $zip = new \ZipArchive(); - $dir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'docx'; if (file_exists($dir) === false) { mkdir($dir); } - $filename = $dir.DIRECTORY_SEPARATOR.$filename; - if ($zip->open($filename, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { - throw new \Exception('Cant open archive '.$filename); + return self::makeMockDocumentAt($content, $instance, $dir.DIRECTORY_SEPARATOR.$filename); + } + + public static function makeMockDocumentAt(string $content, string $instance, string $path): DocumentInterface + { + $zip = new \ZipArchive(); + + if ($zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + throw new \Exception('Cant open archive '.$path); } $zip->addFromString($instance::getContentPath(), $content); $zip->close(); /** @var Document $doc */ - $doc = new $instance($filename); + $doc = new $instance($path); return $doc; } diff --git a/tests/Performance/PHPStamp/XMLHelperPerformanceTest.php b/tests/Performance/PHPStamp/XMLHelperPerformanceTest.php new file mode 100644 index 0000000..f62fab2 --- /dev/null +++ b/tests/Performance/PHPStamp/XMLHelperPerformanceTest.php @@ -0,0 +1,78 @@ +makeFlatData(100000); + $document = new \DOMDocument('1.0', 'UTF-8'); + $root = $document->createElement('root'); + $document->appendChild($root); + + $elapsed = $this->measure(static function () use ($data, $root, $document): void { + XMLHelper::xmlEncode($data, $root, $document); + }); + + $this->assertSame(10000, $root->childNodes->length); + $this->assertLessThan(1.0, $elapsed, sprintf('Encoding 10k flat values took %.4f seconds.', $elapsed)); + } + + public function testXmlEncodeElementCreationOverheadStaysBounded(): void + { + $data = $this->makeFlatData(100000); + + $baselineDocument = new \DOMDocument('1.0', 'UTF-8'); + $baselineRoot = $baselineDocument->createElement('root'); + $baselineDocument->appendChild($baselineRoot); + $baselineElapsed = $this->measure(static function () use ($data, $baselineRoot, $baselineDocument): void { + foreach ($data as $key => $value) { + $node = $baselineDocument->createElement($key); + $baselineRoot->appendChild($node); + $node->appendChild($baselineDocument->createTextNode($value)); + } + }); + + $document = new \DOMDocument('1.0', 'UTF-8'); + $root = $document->createElement('root'); + $document->appendChild($root); + $elapsed = $this->measure(static function () use ($data, $root, $document): void { + XMLHelper::xmlEncode($data, $root, $document); + }); + + $this->assertSame(10000, $root->childNodes->length); + $this->assertLessThan( + max(0.1, $baselineElapsed * 20), + $elapsed, + sprintf('XMLHelper took %.4fs; direct DOM baseline took %.4fs.', $elapsed, $baselineElapsed) + ); + } + + /** + * @return array + */ + private function makeFlatData(int $count): array + { + $data = []; + for ($i = 0; $i < $count; ++$i) { + $data['key_'.$i] = 'value_'.$i; + } + + return $data; + } + + /** + * @param callable(): void $callback + */ + private function measure(callable $callback): float + { + $start = microtime(true); + $callback(); + + return microtime(true) - $start; + } +} diff --git a/tests/Unit/PHPStamp/Document/DocumentTest.php b/tests/Unit/PHPStamp/Document/DocumentTest.php new file mode 100644 index 0000000..d3cf361 --- /dev/null +++ b/tests/Unit/PHPStamp/Document/DocumentTest.php @@ -0,0 +1,69 @@ +assertExtractIgnoresTrailingSlash(true); + $this->assertExtractIgnoresTrailingSlash(false); + } + + private function assertExtractIgnoresTrailingSlash(bool $useTrailingSlash): void + { + $destinationPath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'phpstamp-extract-'.uniqid('', true); + mkdir($destinationPath); + + $extractPath = $destinationPath; + if ($useTrailingSlash === true) { + $extractPath .= DIRECTORY_SEPARATOR; + } + + /** @var string */ + $content = file_get_contents(__DIR__.'/../../../resources/dummy.xml'); + $documentName = 'extract-path-'.uniqid('', true).'.docx'; + $document = $this->makeMockDocument($content, WordDocument::class, $documentName); + + $extractedFile = $document->extract($extractPath, true); + + $expectedContentFile = $destinationPath + .DIRECTORY_SEPARATOR + .$document->generateCacheKey() + .DIRECTORY_SEPARATOR + .str_replace('/', DIRECTORY_SEPARATOR, WordDocument::getContentPath()); + + $this->assertSame($expectedContentFile, $extractedFile); + $this->assertFileExists($expectedContentFile); + } + + public function testCacheNameCollision(): void + { + $destinationPath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'phpstamp-extract-'.uniqid('', true); + mkdir($destinationPath); + + /** @var string $content */ + $content = file_get_contents(__DIR__.'/../../../resources/dummy.xml'); + $firstDocument = $this->makeMockDocumentAt($content, WordDocument::class, $this->makeDocumentPath('first', 'invoice.docx')); + $secondDocument = $this->makeMockDocumentAt($content, WordDocument::class, $this->makeDocumentPath('second', 'invoice.docx')); + + $firstExtractedFile = $firstDocument->extract($destinationPath, true); + $secondExtractedFile = $secondDocument->extract($destinationPath, true); + + $this->assertNotSame($firstDocument->generateCacheKey(), $secondDocument->generateCacheKey()); + $this->assertNotSame($firstExtractedFile, $secondExtractedFile); + $this->assertFileExists($firstExtractedFile); + $this->assertFileExists($secondExtractedFile); + } + + private function makeDocumentPath(string $directoryName, string $filename): string + { + $dir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'docx-'.$directoryName.'-'.uniqid('', true); + mkdir($dir); + + return $dir.DIRECTORY_SEPARATOR.$filename; + } +} diff --git a/tests/Unit/PHPStamp/TemplatorTest.php b/tests/Unit/PHPStamp/TemplatorTest.php index 31006d4..41dd74a 100644 --- a/tests/Unit/PHPStamp/TemplatorTest.php +++ b/tests/Unit/PHPStamp/TemplatorTest.php @@ -15,12 +15,6 @@ class TemplatorTest extends BaseCase */ public function renderContentProvider(): array { - $studentsDoc = new \ZipArchive(); - $studentsDoc->open(__DIR__.'/../../resources/students.docx'); - - $studentsResultDoc = new \ZipArchive(); - $studentsResultDoc->open(__DIR__.'/../../resources/students_result.docx'); - return [ // https://learn.microsoft.com/ru-ru/office/open-xml/structure-of-a-wordprocessingml-document 'base case' => [ @@ -68,6 +62,113 @@ public function testContentRender(string $content, array $values, string $expect $this->assertEquals($expected, $result->getContent()->saveXML()); } + /** + * @dataProvider unicodeContentProvider + * + * @param array $values + */ + public function testUnicodeContent(string $content, array $values, string $expected): void + { + $templator = new Templator(sys_get_temp_dir().DIRECTORY_SEPARATOR); + $templator->debug = true; + + $document = $this->makeMockDocument($content, WordDocument::class, 'utf8-'.uniqid('', true).'.docx'); + $result = $templator->render($document, $values); + + $expected = str_replace(' ', '', $expected); + $this->assertEquals($expected, $result->getContent()->saveXML()); + } + + /** + * @return array + */ + public function unicodeContentProvider(): array + { + return [ + 'utf8 before placeholder' => [ + ''. + ''. + ' '. + ' '. + ' '. + ' Привет, [[username]]!'. + ' '. + ' '. + ' '. + '', + [ + 'username' => 'Neo', + ], + ''.PHP_EOL. + ''. + ' '. + ' '. + ' '. + ' Привет, Neo!'. + ' '. + ' '. + ' '. + ''.PHP_EOL, + ], + 'utf8 after placeholder' => [ + ''. + ''. + ' '. + ' '. + ' '. + ' [[username]], привет!'. + ' '. + ' '. + ' '. + '', + [ + 'username' => 'Neo', + ], + ''.PHP_EOL. + ''. + ' '. + ' '. + ' '. + ' Neo, привет!'. + ' '. + ' '. + ' '. + ''.PHP_EOL, + ], + ]; + } + + public function testCacheIgnoresTrailingSlash(): void + { + $this->assertCacheIgnoresTrailingSlash(true); + $this->assertCacheIgnoresTrailingSlash(false); + } + + public function testCacheNameCollision(): void + { + $cachePath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'phpstamp-cache-'.uniqid('', true); + mkdir($cachePath); + + $templator = new Templator($cachePath); + $templator->debug = true; + + /** @var string $content */ + $content = file_get_contents(__DIR__.'/../../resources/dummy.xml'); + $firstDocument = $this->makeMockDocumentAt($content, WordDocument::class, $this->makeDocumentPath('first', 'invoice.docx')); + $secondDocument = $this->makeMockDocumentAt($content, WordDocument::class, $this->makeDocumentPath('second', 'invoice.docx')); + + $templator->render($firstDocument, ['username' => 'Neo']); + $templator->render($secondDocument, ['username' => 'Neo']); + + $firstContentFile = $firstDocument->composeExtractPath($cachePath); + $secondContentFile = $secondDocument->composeExtractPath($cachePath); + + $this->assertNotSame($firstDocument->generateCacheKey(), $secondDocument->generateCacheKey()); + $this->assertNotSame($firstContentFile, $secondContentFile); + $this->assertFileExists($firstContentFile); + $this->assertFileExists($secondContentFile); + } + /** * @dataProvider * @@ -122,4 +223,41 @@ public function testFileRender(string $contentFile, array $values, string $expec $this->assertEquals($expected, $result->getContent()->saveXML()); } + + private function assertCacheIgnoresTrailingSlash(bool $useTrailingSlash): void + { + $cachePath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'phpstamp-cache-'.uniqid('', true); + mkdir($cachePath); + + $templatorCachePath = $cachePath; + if ($useTrailingSlash === true) { + $templatorCachePath .= DIRECTORY_SEPARATOR; + } + + $templator = new Templator($templatorCachePath); + $templator->debug = true; + + /** @var string $content */ + $content = file_get_contents(__DIR__.'/../../resources/dummy.xml'); + $documentName = 'cache-path-'.uniqid('', true).'.docx'; + $document = $this->makeMockDocument($content, WordDocument::class, $documentName); + + $templator->render($document, ['username' => 'Neo']); + + $expectedContentFile = $cachePath + .DIRECTORY_SEPARATOR + .$document->generateCacheKey() + .DIRECTORY_SEPARATOR + .str_replace('/', DIRECTORY_SEPARATOR, WordDocument::getContentPath()); + + $this->assertFileExists($expectedContentFile); + } + + private function makeDocumentPath(string $directoryName, string $filename): string + { + $dir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'docx-'.$directoryName.'-'.uniqid('', true); + mkdir($dir); + + return $dir.DIRECTORY_SEPARATOR.$filename; + } } diff --git a/tests/Unit/PHPStamp/XMLHelperTest.php b/tests/Unit/PHPStamp/XMLHelperTest.php index 42c7771..e36144b 100644 --- a/tests/Unit/PHPStamp/XMLHelperTest.php +++ b/tests/Unit/PHPStamp/XMLHelperTest.php @@ -2,6 +2,7 @@ namespace PHPStamp\Tests\Unit\PHPStamp; +use PHPStamp\Exception\EncodeException; use PHPStamp\Exception\ParsingException; use PHPStamp\Tests\BaseCase; use PHPStamp\XMLHelper; @@ -253,4 +254,31 @@ public function testEncode(array $data, string $root, string $expected): void $expected = str_replace(' ', '', $expected); // remove indentation $this->assertEquals($expected, $document->saveXML()); } + + /** + * @dataProvider invalidElementNameProvider + */ + public function testInvalidElementName(string $elementName): void + { + $document = new \DOMDocument('1.0', 'UTF-8'); + + $tokensNode = $document->createElement('root'); + $document->appendChild($tokensNode); + + $this->expectException(EncodeException::class); + $this->expectExceptionMessage('Invalid XML element name "'.$elementName.'"'); + + XMLHelper::xmlEncode([$elementName => 'Neo'], $tokensNode, $document); + } + + /** + * @return array + */ + public function invalidElementNameProvider(): array + { + return [ + 'with space' => ['elementName' => 'first name'], + 'empty' => ['elementName' => ''], + ]; + } } diff --git a/tests/resources/dummy.xml b/tests/resources/dummy.xml new file mode 100644 index 0000000..5432486 --- /dev/null +++ b/tests/resources/dummy.xml @@ -0,0 +1,4 @@ + + + Hello, [[username]]! + \ No newline at end of file