Skip to content

Commit 0a56442

Browse files
committed
Add read/write hyperlinks
And some refactoring
1 parent dfa42ef commit 0a56442

File tree

6 files changed

+191
-75
lines changed

6 files changed

+191
-75
lines changed

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,20 +50,21 @@ try {
5050
$nbWorksheets = $xlsxFastEditor->getWorksheetCount();
5151
$worksheetName = $xlsxFastEditor->getWorksheetName(1);
5252
$worksheetId1 = $xlsxFastEditor->getWorksheetNumber('Sheet1');
53-
$worksheetId2 = $xlsxFastEditor->getWorksheetNumber('Sheet2');
5453
// If you want to force Excel to recalculate formulas on next load:
55-
$xlsxFastEditor->setFullCalcOnLoad($worksheetId2, true);
54+
$xlsxFastEditor->setFullCalcOnLoad($worksheetId1, true);
5655

5756
// Direct read/write access
5857
$fx = $xlsxFastEditor->readFormula($worksheetId1, 'A1');
5958
$f = $xlsxFastEditor->readFloat($worksheetId1, 'B2');
6059
$i = $xlsxFastEditor->readInt($worksheetId1, 'C3');
61-
$s = $xlsxFastEditor->readString($worksheetId2, 'D4');
60+
$s = $xlsxFastEditor->readString($worksheetId1, 'D4');
61+
$h = $xlsxFastEditor->readHyperlink($worksheetId1, 'B4');
6262
$xlsxFastEditor->deleteRow($worksheetId1, 5);
6363
$xlsxFastEditor->writeFormula($worksheetId1, 'A1', '=B2*3');
6464
$xlsxFastEditor->writeFloat($worksheetId1, 'B2', 3.14);
6565
$xlsxFastEditor->writeInt($worksheetId1, 'C3', 13);
66-
$xlsxFastEditor->writeString($worksheetId2, 'D4', 'Hello');
66+
$xlsxFastEditor->writeString($worksheetId1, 'D4', 'Hello');
67+
$xlsxFastEditor->writeHyperlink($sheet1, 'B4', 'https://example.net/'); // Only for cells with an existing hyperlink
6768

6869
// Regex search & replace operating globally on all the worksheets:
6970
$xlsxFastEditor->textReplace('/Hello/i', 'World');
@@ -91,10 +92,12 @@ try {
9192
$f = $cell->readFloat();
9293
$i = $cell->readInt();
9394
$s = $cell->readString();
95+
$h = $cell->readHyperlink();
9496
$cell->writeFormula('=B2*3');
9597
$cell->writeFloat(3.14);
9698
$cell->writeInt(13);
9799
$cell->writeString('Hello');
100+
$cell->writeHyperlink('https://example.net/'); // Only for cells with an existing hyperlink
98101

99102
// Iterators for existing rows and cells
100103
foreach ($xlsxFastEditor->rowsIterator($worksheetId1) as $row) {

src/XlsxFastEditor.php

Lines changed: 135 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
*/
2020
final 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

Comments
 (0)