1<?php 2 3namespace dokuwiki\plugin\totext\Extractor; 4 5use dokuwiki\plugin\totext\Exception\ExtractionException; 6use XMLReader; 7 8/** 9 * Extracts text from OpenDocument Spreadsheet (.ods) workbooks. 10 * 11 * Each table is rendered under a "=== Sheet: <name> ===" header with 12 * tab-separated cells and newline-separated rows, mirroring the XLSX output. 13 */ 14class OdsExtractor extends AbstractOdfExtractor 15{ 16 /** namespace URI for the OpenDocument table vocabulary */ 17 protected const TABLE_NS = 'urn:oasis:names:tc:opendocument:xmlns:table:1.0'; 18 19 /** safety cap for table:number-columns-repeated to avoid pathological expansion */ 20 protected const MAX_REPEAT = 1024; 21 22 /** @inheritDoc */ 23 protected function extractText(): string 24 { 25 $content = $this->readPart('content.xml'); 26 if ($content === null) { 27 throw new ExtractionException('Not a valid ODS file: missing content.xml'); 28 } 29 30 $reader = new XMLReader(); 31 if (!$reader->XML($content, 'UTF-8', LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING)) { 32 throw new ExtractionException('Failed to parse content.xml'); 33 } 34 35 try { 36 $out = []; 37 $sheetIndex = 0; 38 while ($reader->read()) { 39 if ($reader->nodeType === XMLReader::ELEMENT && $reader->localName === 'table') { 40 $sheetIndex++; 41 $name = $reader->getAttributeNs('name', self::TABLE_NS) ?? ('Sheet' . $sheetIndex); 42 $body = $this->extractTable($reader); 43 $out[] = "=== Sheet: $name ===\n" . $body; 44 } 45 } 46 return trim(implode("\n", $out)); 47 } finally { 48 $reader->close(); 49 } 50 } 51 52 /** 53 * Render a single <table:table> element as tab-separated rows. 54 * 55 * @param XMLReader $reader positioned on the opening <table:table> element 56 * @return string 57 */ 58 protected function extractTable(XMLReader $reader): string 59 { 60 if ($reader->isEmptyElement) { 61 return ''; 62 } 63 $out = ''; 64 $rowFirst = true; 65 $cellFirst = true; 66 while ($reader->read()) { 67 $nt = $reader->nodeType; 68 $local = $reader->localName; 69 if ($nt === XMLReader::ELEMENT && $local === 'table-row') { 70 if (!$rowFirst) { 71 $out .= "\n"; 72 } 73 $rowFirst = false; 74 $cellFirst = true; 75 } elseif ($nt === XMLReader::ELEMENT && ($local === 'table-cell' || $local === 'covered-table-cell')) { 76 $repeat = (int) ($reader->getAttributeNs('number-columns-repeated', self::TABLE_NS) ?? '1'); 77 if ($repeat < 1) { 78 $repeat = 1; 79 } 80 $value = $this->readCellValue($reader); 81 // Only honour repeats for cells that actually carry text; trailing 82 // repeated empty cells just pad the grid and would blow up output. 83 if ($value !== '') { 84 $repeat = min($repeat, self::MAX_REPEAT); 85 for ($r = 0; $r < $repeat; $r++) { 86 if (!$cellFirst) { 87 $out .= "\t"; 88 } 89 $cellFirst = false; 90 $out .= $value; 91 } 92 } else { 93 if (!$cellFirst) { 94 $out .= "\t"; 95 } 96 $cellFirst = false; 97 } 98 } elseif ($nt === XMLReader::END_ELEMENT && $local === 'table') { 99 break; 100 } 101 } 102 return $out; 103 } 104 105 /** 106 * Read the text content of a single table cell. 107 * 108 * @param XMLReader $reader positioned on the opening cell element 109 * @return string 110 */ 111 protected function readCellValue(XMLReader $reader): string 112 { 113 if ($reader->isEmptyElement) { 114 return ''; 115 } 116 $cellName = $reader->localName; 117 $value = ''; 118 $paraFirst = true; 119 while ($reader->read()) { 120 $nt = $reader->nodeType; 121 $local = $reader->localName; 122 if ($nt === XMLReader::TEXT || $nt === XMLReader::CDATA || $nt === XMLReader::SIGNIFICANT_WHITESPACE) { 123 $value .= $reader->value; 124 } elseif ($nt === XMLReader::ELEMENT && ($local === 'p' || $local === 'h')) { 125 // multiple paragraphs in one cell are separated by a space 126 if (!$paraFirst && $value !== '' && !str_ends_with($value, ' ')) { 127 $value .= ' '; 128 } 129 $paraFirst = false; 130 } elseif ($nt === XMLReader::ELEMENT && $local === 'tab') { 131 $value .= ' '; 132 } elseif ($nt === XMLReader::END_ELEMENT && $local === $cellName) { 133 break; 134 } 135 } 136 return trim($value); 137 } 138} 139