PluginProbe
Mail Queue / trunk
Mail Queue vtrunk
1.6.1 1.6.0 1.5.1 trunk 1.0 1.1 1.2 1.3 1.3.1 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.5.0
mail-queue / lib / html2text / src / Html2Text.php

Html2Text.php in Mail Queue trunk, at lib/html2text/src/Html2Text.php

541 lines 14.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Soundasleep;
4
5 class Html2Text {
6
7 /** @return array<string, bool | string> */
8 public static function defaultOptions(): array {
9 return [
10 'ignore_errors' => false,
11 'drop_links' => false,
12 'char_set' => 'auto'
13 ];
14 }
15
16 /**
17 * Tries to convert the given HTML into a plain text format - best suited for
18 * e-mail display, etc.
19 *
20 * <p>In particular, it tries to maintain the following features:
21 * <ul>
22 * <li>Links are maintained, with the 'href' copied over
23 * <li>Information in the &lt;head&gt; is lost
24 * </ul>
25 *
26 * @param string $html the input HTML
27 * @param boolean|array<string, bool | string> $options if boolean, Ignore xml parsing errors, else ['ignore_errors' => false, 'drop_links' => false, 'char_set' => 'auto']
28 * @return string the HTML converted, as best as possible, to text
29 * @throws Html2TextException if the HTML could not be loaded as a {@link \DOMDocument}
30 */
31 public static function convert(string $html, $options = []): string {
32
33 if ($options === false || $options === true) {
34 // Using old style (< 1.0) of passing in options
35 $options = ['ignore_errors' => $options];
36 }
37
38 $options = array_merge(static::defaultOptions(), $options);
39
40 // check all options are valid
41 foreach ($options as $key => $value) {
42 if (!in_array($key, array_keys(static::defaultOptions()))) {
43 throw new \InvalidArgumentException("Unknown html2text option '$key'. Valid options are " . implode(',', static::defaultOptions()));
44 }
45 }
46
47 $is_office_document = self::isOfficeDocument($html);
48
49 if ($is_office_document) {
50 // remove office namespace
51 $html = str_replace(["<o:p>", "</o:p>"], "", $html);
52 }
53
54 $html = self::fixNewlines($html);
55
56 // use mb_convert_encoding for legacy versions of php
57 if (PHP_MAJOR_VERSION * 10 + PHP_MINOR_VERSION < 81 && mb_detect_encoding($html, "UTF-8", true)) {
58 $html = mb_convert_encoding($html, "HTML-ENTITIES", "UTF-8");
59 }
60
61 $doc = self::getDocument($html, $options);
62
63 $output = self::iterateOverNode($doc, null, false, $is_office_document, $options);
64
65 // process output for whitespace/newlines
66 $output = self::processWhitespaceNewlines($output);
67
68 return $output;
69 }
70
71 /**
72 * Unify newlines; in particular, \r\n becomes \n, and
73 * then \r becomes \n. This means that all newlines (Unix, Windows, Mac)
74 * all become \ns.
75 *
76 * @param string $text text with any number of \r, \r\n and \n combinations
77 * @return string the fixed text
78 */
79 public static function fixNewlines(string $text): string {
80 // replace \r\n to \n
81 $text = str_replace("\r\n", "\n", $text);
82 // remove \rs
83 $text = str_replace("\r", "\n", $text);
84
85 return $text;
86 }
87
88 /** @return array<string> */
89 public static function nbspCodes(): array {
90 return [
91 "\xc2\xa0",
92 "\u00a0",
93 ];
94 }
95
96 /** @return array<string> */
97 public static function zwnjCodes(): array {
98 return [
99 "\xe2\x80\x8c",
100 "\u200c",
101 ];
102 }
103
104 /**
105 * Remove leading or trailing spaces and excess empty lines from provided multiline text
106 *
107 * @param string $text multiline text any number of leading or trailing spaces or excess lines
108 * @return string the fixed text
109 */
110 public static function processWhitespaceNewlines(string $text): string {
111
112 // remove excess spaces around tabs
113 $text = preg_replace("/ *\t */im", "\t", $text);
114
115 // remove leading whitespace
116 $text = ltrim($text);
117
118 // remove leading spaces on each line
119 $text = preg_replace("/\n[ \t]*/im", "\n", $text);
120
121 // convert non-breaking spaces to regular spaces to prevent output issues,
122 // do it here so they do NOT get removed with other leading spaces, as they
123 // are sometimes used for indentation
124 $text = self::renderText($text);
125
126 // remove trailing whitespace
127 $text = rtrim($text);
128
129 // remove trailing spaces on each line
130 $text = preg_replace("/[ \t]*\n/im", "\n", $text);
131
132 // unarmor pre blocks
133 $text = self::fixNewLines($text);
134
135 // remove unnecessary empty lines
136 $text = preg_replace("/\n\n\n*/im", "\n\n", $text);
137
138 return $text;
139 }
140
141 /**
142 * Can we guess that this HTML is generated by Microsoft Office?
143 */
144 public static function isOfficeDocument(string $html): bool {
145 return strpos($html, "urn:schemas-microsoft-com:office") !== false;
146 }
147
148 public static function isWhitespace(string $text): bool {
149 return strlen(trim(self::renderText($text), "\n\r\t ")) === 0;
150 }
151
152 /**
153 * Parse HTML into a DOMDocument
154 *
155 * @param string $html the input HTML
156 * @param array<string, bool | string> $options
157 * @return \DOMDocument the parsed document tree
158 */
159 private static function getDocument(string $html, array $options): \DOMDocument {
160
161 $doc = new \DOMDocument();
162
163 $html = trim($html);
164
165 if (!$html) {
166 // DOMDocument doesn't support empty value and throws an error
167 // Return empty document instead
168 return $doc;
169 }
170
171 if ($html[0] !== '<') {
172 // If HTML does not begin with a tag, we put a body tag around it.
173 // If we do not do this, PHP will insert a paragraph tag around
174 // the first block of text for some reason which can mess up
175 // the newlines. See pre.html test for an example.
176 $html = '<body>' . $html . '</body>';
177 }
178
179 $header = '';
180 // use char sets for modern versions of php
181 if (PHP_MAJOR_VERSION * 10 + PHP_MINOR_VERSION >= 81) {
182 // use specified char_set, or auto detect if not set
183 $char_set = ! empty($options['char_set']) ? $options['char_set'] : 'auto';
184 if ('auto' === $char_set) {
185 $char_set = mb_detect_encoding($html);
186 } else if (strpos($char_set, ',')) {
187 mb_detect_order($char_set);
188 $char_set = mb_detect_encoding($html);
189 }
190 // turn off error detection for Windows-1252 legacy html
191 if (strpos($char_set, '1252')) {
192 $options['ignore_errors'] = true;
193 }
194 $header = '<?xml version="1.0" encoding="' . $char_set . '">';
195 }
196
197 if (! empty($options['ignore_errors'])) {
198 $doc->strictErrorChecking = false;
199 $doc->recover = true;
200 $doc->xmlStandalone = true;
201 $old_internal_errors = libxml_use_internal_errors(true);
202 $load_result = $doc->loadHTML($header . $html, LIBXML_NOWARNING | LIBXML_NOERROR | LIBXML_NONET | LIBXML_PARSEHUGE);
203 libxml_use_internal_errors($old_internal_errors);
204 }
205 else {
206 $load_result = $doc->loadHTML($header . $html);
207 }
208
209 if (!$load_result) {
210 throw new Html2TextException("Could not load HTML - badly formed?", $html);
211 }
212
213 return $doc;
214 }
215
216 /**
217 * Replace any special characters with simple text versions, to prevent output issues:
218 * - Convert non-breaking spaces to regular spaces; and
219 * - Convert zero-width non-joiners to '' (nothing).
220 *
221 * This is to match our goal of rendering documents as they would be rendered
222 * by a browser.
223 */
224 private static function renderText(string $text): string {
225 $text = str_replace(self::nbspCodes(), " ", $text);
226 $text = str_replace(self::zwnjCodes(), "", $text);
227 return $text;
228 }
229
230 private static function nextChildName(?\DOMNode $node): ?string {
231 // get the next child
232 $nextNode = $node->nextSibling;
233 while ($nextNode != null) {
234 if ($nextNode instanceof \DOMText) {
235 if (!self::isWhitespace($nextNode->wholeText)) {
236 break;
237 }
238 }
239
240 if ($nextNode instanceof \DOMElement) {
241 break;
242 }
243
244 $nextNode = $nextNode->nextSibling;
245 }
246
247 $nextName = null;
248 if (($nextNode instanceof \DOMElement || $nextNode instanceof \DOMText) && $nextNode != null) {
249 $nextName = strtolower($nextNode->nodeName);
250 }
251
252 return $nextName;
253 }
254
255 /** @param array<string, bool | string> $options */
256 private static function iterateOverNode(\DOMNode $node, ?string $prevName, bool $in_pre, bool $is_office_document, array $options): string {
257 if ($node instanceof \DOMText) {
258 // Replace whitespace characters with a space (equivilant to \s)
259 if ($in_pre) {
260 $text = "\n" . trim(self::renderText($node->wholeText), "\n\r\t ") . "\n";
261
262 // Remove trailing whitespace only
263 $text = preg_replace("/[ \t]*\n/im", "\n", $text);
264
265 // armor newlines with \r.
266 return str_replace("\n", "\r", $text);
267
268 }
269 $text = self::renderText($node->wholeText);
270 $text = preg_replace("/[\\t\\n\\f\\r ]+/im", " ", $text);
271
272 if (!self::isWhitespace($text) && ($prevName == 'p' || $prevName == 'div')) {
273 return "\n" . $text;
274 }
275 return $text;
276 }
277
278 if ($node instanceof \DOMDocumentType || $node instanceof \DOMProcessingInstruction) {
279 // ignore
280 return "";
281 }
282
283 $name = strtolower($node->nodeName);
284 $nextName = self::nextChildName($node);
285
286 // start whitespace
287 switch ($name) {
288 case "hr":
289 $prefix = '';
290 if ($prevName != null) {
291 $prefix = "\n";
292 }
293 return $prefix . "---------------------------------------------------------------\n";
294
295 case "style":
296 case "head":
297 case "title":
298 case "meta":
299 case "script":
300 // ignore these tags
301 return "";
302
303 case "h1":
304 case "h2":
305 case "h3":
306 case "h4":
307 case "h5":
308 case "h6":
309 case "ol":
310 case "ul":
311 case "pre":
312 // add two newlines
313 $output = "\n\n";
314 break;
315
316 case "td":
317 case "th":
318 // add tab char to separate table fields
319 $output = "\t";
320 break;
321
322 case "p":
323 // Microsoft exchange emails often include HTML which, when passed through
324 // html2text, results in lots of double line returns everywhere.
325 //
326 // To fix this, for any p element with a className of `MsoNormal` (the standard
327 // classname in any Microsoft export or outlook for a paragraph that behaves
328 // like a line return) we skip the first line returns and set the name to br.
329 // @phpstan-ignore-next-line
330 if ($is_office_document && $node->getAttribute('class') == 'MsoNormal') {
331 $output = "";
332 $name = 'br';
333 break;
334 }
335
336 // add two lines
337 $output = "\n\n";
338 break;
339
340 case "tr":
341 // add one line
342 $output = "\n";
343 break;
344
345 case "div":
346 $output = "";
347 if ($prevName !== null) {
348 // add one line
349 $output .= "\n";
350 }
351 break;
352
353 case "li":
354 $output = "- ";
355 break;
356
357 default:
358 // print out contents of unknown tags
359 $output = "";
360 break;
361 }
362
363 // debug
364 //$output .= "[$name,$nextName]";
365
366 if (isset($node->childNodes)) {
367
368 $n = $node->childNodes->item(0);
369 $previousSiblingNames = [];
370 $previousSiblingName = null;
371
372 $parts = [];
373 $trailing_whitespace = 0;
374
375 while ($n != null) {
376
377 $text = self::iterateOverNode($n, $previousSiblingName, $in_pre || $name == 'pre', $is_office_document, $options);
378
379 // Pass current node name to next child, as previousSibling does not appear to get populated
380 if ($n instanceof \DOMDocumentType
381 || $n instanceof \DOMProcessingInstruction
382 || ($n instanceof \DOMText && self::isWhitespace($text))) {
383 // Keep current previousSiblingName, these are invisible
384 $trailing_whitespace++;
385 }
386 else {
387 $previousSiblingName = strtolower($n->nodeName);
388 $previousSiblingNames[] = $previousSiblingName;
389 $trailing_whitespace = 0;
390 }
391
392 $node->removeChild($n);
393 $n = $node->childNodes->item(0);
394
395 $parts[] = $text;
396 }
397
398 // Remove trailing whitespace, important for the br check below
399 while ($trailing_whitespace-- > 0) {
400 array_pop($parts);
401 }
402
403 // suppress last br tag inside a node list if follows text
404 $last_name = array_pop($previousSiblingNames);
405 if ($last_name === 'br') {
406 $last_name = array_pop($previousSiblingNames);
407 if ($last_name === '#text') {
408 array_pop($parts);
409 }
410 }
411
412 $output .= implode('', $parts);
413 }
414
415 // end whitespace
416 switch ($name) {
417 case "h1":
418 case "h2":
419 case "h3":
420 case "h4":
421 case "h5":
422 case "h6":
423 case "pre":
424 case "p":
425 // add two lines
426 $output .= "\n\n";
427 break;
428
429 case "br":
430 // add one line
431 $output .= "\n";
432 break;
433
434 case "div":
435 break;
436
437 case "a":
438 // links are returned in [text](link) format
439 // @phpstan-ignore-next-line
440 $href = $node->getAttribute("href");
441
442 $output = trim($output);
443
444 // remove double [[ ]] s from linking images
445 if (substr($output, 0, 1) == "[" && substr($output, -1) == "]") {
446 $output = substr($output, 1, strlen($output) - 2);
447
448 // for linking images, the title of the <a> overrides the title of the <img>
449 // @phpstan-ignore-next-line
450 if ($node->getAttribute("title")) {
451 // @phpstan-ignore-next-line
452 $output = $node->getAttribute("title");
453 }
454 }
455
456 // if there is no link text, but a title attr
457 // @phpstan-ignore-next-line
458 if (!$output && $node->getAttribute("title")) {
459 // @phpstan-ignore-next-line
460 $output = $node->getAttribute("title");
461 }
462
463 if ($href == null) {
464 // it doesn't link anywhere
465 // @phpstan-ignore-next-line
466 if ($node->getAttribute("name") != null) {
467 if ($options['drop_links']) {
468 $output = "$output";
469 } else {
470 $output = "[$output]";
471 }
472 }
473 } else {
474 if ($href == $output || $href == "mailto:$output" || $href == "http://$output" || $href == "https://$output") {
475 // link to the same address: just use link
476 $output = "$output";
477 } else {
478 // replace it
479 if ($output) {
480 if ($options['drop_links']) {
481 $output = "$output";
482 } else {
483 $output = "[$output]($href)";
484 }
485 } else {
486 // empty string
487 $output = "$href";
488 }
489 }
490 }
491
492 // does the next node require additional whitespace?
493 switch ($nextName) {
494 case "h1": case "h2": case "h3": case "h4": case "h5": case "h6":
495 $output .= "\n";
496 break;
497 }
498 break;
499
500 case "img":
501 // @phpstan-ignore-next-line
502 if ($node->getAttribute("title")) {
503 // @phpstan-ignore-next-line
504 $output = "[" . $node->getAttribute("title") . "]";
505 // @phpstan-ignore-next-line
506 } elseif ($node->getAttribute("alt")) {
507 // @phpstan-ignore-next-line
508 $output = "[" . $node->getAttribute("alt") . "]";
509 } else {
510 $output = "";
511 }
512 break;
513
514 case "li":
515 $output .= "\n";
516 break;
517
518 case "blockquote":
519 // process quoted text for whitespace/newlines
520 $output = self::processWhitespaceNewlines($output);
521
522 // add leading newline
523 $output = "\n" . $output;
524
525 // prepend '> ' at the beginning of all lines
526 $output = preg_replace("/\n/im", "\n> ", $output);
527
528 // replace leading '> >' with '>>'
529 $output = preg_replace("/\n> >/im", "\n>>", $output);
530
531 // add another leading newline and trailing newlines
532 $output = "\n" . $output . "\n\n";
533 break;
534 default:
535 // do nothing
536 }
537
538 return $output;
539 }
540 }
541