diff --git a/docs/changes/1.x/1.2.0.md b/docs/changes/1.x/1.2.0.md index ba8e6bfb38..5b055ca7c0 100644 --- a/docs/changes/1.x/1.2.0.md +++ b/docs/changes/1.x/1.2.0.md @@ -11,6 +11,7 @@ - HTML Writer : Added border-spacing to default styles for table by [@kernusr](https://github.com/kernusr) in GH-2451 - Word2007 Reader : Support for table cell borders and margins by [@kernusr](https://github.com/kernusr) in GH-2454 - PDF Writer : Add config for defining the default font by [@MikeMaldini](https://github.com/MikeMaldini) in [#2262](https://github.com/PHPOffice/PHPWord/pull/2262) & [#2468](https://github.com/PHPOffice/PHPWord/pull/2468) +- Word2007 Reader : Added support for Comments by [@shaedrich](https://github.com/shaedrich) in [#2161](https://github.com/PHPOffice/PHPWord/pull/2161) & [#2469](https://github.com/PHPOffice/PHPWord/pull/2469) ### Bug fixes diff --git a/docs/index.md b/docs/index.md index 1398fd90c4..bd38dd3238 100644 --- a/docs/index.md +++ b/docs/index.md @@ -95,7 +95,7 @@ Below are the supported features for each file formats. | | Footer | :material-check: | | | | | | | Footnote | :material-check: | | | | | | | Endnote | :material-check: | | | | | -| | Comments | | | | | | +| | Comments | :material-check: | | | | | | **Graphs** | 2D basic graphs | | | | | | | | 2D advanced graphs | | | | | | | | 3D graphs | | | | | | diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e4b89e68dd..9d6e6366d4 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -230,11 +230,6 @@ parameters: count: 2 path: src/PhpWord/Reader/Word2007/AbstractPart.php - - - message: "#^Call to method setChangeInfo\\(\\) on an unknown class PhpOffice\\\\PhpWord\\\\Reader\\\\Word2007\\\\AbstractElement\\.$#" - count: 1 - path: src/PhpWord/Reader/Word2007/AbstractPart.php - - message: "#^Method PhpOffice\\\\PhpWord\\\\Reader\\\\Word2007\\\\AbstractPart\\:\\:getHeadingDepth\\(\\) never returns float so it can be removed from the return type\\.$#" count: 1 @@ -250,11 +245,6 @@ parameters: count: 1 path: src/PhpWord/Reader/Word2007/AbstractPart.php - - - message: "#^PHPDoc tag @var for variable \\$element contains unknown class PhpOffice\\\\PhpWord\\\\Reader\\\\Word2007\\\\AbstractElement\\.$#" - count: 1 - path: src/PhpWord/Reader/Word2007/AbstractPart.php - - message: "#^Parameter \\#1 \\$count of method PhpOffice\\\\PhpWord\\\\Element\\\\AbstractContainer\\:\\:addTextBreak\\(\\) expects int, null given\\.$#" count: 1 diff --git a/src/PhpWord/Collection/AbstractCollection.php b/src/PhpWord/Collection/AbstractCollection.php index 70c92689b8..78b5b891b8 100644 --- a/src/PhpWord/Collection/AbstractCollection.php +++ b/src/PhpWord/Collection/AbstractCollection.php @@ -79,7 +79,7 @@ public function setItem($index, $item): void */ public function addItem($item) { - $index = $this->countItems() + 1; + $index = $this->countItems(); $this->items[$index] = $item; return $index; diff --git a/src/PhpWord/PhpWord.php b/src/PhpWord/PhpWord.php index 69efa76723..da57f38d29 100644 --- a/src/PhpWord/PhpWord.php +++ b/src/PhpWord/PhpWord.php @@ -325,4 +325,52 @@ public function save($filename, $format = 'Word2007', $download = false) return true; } + + /** + * Create new section. + * + * @deprecated 0.10.0 + * + * @param array $settings + * + * @return \PhpOffice\PhpWord\Element\Section + * + * @codeCoverageIgnore + */ + public function createSection($settings = null) + { + return $this->addSection($settings); + } + + /** + * Get document properties object. + * + * @deprecated 0.12.0 + * + * @return \PhpOffice\PhpWord\Metadata\DocInfo + * + * @codeCoverageIgnore + */ + public function getDocumentProperties() + { + return $this->getDocInfo(); + } + + /** + * Set document properties object. + * + * @deprecated 0.12.0 + * + * @param \PhpOffice\PhpWord\Metadata\DocInfo $documentProperties + * + * @return self + * + * @codeCoverageIgnore + */ + public function setDocumentProperties($documentProperties) + { + $this->metadata['Document'] = $documentProperties; + + return $this; + } } diff --git a/src/PhpWord/Reader/Word2007.php b/src/PhpWord/Reader/Word2007.php index 1febe0ff0a..bb20a8fed2 100644 --- a/src/PhpWord/Reader/Word2007.php +++ b/src/PhpWord/Reader/Word2007.php @@ -17,7 +17,10 @@ namespace PhpOffice\PhpWord\Reader; +use Exception; +use PhpOffice\PhpWord\Element\AbstractElement; use PhpOffice\PhpWord\PhpWord; +use PhpOffice\PhpWord\Reader\Word2007\AbstractPart; use PhpOffice\PhpWord\Shared\XMLReader; use PhpOffice\PhpWord\Shared\ZipArchive; @@ -42,23 +45,34 @@ public function load($docFile) { $phpWord = new PhpWord(); $relationships = $this->readRelationships($docFile); + $commentRefs = []; $steps = [ - ['stepPart' => 'document', 'stepItems' => [ - 'styles' => 'Styles', - 'numbering' => 'Numbering', - ]], - ['stepPart' => 'main', 'stepItems' => [ - 'officeDocument' => 'Document', - 'core-properties' => 'DocPropsCore', - 'extended-properties' => 'DocPropsApp', - 'custom-properties' => 'DocPropsCustom', - ]], - ['stepPart' => 'document', 'stepItems' => [ - 'endnotes' => 'Endnotes', - 'footnotes' => 'Footnotes', - 'settings' => 'Settings', - ]], + [ + 'stepPart' => 'document', + 'stepItems' => [ + 'styles' => 'Styles', + 'numbering' => 'Numbering', + ], + ], + [ + 'stepPart' => 'main', + 'stepItems' => [ + 'officeDocument' => 'Document', + 'core-properties' => 'DocPropsCore', + 'extended-properties' => 'DocPropsApp', + 'custom-properties' => 'DocPropsCustom', + ], + ], + [ + 'stepPart' => 'document', + 'stepItems' => [ + 'endnotes' => 'Endnotes', + 'footnotes' => 'Footnotes', + 'settings' => 'Settings', + 'comments' => 'Comments', + ], + ], ]; foreach ($steps as $step) { @@ -72,7 +86,8 @@ public function load($docFile) if (isset($stepItems[$relType])) { $partName = $stepItems[$relType]; $xmlFile = $relItem['target']; - $this->readPart($phpWord, $relationships, $partName, $docFile, $xmlFile); + $part = $this->readPart($phpWord, $relationships, $commentRefs, $partName, $docFile, $xmlFile); + $commentRefs = $part->getCommentReferences(); } } } @@ -83,21 +98,23 @@ public function load($docFile) /** * Read document part. * - * @param array $relationships - * @param string $partName - * @param string $docFile - * @param string $xmlFile + * @param array> $commentRefs */ - private function readPart(PhpWord $phpWord, $relationships, $partName, $docFile, $xmlFile): void + private function readPart(PhpWord $phpWord, array $relationships, array $commentRefs, string $partName, string $docFile, string $xmlFile): AbstractPart { $partClass = "PhpOffice\\PhpWord\\Reader\\Word2007\\{$partName}"; - if (class_exists($partClass)) { - /** @var \PhpOffice\PhpWord\Reader\Word2007\AbstractPart $part Type hint */ - $part = new $partClass($docFile, $xmlFile); - $part->setImageLoading($this->hasImageLoading()); - $part->setRels($relationships); - $part->read($phpWord); + if (!class_exists($partClass)) { + throw new Exception(sprintf('The part "%s" doesn\'t exist', $partClass)); } + + /** @var AbstractPart $part Type hint */ + $part = new $partClass($docFile, $xmlFile); + $part->setImageLoading($this->hasImageLoading()); + $part->setRels($relationships); + $part->setCommentReferences($commentRefs); + $part->read($phpWord); + + return $part; } /** diff --git a/src/PhpWord/Reader/Word2007/AbstractPart.php b/src/PhpWord/Reader/Word2007/AbstractPart.php index 3ab8995f9e..dc61b09356 100644 --- a/src/PhpWord/Reader/Word2007/AbstractPart.php +++ b/src/PhpWord/Reader/Word2007/AbstractPart.php @@ -19,8 +19,10 @@ use DateTime; use DOMElement; +use InvalidArgumentException; use PhpOffice\PhpWord\ComplexType\TblWidth as TblWidthComplexType; use PhpOffice\PhpWord\Element\AbstractContainer; +use PhpOffice\PhpWord\Element\AbstractElement; use PhpOffice\PhpWord\Element\TextRun; use PhpOffice\PhpWord\Element\TrackChange; use PhpOffice\PhpWord\PhpWord; @@ -67,6 +69,13 @@ abstract class AbstractPart */ protected $rels = []; + /** + * Comment references. + * + * @var array> + */ + protected $commentRefs = []; + /** * Image Loading. * @@ -113,6 +122,62 @@ public function hasImageLoading(): bool return $this->imageLoading; } + /** + * Get comment references. + * + * @return array> + */ + public function getCommentReferences(): array + { + return $this->commentRefs; + } + + /** + * Set comment references. + * + * @param array> $commentRefs + */ + public function setCommentReferences(array $commentRefs): self + { + $this->commentRefs = $commentRefs; + + return $this; + } + + /** + * Set comment reference. + */ + private function setCommentReference(string $type, string $id, AbstractElement $element): self + { + if (!in_array($type, ['start', 'end'])) { + throw new InvalidArgumentException('Type must be "start" or "end"'); + } + + if (!array_key_exists($id, $this->commentRefs)) { + $this->commentRefs[$id] = [ + 'start' => null, + 'end' => null, + ]; + } + $this->commentRefs[$id][$type] = $element; + + return $this; + } + + /** + * Get comment reference. + * + * @return array + */ + protected function getCommentReference(string $id): array + { + if (!array_key_exists($id, $this->commentRefs)) { + throw new InvalidArgumentException(sprintf('Comment with id %s isn\'t referenced in document', $id)); + } + + return $this->commentRefs[$id]; + } + /** * Read w:p. * @@ -126,6 +191,19 @@ protected function readParagraph(XMLReader $xmlReader, DOMElement $domNode, $par // Paragraph style $paragraphStyle = null; $headingDepth = null; + if ($xmlReader->elementExists('w:commentReference', $domNode) + || $xmlReader->elementExists('w:commentRangeStart', $domNode) + || $xmlReader->elementExists('w:commentRangeEnd', $domNode) + ) { + $nodes = $xmlReader->getElements('w:commentReference|w:commentRangeStart|w:commentRangeEnd', $domNode); + $node = current(iterator_to_array($nodes)); + if ($node) { + $attributeIdentifier = $node->attributes->getNamedItem('id'); + if ($attributeIdentifier) { + $id = $attributeIdentifier->nodeValue; + } + } + } if ($xmlReader->elementExists('w:pPr', $domNode)) { $paragraphStyle = $this->readParagraphStyle($xmlReader, $domNode); $headingDepth = $this->getHeadingDepth($paragraphStyle); @@ -182,7 +260,7 @@ protected function readParagraph(XMLReader $xmlReader, DOMElement $domNode, $par $parent->addTitle($textContent, $headingDepth); } else { // Text and TextRun - $textRunContainers = $xmlReader->countElements('w:r|w:ins|w:del|w:hyperlink|w:smartTag', $domNode); + $textRunContainers = $xmlReader->countElements('w:r|w:ins|w:del|w:hyperlink|w:smartTag|w:commentReference|w:commentRangeStart|w:commentRangeEnd', $domNode); if (0 === $textRunContainers) { $parent->addTextBreak(null, $paragraphStyle); } else { @@ -230,7 +308,7 @@ private function getHeadingDepth(?array $paragraphStyle = null) */ protected function readRun(XMLReader $xmlReader, DOMElement $domNode, $parent, $docPart, $paragraphStyle = null): void { - if (in_array($domNode->nodeName, ['w:ins', 'w:del', 'w:smartTag', 'w:hyperlink'])) { + if (in_array($domNode->nodeName, ['w:ins', 'w:del', 'w:smartTag', 'w:hyperlink', 'w:commentReference'])) { $nodes = $xmlReader->getElements('*', $domNode); foreach ($nodes as $node) { $this->readRun($xmlReader, $node, $parent, $docPart, $paragraphStyle); @@ -242,6 +320,17 @@ protected function readRun(XMLReader $xmlReader, DOMElement $domNode, $parent, $ $this->readRunChild($xmlReader, $node, $parent, $docPart, $paragraphStyle, $fontStyle); } } + + if ($xmlReader->elementExists('.//*["commentReference"=local-name()]', $domNode)) { + $node = iterator_to_array($xmlReader->getElements('.//*["commentReference"=local-name()]', $domNode))[0]; + $attributeIdentifier = $node->attributes->getNamedItem('id'); + if ($attributeIdentifier) { + $id = $attributeIdentifier->nodeValue; + + $this->setCommentReference('start', $id, $parent->getElement($parent->countElements() - 1)); + $this->setCommentReference('end', $id, $parent->getElement($parent->countElements() - 1)); + } + } } /** @@ -339,6 +428,7 @@ protected function readRunChild(XMLReader $xmlReader, DOMElement $node, Abstract $type = ($runParent->nodeName == 'w:del') ? TrackChange::DELETED : TrackChange::INSERTED; $author = $runParent->getAttribute('w:author'); $date = DateTime::createFromFormat('Y-m-d\TH:i:s\Z', $runParent->getAttribute('w:date')); + $date = $date instanceof DateTime ? $date : null; $element->setChangeInfo($type, $author, $date); } } diff --git a/src/PhpWord/Reader/Word2007/Comments.php b/src/PhpWord/Reader/Word2007/Comments.php new file mode 100644 index 0000000000..61b31713b5 --- /dev/null +++ b/src/PhpWord/Reader/Word2007/Comments.php @@ -0,0 +1,56 @@ +getDomFromZip($this->docFile, $this->xmlFile); + + $comments = $phpWord->getComments(); + + $nodes = $xmlReader->getElements('*'); + + foreach ($nodes as $node) { + $name = str_replace('w:', '', $node->nodeName); + + $author = $xmlReader->getAttribute('w:author', $node); + $date = $xmlReader->getAttribute('w:date', $node); + $initials = $xmlReader->getAttribute('w:initials', $node); + + $element = new Comment($author, new DateTime($date), $initials); + + $range = $this->getCommentReference($xmlReader->getAttribute('w:id', $node)); + if ($range['start']) { + $range['start']->setCommentRangeStart($element); + } + if ($range['end']) { + $range['end']->setCommentRangeEnd($element); + } + + $pNodes = $xmlReader->getElements('w:p/w:r', $node); + foreach ($pNodes as $pNode) { + $this->readRun($xmlReader, $pNode, $element, $this->collection); + } + + $phpWord->getComments()->addItem($element); + } + } +} diff --git a/tests/PhpWordTests/Collection/CollectionTest.php b/tests/PhpWordTests/Collection/CollectionTest.php index 8f7e3c2ebf..9a18a2a75f 100644 --- a/tests/PhpWordTests/Collection/CollectionTest.php +++ b/tests/PhpWordTests/Collection/CollectionTest.php @@ -35,13 +35,26 @@ public function testCollection(): void $object = new Footnotes(); $object->addItem(new Footnote()); // addItem #1 - self::assertEquals(2, $object->addItem(new Footnote())); // addItem #2. Should returns new item index + self::assertEquals(1, $object->addItem(new Footnote())); // addItem #2. Should returns new item index self::assertCount(2, $object->getItems()); // getItems returns array - self::assertInstanceOf('PhpOffice\\PhpWord\\Element\\Footnote', $object->getItem(1)); // getItem returns object + self::assertInstanceOf(Footnote::class, $object->getItem(1)); // getItem returns object self::assertNull($object->getItem(3)); // getItem returns null when invalid index is referenced $object->setItem(2, null); // Set item #2 to null self::assertNull($object->getItem(2)); // Check if it's null } + + /** + * @covers ::setItem + */ + public function testCollectionSetItem(): void + { + $object = new Footnotes(); + $object->addItem(new Footnote()); + self::assertCount(1, $object->getItems()); + + $object->setItem(0, new Footnote()); + self::assertCount(1, $object->getItems()); + } } diff --git a/tests/PhpWordTests/Reader/Word2007Test.php b/tests/PhpWordTests/Reader/Word2007Test.php index 883dc84d53..e42f0110d5 100644 --- a/tests/PhpWordTests/Reader/Word2007Test.php +++ b/tests/PhpWordTests/Reader/Word2007Test.php @@ -17,11 +17,15 @@ namespace PhpOffice\PhpWordTests\Reader; +use DateTime; +use PhpOffice\PhpWord\Element\Comment; use PhpOffice\PhpWord\Element\Image; +use PhpOffice\PhpWord\Element\Text; use PhpOffice\PhpWord\Element\TextRun; use PhpOffice\PhpWord\IOFactory; use PhpOffice\PhpWord\PhpWord; use PhpOffice\PhpWord\Reader\Word2007; +use PhpOffice\PhpWord\Style\Font; use PhpOffice\PhpWordTests\TestHelperDOCX; /** @@ -118,4 +122,41 @@ public function providerSettingsImageLoading(): iterable [false], ]; } + + public function testLoadComments(): void + { + $phpWord = IOFactory::load(dirname(__DIR__, 1) . '/_files/documents/reader-comments.docx'); + + self::assertInstanceOf(PhpWord::class, $phpWord); + + self::assertEquals(2, $phpWord->getComments()->countItems()); + + /** @var Comment $comment */ + $comment = $phpWord->getComments()->getItem(0); + self::assertInstanceOf(Comment::class, $comment); + self::assertEquals('shaedrich', $comment->getAuthor()); + self::assertEquals(new DateTime('2021-10-28T13:56:55Z'), $comment->getDate()); + self::assertEquals('SH', $comment->getInitials()); + self::assertCount(1, $comment->getElements()); + self::assertInstanceOf(Text::class, $comment->getElement(0)); + self::assertEquals('This this be lowercase', $comment->getElement(0)->getText()); + /** @var Font $fontStyle */ + $fontStyle = $comment->getElement(0)->getFontStyle(); + self::assertInstanceOf(Font::class, $fontStyle); + self::assertEquals('de-DE', $fontStyle->getLang()->getLatin()); + + /** @var Comment $comment */ + $comment = $phpWord->getComments()->getItem(1); + self::assertInstanceOf(Comment::class, $comment); + self::assertEquals('shaedrich', $comment->getAuthor()); + self::assertEquals(new DateTime('2021-11-02T19:10:00Z'), $comment->getDate()); + self::assertEquals('SH', $comment->getInitials()); + self::assertCount(1, $comment->getElements()); + self::assertInstanceOf(Text::class, $comment->getElement(0)); + self::assertEquals('But this should be uppercase', $comment->getElement(0)->getText()); + /** @var Font $fontStyle */ + $fontStyle = $comment->getElement(0)->getFontStyle(); + self::assertInstanceOf(Font::class, $fontStyle); + self::assertEquals('de-DE', $fontStyle->getLang()->getLatin()); + } } diff --git a/tests/PhpWordTests/_files/documents/reader-comments.docx b/tests/PhpWordTests/_files/documents/reader-comments.docx new file mode 100644 index 0000000000..4748da6838 Binary files /dev/null and b/tests/PhpWordTests/_files/documents/reader-comments.docx differ