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