Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.86% covered (warning)
72.86%
51 / 70
14.29% covered (danger)
14.29%
1 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPS
72.86% covered (warning)
72.86%
51 / 70
14.29% covered (danger)
14.29%
1 / 7
50.22
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canRead
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 load
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 extractText
68.75% covered (warning)
68.75%
22 / 32
0.00% covered (danger)
0.00%
0 / 1
14.69
 readContentsStream
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
6.40
 getUShort
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 getULong
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
1<?php
2
3/**
4 * This file is part of PHPWord - A pure PHP library for reading and writing
5 * word processing documents.
6 *
7 * PHPWord is free software distributed under the terms of the GNU Lesser
8 * General Public License version 3 as published by the Free Software Foundation.
9 *
10 * For the full copyright and license information, please read the LICENSE
11 * file that was distributed with this source code. For the full list of
12 * contributors, visit https://github.com/PHPOffice/PHPWord/contributors.
13 *
14 * @see         https://github.com/PHPOffice/PHPWord
15 *
16 * @license     http://www.gnu.org/licenses/lgpl.txt LGPL version 3
17 */
18
19namespace PhpOffice\PhpWord\Reader;
20
21use InvalidArgumentException;
22use PhpOffice\PhpWord\Exception\Exception;
23use PhpOffice\PhpWord\PhpWord;
24use PhpOffice\PhpWord\Shared\OLERead;
25
26/**
27 * Reader for Microsoft Works WPS documents (Works 8 and later).
28 *
29 * Extracts the textual content stored in the OLE "CONTENTS" stream. The
30 * stream begins with the "CHNKWKS" magic and holds a linked list of chunk
31 * blocks; each block starts with an 0x01F8 tag followed by named entries,
32 * one of which ("TEXT") points at the UTF-16LE document text.
33 *
34 * @see https://github.com/PHPOffice/PHPWord/issues/69
35 * @since 1.5.0
36 */
37class WPS extends AbstractReader implements ReaderInterface
38{
39    /**
40     * Chunk block tag.
41     */
42    const CHUNK_MAGIC = 0x01F8;
43
44    /**
45     * Chunk block size.
46     */
47    const CHUNK_BLOCK_SIZE = 0x0E00;
48
49    public function __construct()
50    {
51        $this->readDataOnly = true;
52    }
53
54    /**
55     * Can the current Reader read the file?
56     *
57     * @param string $filename
58     */
59    public function canRead($filename): bool
60    {
61        try {
62            $contents = $this->readContentsStream($filename);
63
64            return strpos($contents, 'CHNKWKS') === 0;
65        } catch (Exception $e) {
66            return false;
67        } catch (InvalidArgumentException $e) {
68            return false;
69        }
70    }
71
72    /**
73     * Loads PhpWord from file.
74     *
75     * @param string $docFile
76     */
77    public function load($docFile): PhpWord
78    {
79        $phpWord = new PhpWord();
80        $text = $this->extractText($docFile);
81
82        $section = $phpWord->addSection();
83        $lines = preg_split('/\r\n|\r|\n/u', $text);
84        if ($lines === false) {
85            throw new Exception('Invalid WPS file: failed to split text into paragraphs');
86        }
87        foreach ($lines as $line) {
88            if ($line !== '') {
89                $section->addText($line);
90            }
91        }
92
93        return $phpWord;
94    }
95
96    /**
97     * Extract the UTF-16LE text from the CONTENTS stream of a WPS document.
98     */
99    protected function extractText(string $filename): string
100    {
101        $contents = $this->readContentsStream($filename);
102
103        if (strpos($contents, 'CHNKWKS') !== 0) {
104            throw new Exception('Invalid WPS file: CHNKWKS magic not found');
105        }
106
107        if (substr($contents, 0, 7) === 'CHNKINK') {
108            throw new Exception('WPS files prior to version 8 are not supported');
109        }
110
111        $totalEntries = $this->getUShort($contents, 12);
112        $entriesPos = 24;
113
114        while (true) {
115            $magic = $this->getUShort($contents, $entriesPos);
116            if ($magic !== self::CHUNK_MAGIC) {
117                throw new Exception('Invalid WPS file: chunk tag mismatch');
118            }
119
120            $localEntries = $this->getUShort($contents, $entriesPos + 2);
121            $nextOffset = $this->getULong($contents, $entriesPos + 4);
122            $entryPos = $entriesPos + 8;
123
124            for ($i = 0; $i < $localEntries; ++$i) {
125                $entrySize = $this->getUShort($contents, $entryPos);
126                $name = substr($contents, $entryPos + 4, 4);
127                if ($name === 'TEXT') {
128                    $textOffset = $this->getULong($contents, $entryPos + 18);
129                    $textSize = $this->getULong($contents, $entryPos + 22);
130                    $raw = substr($contents, $textOffset, $textSize);
131                    if ($raw === '') {
132                        throw new Exception('Invalid WPS file: empty TEXT chunk');
133                    }
134                    $text = iconv('UTF-16LE', 'UTF-8', $raw);
135                    if ($text === false) {
136                        throw new Exception('Invalid WPS file: text is not valid UTF-16LE');
137                    }
138
139                    return str_replace("\r", "\r\n", $text);
140                }
141                $entryPos += $entrySize;
142            }
143
144            $totalEntries -= $localEntries;
145            if ($totalEntries > 0 && $nextOffset > 0) {
146                $entriesPos = $nextOffset;
147            } else {
148                throw new Exception('Invalid WPS file: TEXT chunk not found');
149            }
150        }
151    }
152
153    /**
154     * Read the OLE "CONTENTS" stream of a WPS document.
155     */
156    protected function readContentsStream(string $filename): string
157    {
158        $ole = new OLERead();
159        $ole->read($filename);
160
161        foreach ($ole->props as $index => $property) {
162            if (strtoupper($property['name']) === 'CONTENTS' && $property['type'] === 2) {
163                $stream = $ole->getStream($index);
164                if ($stream === null || $stream === '') {
165                    break;
166                }
167
168                return $stream;
169            }
170        }
171
172        throw new Exception('Invalid WPS file: CONTENTS stream not found');
173    }
174
175    /**
176     * Read a 16-bit unsigned integer (little endian).
177     */
178    protected function getUShort(string $data, int $offset): int
179    {
180        if ($offset + 2 > strlen($data)) {
181            throw new Exception('Invalid WPS file: unexpected end of data');
182        }
183
184        $value = unpack('v', substr($data, $offset, 2));
185        if ($value === false) {
186            throw new Exception('Invalid WPS file: failed to read 16-bit value');
187        }
188
189        return $value[1];
190    }
191
192    /**
193     * Read a 32-bit unsigned integer (little endian).
194     */
195    protected function getULong(string $data, int $offset): int
196    {
197        if ($offset + 4 > strlen($data)) {
198            throw new Exception('Invalid WPS file: unexpected end of data');
199        }
200
201        $value = unpack('V', substr($data, $offset, 4));
202        if ($value === false) {
203            throw new Exception('Invalid WPS file: failed to read 32-bit value');
204        }
205
206        return $value[1];
207    }
208}