1<?php 2 3namespace dokuwiki\plugin\totext\Extractor; 4 5use dokuwiki\plugin\totext\Exception\ExtractionException; 6use FilesystemIterator; 7use RecursiveDirectoryIterator; 8use RecursiveIteratorIterator; 9use splitbrain\PHPArchive\Zip; 10use XMLReader; 11 12/** 13 * Base class for extractors that read a ZIP container of XML parts. 14 * 15 * This covers both OOXML (DOCX/XLSX/PPTX) and OpenDocument (ODT/ODS/ODP), 16 * which all package their content as XML inside a ZIP archive. Subclasses 17 * declare their extension and implement extractText() using readPart(), 18 * listParts() and the XML text-walking helpers provided here. 19 */ 20abstract class AbstractZipXmlExtractor implements ExtractorInterface 21{ 22 /** @var string path to the temp dir the archive was extracted into */ 23 protected string $tempDir = ''; 24 25 /** 26 * Extract text from the already-unpacked archive (in $this->tempDir). 27 * 28 * @return string 29 */ 30 abstract protected function extractText(): string; 31 32 /** 33 * Extract the canonical metadata map from the already-unpacked archive. 34 * 35 * Implemented per format family (see AbstractOoxmlExtractor / 36 * AbstractOdfExtractor), reading the family's metadata part(s) from 37 * $this->tempDir. Best-effort: a missing or broken metadata part yields an 38 * empty array rather than throwing, so the body text is still returned. 39 * 40 * @return array<string, string> canonical key => value map 41 */ 42 abstract protected function extractMetadata(): array; 43 44 /** 45 * Clean up any leftover temp dir when the instance is destroyed. 46 * 47 * extract() removes the temp dir promptly in its finally block; this acts 48 * as a safety net so a dir is still removed if the process dies (e.g. on a 49 * fatal error) before that block runs. 50 */ 51 public function __destruct() 52 { 53 $this->cleanup(); 54 } 55 56 /** @inheritDoc */ 57 public function extract(string $path): ExtractionResult 58 { 59 if (!is_file($path)) { 60 throw new ExtractionException("File not found: $path"); 61 } 62 63 $this->tempDir = $this->makeTempDir(); 64 try { 65 // Opening the container is the total-failure gate: if the archive 66 // cannot be unpacked, nothing is recoverable and we throw. 67 try { 68 $zip = new Zip(); 69 $zip->open($path); 70 $zip->extract($this->tempDir); 71 $zip->close(); 72 } catch (\Throwable $e) { 73 throw ExtractionException::wrap($e, "Failed to open $path"); 74 } 75 76 // The container opened: text and metadata are independent halves, 77 // each recorded so a failure in one never discards the other. 78 $text = ''; 79 $textError = null; 80 try { 81 $text = $this->extractText(); 82 } catch (\Throwable $e) { 83 $textError = ExtractionException::wrap($e, "Failed to extract text from $path"); 84 } 85 86 $metadata = []; 87 $metadataError = null; 88 try { 89 $metadata = $this->extractMetadata(); 90 } catch (\Throwable $e) { 91 $metadataError = ExtractionException::wrap($e, "Failed to extract metadata from $path"); 92 } 93 94 return new ExtractionResult($text, $metadata, $textError, $metadataError); 95 } finally { 96 $this->cleanup(); 97 } 98 } 99 100 /** 101 * Read a single part from the unpacked archive. 102 * 103 * @param string $internalPath path relative to the archive root 104 * @return string|null the part content, or null if it does not exist 105 */ 106 protected function readPart(string $internalPath): ?string 107 { 108 $full = $this->tempDir . '/' . ltrim($internalPath, '/'); 109 if (!is_file($full)) { 110 return null; 111 } 112 $data = file_get_contents($full); 113 return $data === false ? null : $data; 114 } 115 116 /** 117 * List all parts whose internal path starts with the given prefix. 118 * 119 * @param string $prefix internal path prefix to match 120 * @return string[] internal paths (relative to archive root), naturally sorted 121 */ 122 protected function listParts(string $prefix): array 123 { 124 if (!is_dir($this->tempDir)) { 125 return []; 126 } 127 $base = $this->tempDir . '/'; 128 $results = []; 129 $it = new RecursiveIteratorIterator( 130 new RecursiveDirectoryIterator($this->tempDir, FilesystemIterator::SKIP_DOTS), 131 ); 132 foreach ($it as $file) { 133 if (!$file->isFile()) { 134 continue; 135 } 136 $rel = str_replace('\\', '/', substr($file->getPathname(), strlen($base))); 137 if (str_starts_with($rel, $prefix)) { 138 $results[] = $rel; 139 } 140 } 141 sort($results, SORT_NATURAL); 142 return $results; 143 } 144 145 /** 146 * Stream-parse an XML metadata part into a canonical key => value map. 147 * 148 * Walks the document and, for every element whose local name is a key in 149 * $map, captures its text content under the mapped canonical key. Matching 150 * is by local name only (namespace-agnostic), which is enough because the 151 * metadata vocabularies (OOXML core/app, ODF meta) use distinct local 152 * names. Empty values are dropped. Local names listed (by their canonical 153 * key) in $multiValueKeys accumulate every occurrence, space-joined; all 154 * others keep the last non-empty occurrence. 155 * 156 * Best-effort: returns an empty array if the XML cannot be parsed. 157 * 158 * @param string $xml the metadata part XML 159 * @param array<string, string> $map element local name => canonical key 160 * @param string[] $multiValueKeys canonical keys whose values accumulate 161 * @return array<string, string> canonical key => value map 162 */ 163 protected function mapMetadataFromXml(string $xml, array $map, array $multiValueKeys = []): array 164 { 165 $reader = new XMLReader(); 166 if (!$reader->XML($xml, 'UTF-8', LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING)) { 167 return []; 168 } 169 $multi = array_flip($multiValueKeys); 170 $out = []; 171 try { 172 while ($reader->read()) { 173 if ($reader->nodeType !== XMLReader::ELEMENT) { 174 continue; 175 } 176 $key = $map[$reader->localName] ?? null; 177 if ($key === null) { 178 continue; 179 } 180 $value = trim($reader->readString()); 181 if ($value === '') { 182 continue; 183 } 184 if (isset($multi[$key]) && isset($out[$key])) { 185 $out[$key] .= ' ' . $value; 186 } else { 187 $out[$key] = $value; 188 } 189 } 190 } finally { 191 $reader->close(); 192 } 193 return $out; 194 } 195 196 /** 197 * Stream-parse XML and concatenate text from elements matching $textElement. 198 * 199 * Used by OOXML formats where runs of text live in a known wrapper element 200 * (e.g. <w:t>, <a:t>). Block elements emit a newline; tab elements emit a tab. 201 * 202 * @param string $xml the XML document 203 * @param string $textElement local name of the text-carrying element 204 * @param string[] $blockElements local names that should emit a newline 205 * @param string[] $tabElements local names that should emit a tab 206 * @return string 207 * @throws ExtractionException if the XML cannot be parsed 208 */ 209 protected function extractTextFromXml( 210 string $xml, 211 string $textElement, 212 array $blockElements = [], 213 array $tabElements = [], 214 ): string { 215 $reader = new XMLReader(); 216 if (!$reader->XML($xml, 'UTF-8', LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING)) { 217 throw new ExtractionException('Failed to parse XML'); 218 } 219 try { 220 $out = ''; 221 $blocks = array_flip($blockElements); 222 $tabs = array_flip($tabElements); 223 while ($reader->read()) { 224 if ($reader->nodeType !== XMLReader::ELEMENT) { 225 continue; 226 } 227 $local = $reader->localName; 228 if ($local === $textElement) { 229 $out .= $reader->readString(); 230 } elseif (isset($blocks[$local])) { 231 if ($out !== '' && !str_ends_with($out, "\n")) { 232 $out .= "\n"; 233 } 234 } elseif (isset($tabs[$local])) { 235 $out .= "\t"; 236 } 237 } 238 return $out; 239 } finally { 240 $reader->close(); 241 } 242 } 243 244 /** 245 * Stream-parse XML and concatenate ALL character data. 246 * 247 * Used by OpenDocument formats, which store text directly as character 248 * data inside paragraph elements rather than in a single wrapper element. 249 * Block elements emit a newline; tab elements emit a tab. 250 * 251 * @param string $xml the XML document 252 * @param string[] $blockElements local names that should emit a newline 253 * @param string[] $tabElements local names that should emit a tab 254 * @return string 255 * @throws ExtractionException if the XML cannot be parsed 256 */ 257 protected function extractAllTextFromXml( 258 string $xml, 259 array $blockElements = [], 260 array $tabElements = [], 261 ): string { 262 $reader = new XMLReader(); 263 if (!$reader->XML($xml, 'UTF-8', LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING)) { 264 throw new ExtractionException('Failed to parse XML'); 265 } 266 try { 267 $out = ''; 268 $blocks = array_flip($blockElements); 269 $tabs = array_flip($tabElements); 270 while ($reader->read()) { 271 $nt = $reader->nodeType; 272 if ($nt === XMLReader::TEXT || $nt === XMLReader::CDATA || $nt === XMLReader::SIGNIFICANT_WHITESPACE) { 273 $out .= $reader->value; 274 } elseif ($nt === XMLReader::ELEMENT) { 275 $local = $reader->localName; 276 if (isset($blocks[$local])) { 277 // drop any indentation whitespace captured before the block 278 $out = rtrim($out, " \t"); 279 if ($out !== '' && !str_ends_with($out, "\n")) { 280 $out .= "\n"; 281 } 282 } elseif (isset($tabs[$local])) { 283 $out .= "\t"; 284 } 285 } 286 } 287 return $out; 288 } finally { 289 $reader->close(); 290 } 291 } 292 293 /** 294 * Create a private temporary directory for unpacking the archive. 295 * 296 * Uses DokuWiki's temp dir ($conf['tmpdir']) via the core io_mktmpdir() 297 * helper rather than the system temp dir. 298 * 299 * @return string the created directory path 300 * @throws ExtractionException if the directory cannot be created 301 */ 302 protected function makeTempDir(): string 303 { 304 $dir = io_mktmpdir(); 305 if ($dir === false) { 306 throw new ExtractionException('Could not create temp dir'); 307 } 308 return $dir; 309 } 310 311 /** 312 * Recursively remove the unpack temp dir and reset the tracking property. 313 * 314 * Safe to call repeatedly and when no temp dir is set (the destructor and 315 * extract()'s finally block both call it). 316 * 317 * @return void 318 */ 319 protected function cleanup(): void 320 { 321 if ($this->tempDir === '') { 322 return; 323 } 324 io_rmdir($this->tempDir, true); 325 $this->tempDir = ''; 326 } 327} 328