Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
72.86% |
51 / 70 |
|
14.29% |
1 / 7 |
CRAP | |
0.00% |
0 / 1 |
| WPS | |
72.86% |
51 / 70 |
|
14.29% |
1 / 7 |
50.22 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| canRead | |
66.67% |
4 / 6 |
|
0.00% |
0 / 1 |
3.33 | |||
| load | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
4.02 | |||
| extractText | |
68.75% |
22 / 32 |
|
0.00% |
0 / 1 |
14.69 | |||
| readContentsStream | |
77.78% |
7 / 9 |
|
0.00% |
0 / 1 |
6.40 | |||
| getUShort | |
66.67% |
4 / 6 |
|
0.00% |
0 / 1 |
3.33 | |||
| getULong | |
66.67% |
4 / 6 |
|
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 | |
| 19 | namespace PhpOffice\PhpWord\Reader; |
| 20 | |
| 21 | use InvalidArgumentException; |
| 22 | use PhpOffice\PhpWord\Exception\Exception; |
| 23 | use PhpOffice\PhpWord\PhpWord; |
| 24 | use 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 | */ |
| 37 | class 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 | } |