19
19
*/
20
20
final class XlsxFastEditor
21
21
{
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 ' ;
23
24
24
25
private const CALC_CHAIN_CACHE_PATH = 'xl/calcChain.xml ' ;
25
26
private const SHARED_STRINGS_PATH = 'xl/sharedStrings.xml ' ;
@@ -28,8 +29,8 @@ final class XlsxFastEditor
28
29
private \ZipArchive $ zip ;
29
30
30
31
/**
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 >
33
34
*/
34
35
private array $ documents = [];
35
36
@@ -111,10 +112,11 @@ public function save(bool $close = true): void
111
112
if (!$ pending || !isset ($ this ->documents [$ name ])) {
112
113
continue ;
113
114
}
114
- $ dom = $ this ->documents [$ name ];
115
+ $ xpath = $ this ->documents [$ name ];
115
116
if (!$ this ->zip ->deleteName ($ name )) {
116
117
throw new XlsxFastEditorZipException ("Error deleting old fragment {$ name }! " );
117
118
}
119
+ $ dom = $ xpath ->document ;
118
120
$ xml = $ dom ->saveXML ();
119
121
if ($ xml === false ) {
120
122
throw new XlsxFastEditorXmlException ("Error saving changes {$ name }! " );
@@ -130,14 +132,51 @@ public function save(bool $close = true): void
130
132
}
131
133
}
132
134
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
+
133
174
/**
134
175
* Count the number of worksheets in the workbook.
135
176
*/
136
177
public function getWorksheetCount (): int
137
178
{
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 );
141
180
$ count = $ xpath ->evaluate ('count(/o:workbook/o:sheets/o:sheet) ' );
142
181
return is_numeric ($ count ) ? (int )$ count : 0 ;
143
182
}
@@ -149,9 +188,7 @@ public function getWorksheetCount(): int
149
188
*/
150
189
public function getWorksheetNumber (string $ sheetName ): int
151
190
{
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 );
155
192
$ sheetId = $ xpath ->evaluate ("normalize-space(/o:workbook/o:sheets/o:sheet[@name=' $ sheetName'][1]/@sheetId) " );
156
193
if (is_string ($ sheetId )) {
157
194
return (int )$ sheetId ;
@@ -166,9 +203,7 @@ public function getWorksheetNumber(string $sheetName): int
166
203
*/
167
204
public function getWorksheetName (int $ sheetNumber ): ?string
168
205
{
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 );
172
207
$ sheetName = $ xpath ->evaluate ("normalize-space(/o:workbook/o:sheets/o:sheet[ $ sheetNumber][1]/@name) " );
173
208
return is_string ($ sheetName ) ? $ sheetName : null ;
174
209
}
@@ -178,32 +213,6 @@ private static function getWorksheetPath(int $sheetNumber): string
178
213
return "xl/worksheets/sheet {$ sheetNumber }.xml " ;
179
214
}
180
215
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
-
207
216
/**
208
217
* Defines the *Full calculation on load* policy for the specified worksheet.
209
218
* @param int $sheetNumber Worksheet number (base 1)
@@ -248,10 +257,7 @@ public function setFullCalcOnLoad(int $sheetNumber, bool $value): void
248
257
*/
249
258
public function getRow (int $ sheetNumber , int $ rowNumber , int $ accessMode = XlsxFastEditor::ACCESS_MODE_NULL ): ?XlsxFastEditorRow
250
259
{
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 ));
255
261
$ rows = $ xpath ->query ("/o:worksheet/o:sheetData/o:row[@r=' {$ rowNumber }'][1] " );
256
262
$ row = null ;
257
263
if ($ rows !== false && $ rows ->length > 0 ) {
@@ -268,15 +274,15 @@ public function getRow(int $sheetNumber, int $rowNumber, int $accessMode = XlsxF
268
274
case XlsxFastEditor::ACCESS_MODE_EXCEPTION :
269
275
throw new XlsxFastEditorInputException ("Row {$ sheetNumber }/ {$ rowNumber } not found! " );
270
276
case XlsxFastEditor::ACCESS_MODE_AUTOCREATE :
271
- $ sheetDatas = $ dom ->getElementsByTagName ('sheetData ' );
277
+ $ sheetDatas = $ xpath -> document ->getElementsByTagName ('sheetData ' );
272
278
if ($ sheetDatas ->length === 0 ) {
273
279
throw new XlsxFastEditorXmlException ("Cannot find sheetData for worksheet {$ sheetNumber }! " );
274
280
}
275
281
$ sheetData = $ sheetDatas [0 ];
276
282
if (!($ sheetData instanceof \DOMElement)) {
277
283
throw new XlsxFastEditorXmlException ("Error querying XML fragment for worksheet {$ sheetNumber }! " );
278
284
}
279
- $ row = $ dom ->createElement ('row ' );
285
+ $ row = $ xpath -> document ->createElement ('row ' );
280
286
if ($ row === false ) {
281
287
throw new XlsxFastEditorXmlException ("Error creating row {$ sheetNumber }/ {$ rowNumber }! " );
282
288
}
@@ -305,10 +311,7 @@ public function getRow(int $sheetNumber, int $rowNumber, int $accessMode = XlsxF
305
311
*/
306
312
public function getFirstRow (int $ sheetNumber ): ?XlsxFastEditorRow
307
313
{
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 ));
312
315
$ rs = $ xpath ->query ("/o:worksheet/o:sheetData/o:row[position() = 1] " );
313
316
if ($ rs !== false && $ rs ->length > 0 ) {
314
317
$ r = $ rs [0 ];
@@ -327,10 +330,7 @@ public function getFirstRow(int $sheetNumber): ?XlsxFastEditorRow
327
330
*/
328
331
public function getLastRow (int $ sheetNumber ): ?XlsxFastEditorRow
329
332
{
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 ));
334
334
$ rs = $ xpath ->query ("/o:worksheet/o:sheetData/o:row[position() = last()] " );
335
335
if ($ rs !== false && $ rs ->length > 0 ) {
336
336
$ r = $ rs [0 ];
@@ -348,10 +348,7 @@ public function getLastRow(int $sheetNumber): ?XlsxFastEditorRow
348
348
*/
349
349
public function deleteRow (int $ sheetNumber , int $ rowNumber ): bool
350
350
{
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 ));
355
352
$ rs = $ xpath ->query ("/o:worksheet/o:sheetData/o:row[@r=' {$ rowNumber }'][1] " );
356
353
if ($ rs !== false && $ rs ->length > 0 ) {
357
354
$ r = $ rs [0 ];
@@ -369,10 +366,7 @@ public function deleteRow(int $sheetNumber, int $rowNumber): bool
369
366
*/
370
367
public function rowsIterator (int $ sheetNumber ): \Traversable
371
368
{
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 ));
376
370
$ rs = $ xpath ->query ("/o:worksheet/o:sheetData/o:row " );
377
371
if ($ rs !== false ) {
378
372
for ($ i = 0 ; $ i < $ rs ->length ; $ i ++) {
@@ -427,16 +421,12 @@ public static function _columnOrderCompare(string $ref1, string $ref2): int
427
421
*/
428
422
public function getCell (int $ sheetNumber , string $ cellName , int $ accessMode = XlsxFastEditor::ACCESS_MODE_NULL ): ?XlsxFastEditorCell
429
423
{
430
- $ dom = $ this ->getDomFromPath (self ::getWorksheetPath ($ sheetNumber ));
431
- $ xpath = new \DOMXPath ($ dom );
432
- $ xpath ->registerNamespace ('o ' , self ::OXML_NAMESPACE );
433
-
434
424
if (!ctype_alnum ($ cellName )) {
435
425
throw new XlsxFastEditorInputException ("Invalid cell reference {$ cellName }! " );
436
426
}
437
427
$ cellName = strtoupper ($ cellName );
438
428
439
- $ c = null ;
429
+ $ xpath = $ this -> getXPathFromPath ( self :: getWorksheetPath ( $ sheetNumber )) ;
440
430
$ cs = $ xpath ->query ("/o:worksheet/o:sheetData/o:row/o:c[@r=' {$ cellName }'][1] " );
441
431
$ c = null ;
442
432
if ($ cs !== false && $ cs ->length > 0 ) {
@@ -511,12 +501,9 @@ public function readInt(int $sheetNumber, string $cellName): ?int
511
501
*/
512
502
public function _getSharedString (int $ stringNumber ): ?string
513
503
{
514
- $ dom = $ this ->getDomFromPath (self ::SHARED_STRINGS_PATH );
515
- $ xpath = new \DOMXPath ($ dom );
516
- $ xpath ->registerNamespace ('o ' , self ::OXML_NAMESPACE );
517
-
518
504
$ stringNumber ++; // Base 1
519
505
506
+ $ xpath = $ this ->getXPathFromPath (self ::SHARED_STRINGS_PATH );
520
507
$ ts = $ xpath ->query ("/o:sst/o:si[ $ stringNumber][1]/o:t[1] " );
521
508
if ($ ts !== false && $ ts ->length > 0 ) {
522
509
$ t = $ ts [0 ];
@@ -541,6 +528,84 @@ public function readString(int $sheetNumber, string $cellName): ?string
541
528
return $ cell === null ? null : $ cell ->readString ();
542
529
}
543
530
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
+
544
609
/**
545
610
* Write a formulat in the given worksheet at the given cell location, without changing the type/style of the cell.
546
611
* Auto-creates the cell if it does not already exists.
0 commit comments