xref: /plugin/totext/Extractor/OdsExtractor.php (revision fd4f7c47d290ec79c2b911626d1d262c1e3d161e)
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