xref: /plugin/totext/Extractor/AbstractZipXmlExtractor.php (revision 0fbee1892eb8b7339ce48e27e48e3fc821a8695f)
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