1919 */
2020final class XlsxFastEditor
2121{
22- public const OXML_NAMESPACE = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main ' ;
22+ /** @internal */
23+ public const _OXML_NAMESPACE = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main ' ;
2324
2425 private const CALC_CHAIN_CACHE_PATH = 'xl/calcChain.xml ' ;
2526 private const SHARED_STRINGS_PATH = 'xl/sharedStrings.xml ' ;
@@ -28,8 +29,8 @@ final class XlsxFastEditor
2829 private \ZipArchive $ zip ;
2930
3031 /**
31- * Cache of the XML documents.
32- * @var array<string,\DOMDocument >
32+ * Cache of the XPath instances associated to the DOM of the XML documents.
33+ * @var array<string,\DOMXPath >
3334 */
3435 private array $ documents = [];
3536
@@ -111,10 +112,11 @@ public function save(bool $close = true): void
111112 if (!$ pending || !isset ($ this ->documents [$ name ])) {
112113 continue ;
113114 }
114- $ dom = $ this ->documents [$ name ];
115+ $ xpath = $ this ->documents [$ name ];
115116 if (!$ this ->zip ->deleteName ($ name )) {
116117 throw new XlsxFastEditorZipException ("Error deleting old fragment {$ name }! " );
117118 }
119+ $ dom = $ xpath ->document ;
118120 $ xml = $ dom ->saveXML ();
119121 if ($ xml === false ) {
120122 throw new XlsxFastEditorXmlException ("Error saving changes {$ name }! " );
@@ -130,14 +132,51 @@ public function save(bool $close = true): void
130132 }
131133 }
132134
135+ /**
136+ * Extracts a worksheet from the internal ZIP document,
137+ * parse the XML, open the DOM, and
138+ * returns an XPath instance associated to the DOM at the given XML path.
139+ * The XPath instance is then cached.
140+ * @param string $path The path of the document inside the ZIP document.
141+ */
142+ private function getXPathFromPath (string $ path ): \DOMXPath
143+ {
144+ if (isset ($ this ->documents [$ path ])) {
145+ return $ this ->documents [$ path ];
146+ }
147+
148+ $ xml = $ this ->zip ->getFromName ($ path );
149+ if ($ xml === false ) {
150+ throw new XlsxFastEditorFileFormatException ("Missing XML fragment {$ path }! " );
151+ }
152+
153+ $ dom = new \DOMDocument ();
154+ if ($ dom ->loadXML ($ xml , LIBXML_NOERROR | LIBXML_NONET | LIBXML_NOWARNING ) === false ) {
155+ throw new XlsxFastEditorXmlException ("Error reading XML fragment {$ path }! " );
156+ }
157+
158+ $ xpath = new \DOMXPath ($ dom );
159+ $ xpath ->registerNamespace ('o ' , self ::_OXML_NAMESPACE );
160+
161+ $ this ->documents [$ path ] = $ xpath ;
162+ return $ xpath ;
163+ }
164+
165+ /**
166+ * Returns a DOM document of the given XML path.
167+ * @param string $path The path of the document inside the ZIP document.
168+ */
169+ private function getDomFromPath (string $ path ): \DOMDocument
170+ {
171+ return $ this ->getXPathFromPath ($ path )->document ;
172+ }
173+
133174 /**
134175 * Count the number of worksheets in the workbook.
135176 */
136177 public function getWorksheetCount (): int
137178 {
138- $ dom = $ this ->getDomFromPath (self ::WORKBOOK_PATH );
139- $ xpath = new \DOMXPath ($ dom );
140- $ xpath ->registerNamespace ('o ' , self ::OXML_NAMESPACE );
179+ $ xpath = $ this ->getXPathFromPath (self ::WORKBOOK_PATH );
141180 $ count = $ xpath ->evaluate ('count(/o:workbook/o:sheets/o:sheet) ' );
142181 return is_numeric ($ count ) ? (int )$ count : 0 ;
143182 }
@@ -149,9 +188,7 @@ public function getWorksheetCount(): int
149188 */
150189 public function getWorksheetNumber (string $ sheetName ): int
151190 {
152- $ dom = $ this ->getDomFromPath (self ::WORKBOOK_PATH );
153- $ xpath = new \DOMXPath ($ dom );
154- $ xpath ->registerNamespace ('o ' , self ::OXML_NAMESPACE );
191+ $ xpath = $ this ->getXPathFromPath (self ::WORKBOOK_PATH );
155192 $ sheetId = $ xpath ->evaluate ("normalize-space(/o:workbook/o:sheets/o:sheet[@name=' $ sheetName'][1]/@sheetId) " );
156193 if (is_string ($ sheetId )) {
157194 return (int )$ sheetId ;
@@ -166,9 +203,7 @@ public function getWorksheetNumber(string $sheetName): int
166203 */
167204 public function getWorksheetName (int $ sheetNumber ): ?string
168205 {
169- $ dom = $ this ->getDomFromPath (self ::WORKBOOK_PATH );
170- $ xpath = new \DOMXPath ($ dom );
171- $ xpath ->registerNamespace ('o ' , self ::OXML_NAMESPACE );
206+ $ xpath = $ this ->getXPathFromPath (self ::WORKBOOK_PATH );
172207 $ sheetName = $ xpath ->evaluate ("normalize-space(/o:workbook/o:sheets/o:sheet[ $ sheetNumber][1]/@name) " );
173208 return is_string ($ sheetName ) ? $ sheetName : null ;
174209 }
@@ -178,32 +213,6 @@ private static function getWorksheetPath(int $sheetNumber): string
178213 return "xl/worksheets/sheet {$ sheetNumber }.xml " ;
179214 }
180215
181- /**
182- * Extracts a worksheet from the internal ZIP document,
183- * parse the XML, and returns a DOM document.
184- * The DOM document is then cached.
185- * @param string $path The path of the document inside the ZIP document.
186- */
187- private function getDomFromPath (string $ path ): \DOMDocument
188- {
189- if (isset ($ this ->documents [$ path ])) {
190- return $ this ->documents [$ path ];
191- }
192-
193- $ xml = $ this ->zip ->getFromName ($ path );
194- if ($ xml === false ) {
195- throw new XlsxFastEditorFileFormatException ("Missing XML fragment {$ path }! " );
196- }
197-
198- $ dom = new \DOMDocument ();
199- if ($ dom ->loadXML ($ xml , LIBXML_NOERROR | LIBXML_NONET | LIBXML_NOWARNING ) === false ) {
200- throw new XlsxFastEditorXmlException ("Error reading XML fragment {$ path }! " );
201- }
202-
203- $ this ->documents [$ path ] = $ dom ;
204- return $ dom ;
205- }
206-
207216 /**
208217 * Defines the *Full calculation on load* policy for the specified worksheet.
209218 * @param int $sheetNumber Worksheet number (base 1)
@@ -248,10 +257,7 @@ public function setFullCalcOnLoad(int $sheetNumber, bool $value): void
248257 */
249258 public function getRow (int $ sheetNumber , int $ rowNumber , int $ accessMode = XlsxFastEditor::ACCESS_MODE_NULL ): ?XlsxFastEditorRow
250259 {
251- $ dom = $ this ->getDomFromPath (self ::getWorksheetPath ($ sheetNumber ));
252- $ xpath = new \DOMXPath ($ dom );
253- $ xpath ->registerNamespace ('o ' , self ::OXML_NAMESPACE );
254-
260+ $ xpath = $ this ->getXPathFromPath (self ::getWorksheetPath ($ sheetNumber ));
255261 $ rows = $ xpath ->query ("/o:worksheet/o:sheetData/o:row[@r=' {$ rowNumber }'][1] " );
256262 $ row = null ;
257263 if ($ rows !== false && $ rows ->length > 0 ) {
@@ -268,15 +274,15 @@ public function getRow(int $sheetNumber, int $rowNumber, int $accessMode = XlsxF
268274 case XlsxFastEditor::ACCESS_MODE_EXCEPTION :
269275 throw new XlsxFastEditorInputException ("Row {$ sheetNumber }/ {$ rowNumber } not found! " );
270276 case XlsxFastEditor::ACCESS_MODE_AUTOCREATE :
271- $ sheetDatas = $ dom ->getElementsByTagName ('sheetData ' );
277+ $ sheetDatas = $ xpath -> document ->getElementsByTagName ('sheetData ' );
272278 if ($ sheetDatas ->length === 0 ) {
273279 throw new XlsxFastEditorXmlException ("Cannot find sheetData for worksheet {$ sheetNumber }! " );
274280 }
275281 $ sheetData = $ sheetDatas [0 ];
276282 if (!($ sheetData instanceof \DOMElement)) {
277283 throw new XlsxFastEditorXmlException ("Error querying XML fragment for worksheet {$ sheetNumber }! " );
278284 }
279- $ row = $ dom ->createElement ('row ' );
285+ $ row = $ xpath -> document ->createElement ('row ' );
280286 if ($ row === false ) {
281287 throw new XlsxFastEditorXmlException ("Error creating row {$ sheetNumber }/ {$ rowNumber }! " );
282288 }
@@ -305,10 +311,7 @@ public function getRow(int $sheetNumber, int $rowNumber, int $accessMode = XlsxF
305311 */
306312 public function getFirstRow (int $ sheetNumber ): ?XlsxFastEditorRow
307313 {
308- $ dom = $ this ->getDomFromPath (self ::getWorksheetPath ($ sheetNumber ));
309- $ xpath = new \DOMXPath ($ dom );
310- $ xpath ->registerNamespace ('o ' , self ::OXML_NAMESPACE );
311-
314+ $ xpath = $ this ->getXPathFromPath (self ::getWorksheetPath ($ sheetNumber ));
312315 $ rs = $ xpath ->query ("/o:worksheet/o:sheetData/o:row[position() = 1] " );
313316 if ($ rs !== false && $ rs ->length > 0 ) {
314317 $ r = $ rs [0 ];
@@ -327,10 +330,7 @@ public function getFirstRow(int $sheetNumber): ?XlsxFastEditorRow
327330 */
328331 public function getLastRow (int $ sheetNumber ): ?XlsxFastEditorRow
329332 {
330- $ dom = $ this ->getDomFromPath (self ::getWorksheetPath ($ sheetNumber ));
331- $ xpath = new \DOMXPath ($ dom );
332- $ xpath ->registerNamespace ('o ' , self ::OXML_NAMESPACE );
333-
333+ $ xpath = $ this ->getXPathFromPath (self ::getWorksheetPath ($ sheetNumber ));
334334 $ rs = $ xpath ->query ("/o:worksheet/o:sheetData/o:row[position() = last()] " );
335335 if ($ rs !== false && $ rs ->length > 0 ) {
336336 $ r = $ rs [0 ];
@@ -348,10 +348,7 @@ public function getLastRow(int $sheetNumber): ?XlsxFastEditorRow
348348 */
349349 public function deleteRow (int $ sheetNumber , int $ rowNumber ): bool
350350 {
351- $ dom = $ this ->getDomFromPath (self ::getWorksheetPath ($ sheetNumber ));
352- $ xpath = new \DOMXPath ($ dom );
353- $ xpath ->registerNamespace ('o ' , self ::OXML_NAMESPACE );
354-
351+ $ xpath = $ this ->getXPathFromPath (self ::getWorksheetPath ($ sheetNumber ));
355352 $ rs = $ xpath ->query ("/o:worksheet/o:sheetData/o:row[@r=' {$ rowNumber }'][1] " );
356353 if ($ rs !== false && $ rs ->length > 0 ) {
357354 $ r = $ rs [0 ];
@@ -369,10 +366,7 @@ public function deleteRow(int $sheetNumber, int $rowNumber): bool
369366 */
370367 public function rowsIterator (int $ sheetNumber ): \Traversable
371368 {
372- $ dom = $ this ->getDomFromPath (self ::getWorksheetPath ($ sheetNumber ));
373- $ xpath = new \DOMXPath ($ dom );
374- $ xpath ->registerNamespace ('o ' , self ::OXML_NAMESPACE );
375-
369+ $ xpath = $ this ->getXPathFromPath (self ::getWorksheetPath ($ sheetNumber ));
376370 $ rs = $ xpath ->query ("/o:worksheet/o:sheetData/o:row " );
377371 if ($ rs !== false ) {
378372 for ($ i = 0 ; $ i < $ rs ->length ; $ i ++) {
@@ -427,16 +421,12 @@ public static function _columnOrderCompare(string $ref1, string $ref2): int
427421 */
428422 public function getCell (int $ sheetNumber , string $ cellName , int $ accessMode = XlsxFastEditor::ACCESS_MODE_NULL ): ?XlsxFastEditorCell
429423 {
430- $ dom = $ this ->getDomFromPath (self ::getWorksheetPath ($ sheetNumber ));
431- $ xpath = new \DOMXPath ($ dom );
432- $ xpath ->registerNamespace ('o ' , self ::OXML_NAMESPACE );
433-
434424 if (!ctype_alnum ($ cellName )) {
435425 throw new XlsxFastEditorInputException ("Invalid cell reference {$ cellName }! " );
436426 }
437427 $ cellName = strtoupper ($ cellName );
438428
439- $ c = null ;
429+ $ xpath = $ this -> getXPathFromPath ( self :: getWorksheetPath ( $ sheetNumber )) ;
440430 $ cs = $ xpath ->query ("/o:worksheet/o:sheetData/o:row/o:c[@r=' {$ cellName }'][1] " );
441431 $ c = null ;
442432 if ($ cs !== false && $ cs ->length > 0 ) {
@@ -511,12 +501,9 @@ public function readInt(int $sheetNumber, string $cellName): ?int
511501 */
512502 public function _getSharedString (int $ stringNumber ): ?string
513503 {
514- $ dom = $ this ->getDomFromPath (self ::SHARED_STRINGS_PATH );
515- $ xpath = new \DOMXPath ($ dom );
516- $ xpath ->registerNamespace ('o ' , self ::OXML_NAMESPACE );
517-
518504 $ stringNumber ++; // Base 1
519505
506+ $ xpath = $ this ->getXPathFromPath (self ::SHARED_STRINGS_PATH );
520507 $ ts = $ xpath ->query ("/o:sst/o:si[ $ stringNumber][1]/o:t[1] " );
521508 if ($ ts !== false && $ ts ->length > 0 ) {
522509 $ t = $ ts [0 ];
@@ -541,6 +528,84 @@ public function readString(int $sheetNumber, string $cellName): ?string
541528 return $ cell === null ? null : $ cell ->readString ();
542529 }
543530
531+ private static function getWorksheetRelPath (int $ sheetNumber ): string
532+ {
533+ return "xl/worksheets/_rels/sheet {$ sheetNumber }.xml.rels " ;
534+ }
535+
536+ /**
537+ * Access an hyperlink referenced from a cell of the specified sheet.
538+ * @param string $rId Hyperlink reference.
539+ * @internal
540+ */
541+ public function _getHyperlink (int $ sheetNumber , string $ rId ): ?string
542+ {
543+ if (!ctype_alnum ($ rId )) {
544+ throw new XlsxFastEditorInputException ("Invalid internal hyperlink reference {$ sheetNumber }/ {$ rId }! " );
545+ }
546+ $ xpath = $ this ->getXPathFromPath (self ::getWorksheetRelPath ($ sheetNumber ));
547+ $ xpath ->registerNamespace ('pr ' , 'http://schemas.openxmlformats.org/package/2006/relationships ' );
548+ $ target = $ xpath ->evaluate (<<<xpath
549+ normalize-space(/pr:Relationships/pr:Relationship[@Id=' {$ rId }'
550+ and @Type='http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink'][1]/@Target)
551+ xpath );
552+ return is_string ($ target ) ? $ target : null ;
553+ }
554+
555+ /**
556+ * Read a hyperlink in the given worksheet at the given cell location.
557+ *
558+ * @param int $sheetNumber Worksheet number (base 1)
559+ * @param $cellName Cell name such as `B4`
560+ */
561+ public function readHyperlink (int $ sheetNumber , string $ cellName ): ?string
562+ {
563+ $ cell = $ this ->getCell ($ sheetNumber , $ cellName , XlsxFastEditor::ACCESS_MODE_NULL );
564+ return $ cell === null ? null : $ cell ->readHyperlink ();
565+ }
566+
567+ /**
568+ * Change an hyperlink associated to the given cell of the given worksheet.
569+ * @return bool True if any hyperlink was cleared, false otherwise.
570+ * @internal
571+ */
572+ public function _setHyperlink (int $ sheetNumber , string $ rId , string $ value ): bool
573+ {
574+ if (!ctype_alnum ($ rId )) {
575+ throw new XlsxFastEditorInputException ("Invalid internal hyperlink reference {$ sheetNumber }/ {$ rId }! " );
576+ }
577+ $ xmlPath = self ::getWorksheetRelPath ($ sheetNumber );
578+ $ xpath = $ this ->getXPathFromPath ($ xmlPath );
579+ $ xpath ->registerNamespace ('pr ' , 'http://schemas.openxmlformats.org/package/2006/relationships ' );
580+ $ hyperlinks = $ xpath ->query (<<<xpath
581+ /pr:Relationships/pr:Relationship[@Id=' {$ rId }'
582+ and @Type='http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink'][1]
583+ xpath );
584+ if ($ hyperlinks !== false && $ hyperlinks ->length > 0 ) {
585+ $ hyperlink = $ hyperlinks [0 ];
586+ if (!($ hyperlink instanceof \DOMElement)) {
587+ throw new XlsxFastEditorXmlException ("Error querying XML fragment for hyperlink {$ sheetNumber }/ {$ rId }! " );
588+ }
589+ $ this ->touchPath ($ xmlPath );
590+ return $ hyperlink ->setAttribute ('Target ' , $ value ) !== false ;
591+ }
592+ return false ;
593+ }
594+
595+ /**
596+ * Replace the hyperlink of the cell, if that cell already has an hyperlink.
597+ * Warning: does not support the creation of a new hyperlink.
598+ *
599+ * @param int $sheetNumber Worksheet number (base 1)
600+ * @param $cellName Cell name such as `B4`
601+ * @return bool True if the hyperlink could be replaced, false otherwise.
602+ */
603+ public function writeHyperlink (int $ sheetNumber , string $ cellName , string $ value ): bool
604+ {
605+ $ cell = $ this ->getCell ($ sheetNumber , $ cellName , XlsxFastEditor::ACCESS_MODE_NULL );
606+ return $ cell === null ? false : $ cell ->writeHyperlink ($ value );
607+ }
608+
544609 /**
545610 * Write a formulat in the given worksheet at the given cell location, without changing the type/style of the cell.
546611 * Auto-creates the cell if it does not already exists.
0 commit comments