├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml ├── src ├── Converter.php ├── ConverterExtra.php └── Parser.php └── test ├── ConverterExtraTest.php ├── ConverterTest.php └── ConverterTestCase.php /.gitignore: -------------------------------------------------------------------------------- 1 | # Editor config 2 | .idea 3 | 4 | # Dependencies 5 | vendor 6 | composer.lock 7 | 8 | # Local test files 9 | test.html 10 | test.php -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | before_script: 3 | - wget http://getcomposer.org/composer.phar 4 | - php composer.phar install 5 | php: 6 | - 7.3 7 | - 7.2 8 | - 7.1 9 | - 7.0 10 | - 5.6 11 | - 5.5 12 | - 5.4 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ============== 3 | 4 | 5 | 22/01/2019 v2.3.1 6 | -------------- 7 | 8 | * Fix: PHP 7.3 support (#36) 9 | 10 | 11 | 27/03/2018 v2.3.0 12 | -------------- 13 | 14 | * Support: Remove PHP5.3 & hhvm support 15 | * Fix: inline spacing and empty tag (#33) 16 | * Fix: aside element conversion (#33) 17 | * License: use the MIT license for now 18 | * Refactor: use brackets notation for array (#28) 19 | 20 | 21 | 22/12/2017 v2.2.2 22 | -------------- 23 | 24 | * Fix: Allow to strip `` tags (#25) 25 | 26 | 27 | 21/09/2016 v2.2.1 28 | -------------- 29 | 30 | * Fix: Moving trailing whitespace from inline elements outside of the element 31 | * Feature: Use PSR-4 32 | * Feature: PHP 7.0 support in continuous integration 33 | * Doc: Update of the README 34 | 35 | 36 | 07/09/2016 v2.2.0 37 | -------------- 38 | 39 | * Fix: Reset state between each parsing 40 | 41 | 42 | 19/02/2016 v2.1.11 43 | -------------- 44 | 45 | * Fix: Empty table cell conversion 46 | 47 | 48 | 10/02/2016 v2.1.10 49 | -------------- 50 | 51 | * Fix: Handle nested table. 52 | 53 | 54 | 01/04/2015 v2.1.9 55 | -------------- 56 | 57 | * Fix: Handle HTML breaks & spaces in a less destructive way. 58 | 59 | 60 | 26/03/2015 v2.1.8 61 | -------------- 62 | 63 | * Fix: Use alternative italic character 64 | * Fix: Handle HTML breaks inside another tag 65 | * Fix: Handle HTML spaces around tags 66 | 67 | 68 | 07/11/2014 v2.1.7 69 | -------------- 70 | 71 | * Change composer name to "elephant418/markdownify" 72 | 73 | 74 | 14/07/2014 v2.1.6 75 | -------------- 76 | 77 | * Fix: Simulate a paragraph for inline text preceding block element 78 | * Fix: Nested lists 79 | * Fix: setKeepHTML method 80 | * Feature: PHP 5.5 & 5.6 support in continuous integration 81 | 82 | 83 | 16/03/2014 v2.1.5 84 | -------------- 85 | 86 | Add display settings 87 | 88 | * Test: Add tests for footnotes after every paragraph or not 89 | * Feature: Allow to display link reference in paragraph, without footnotes 90 | 91 | 92 | 27/02/2014 v2.1.4 93 | -------------- 94 | 95 | Improve how ConverterExtra handle id & class attributes: 96 | 97 | * Feature: Allow id & class attributes on links 98 | * Feature: Allow class attributes on headings -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2008-2019 Milian Wolff, Thomas Zilliox 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Markdownify 2 | 3 | [![Build Status](https://travis-ci.org/Elephant418/Markdownify.png?branch=master)](https://travis-ci.org/Elephant418/Markdownify?branch=master) 4 | [![Total Downloads](https://poser.pugx.org/pixel418/markdownify/downloads)](https://packagist.org/packages/pixel418/markdownify) 5 | [![MIT](https://poser.pugx.org/pixel418/markdownify/license)](https://opensource.org/licenses/MIT) 6 | 7 | The HTML to Markdown converter for PHP 8 | 9 | [Code example](#code-example) | [How to Install](#how-to-install) | [How to Contribute](#how-to-contribute) | [Author & Community](#author--community) 10 | 11 | 12 | 13 | Code example 14 | -------- 15 | 16 | ### Markdown 17 | 18 | ```php 19 | $converter = new Markdownify\Converter; 20 | $converter->parseString('

Heading

'); 21 | // Returns: # Heading 22 | ``` 23 | 24 | ### Markdown Extra [as defined by @michelf](http://michelf.ca/projects/php-markdown/extra/) 25 | 26 | ```php 27 | $converter = new Markdownify\ConverterExtra; 28 | $converter->parseString('

Heading

'); 29 | // Returns: # Heading {#md} 30 | ``` 31 | 32 | 33 | 34 | How to Install 35 | -------- 36 | 37 | This library package requires `PHP 5.4` or later.
38 | Install [Composer](http://getcomposer.org/doc/01-basic-usage.md#installation) and run the following command to get the latest version: 39 | 40 | ```sh 41 | composer require pixel418/markdownify 42 | ``` 43 | 44 | 45 | 46 | How to Contribute 47 | -------- 48 | 49 | 1. Fork the Markdownify repository 50 | 2. Create a new branch for each feature or improvement 51 | 3. Send a pull request from each feature branch to the **v2.x** branch 52 | 53 | If you don't know much about pull request, you can read [the Github article](https://help.github.com/articles/using-pull-requests) 54 | 55 | 56 | 57 | Author & Community 58 | -------- 59 | 60 | Markdownify is under [MIT License](https://opensource.org/licenses/MIT)
61 | It was created by [Milian Wolff](http://milianw.de)
62 | It was converted to a Symfony Bundle by [Peter Kruithof](https://github.com/pkruithof)
63 | It is maintained by [Thomas ZILLIOX](https://tzi.fr) 64 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pixel418/markdownify", 3 | "type": "lib", 4 | "description": "The HTML to Markdown converter for PHP ", 5 | "keywords": ["markdown", "html", "converter", "markdownify"], 6 | "license": "MIT", 7 | "homepage": "https://github.com/elephant418/Markdownify", 8 | "authors": [ 9 | { 10 | "name": "Milian Wolff", 11 | "email": "mail@milianw.de", 12 | "homepage": "http://milianw.de" 13 | 14 | }, 15 | { 16 | "name": "Thomas Zilliox", 17 | "email": "hello@tzi.fr", 18 | "homepage": "https://tzi.fr" 19 | } 20 | ], 21 | "require": { 22 | "php": ">=5.4.0" 23 | }, 24 | "require-dev": { 25 | "phpunit/phpunit": "^4.8" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "Markdownify\\": "src", 30 | "Test\\Markdownify\\": "test" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | ./test/ 4 | 5 | -------------------------------------------------------------------------------- /src/Converter.php: -------------------------------------------------------------------------------- 1 | 34 | */ 35 | protected $notConverted = []; 36 | 37 | /** 38 | * skip conversion to markdown 39 | * 40 | * @var bool 41 | */ 42 | protected $skipConversion = false; 43 | 44 | /* options */ 45 | 46 | /** 47 | * keep html tags which cannot be converted to markdown 48 | * 49 | * @var bool 50 | */ 51 | protected $keepHTML = false; 52 | 53 | /** 54 | * wrap output, set to 0 to skip wrapping 55 | * 56 | * @var int 57 | */ 58 | protected $bodyWidth = 0; 59 | 60 | /** 61 | * minimum body width 62 | * 63 | * @var int 64 | */ 65 | protected $minBodyWidth = 25; 66 | 67 | /** 68 | * position where the link reference will be displayed 69 | * 70 | * 71 | * @var int 72 | */ 73 | protected $linkPosition; 74 | const LINK_AFTER_CONTENT = 0; 75 | const LINK_AFTER_PARAGRAPH = 1; 76 | const LINK_IN_PARAGRAPH = 2; 77 | 78 | /** 79 | * stores current buffers 80 | * 81 | * @var array 82 | */ 83 | protected $buffer = []; 84 | 85 | /** 86 | * stores current buffers 87 | * 88 | * @var array 89 | */ 90 | protected $footnotes = []; 91 | 92 | /** 93 | * tags with elements which can be handled by markdown 94 | * 95 | * @var array 96 | */ 97 | protected $isMarkdownable = [ 98 | 'p' => [], 99 | 'ul' => [], 100 | 'ol' => [], 101 | 'li' => [], 102 | 'br' => [], 103 | 'blockquote' => [], 104 | 'code' => [], 105 | 'pre' => [], 106 | 'a' => [ 107 | 'href' => 'required', 108 | 'title' => 'optional', 109 | ], 110 | 'strong' => [], 111 | 'b' => [], 112 | 'em' => [], 113 | 'i' => [], 114 | 'img' => [ 115 | 'src' => 'required', 116 | 'alt' => 'optional', 117 | 'title' => 'optional', 118 | ], 119 | 'h1' => [], 120 | 'h2' => [], 121 | 'h3' => [], 122 | 'h4' => [], 123 | 'h5' => [], 124 | 'h6' => [], 125 | 'hr' => [], 126 | ]; 127 | 128 | /** 129 | * html tags to be ignored (contents will be parsed) 130 | * 131 | * @var array 132 | */ 133 | protected $ignore = [ 134 | 'html', 135 | 'body', 136 | ]; 137 | 138 | /** 139 | * html tags to be dropped (contents will not be parsed!) 140 | * 141 | * @var array 142 | */ 143 | protected $drop = [ 144 | 'script', 145 | 'head', 146 | 'style', 147 | 'form', 148 | 'area', 149 | 'object', 150 | 'param', 151 | 'iframe', 152 | ]; 153 | 154 | /** 155 | * html block tags that allow inline & block children 156 | * 157 | * @var array 158 | */ 159 | protected $allowMixedChildren = [ 160 | 'li' 161 | ]; 162 | 163 | /** 164 | * Markdown indents which could be wrapped 165 | * @note: use strings in regex format 166 | * 167 | * @var array 168 | */ 169 | protected $wrappableIndents = [ 170 | '\* ', // ul 171 | '\d. ', // ol 172 | '\d\d. ', // ol 173 | '> ', // blockquote 174 | '', // p 175 | ]; 176 | 177 | /** 178 | * list of chars which have to be escaped in normal text 179 | * @note: use strings in regex format 180 | * 181 | * @var array 182 | * 183 | * TODO: what's with block chars / sequences at the beginning of a block? 184 | */ 185 | protected $escapeInText = [ 186 | '\*\*([^*]+)\*\*' => '\*\*$1\*\*', // strong 187 | '\*([^*]+)\*' => '\*$1\*', // em 188 | '__(?! |_)(.+)(?!<_| )__' => '\_\_$1\_\_', // strong 189 | '_(?! |_)(.+)(?!<_| )_' => '\_$1\_', // em 190 | '([-*_])([ ]{0,2}\1){2,}' => '\\\\$0', // hr 191 | '`' => '\`', // code 192 | '\[(.+)\](\s*\()' => '\[$1\]$2', // links: [text] (url) => [text\] (url) 193 | '\[(.+)\](\s*)\[(.*)\]' => '\[$1\]$2\[$3\]', // links: [text][id] => [text\][id\] 194 | '^#(#{0,5}) ' => '\#$1 ', // header 195 | ]; 196 | 197 | /** 198 | * wether last processed node was a block tag or not 199 | * 200 | * @var bool 201 | */ 202 | protected $lastWasBlockTag = false; 203 | 204 | /** 205 | * name of last closed tag 206 | * 207 | * @var string 208 | */ 209 | protected $lastClosedTag = ''; 210 | 211 | /** 212 | * number of line breaks before next inline output 213 | */ 214 | protected $lineBreaks = 0; 215 | 216 | /** 217 | * node stack, e.g. for and tags 218 | * 219 | * @var array 220 | */ 221 | protected $stack = []; 222 | 223 | /** 224 | * current indentation 225 | * 226 | * @var string 227 | */ 228 | protected $indent = ''; 229 | 230 | /** 231 | * constructor, set options, setup parser 232 | * 233 | * @param int $linkPosition define the position of links 234 | * @param int $bodyWidth whether or not to wrap the output to the given width 235 | * defaults to false 236 | * @param bool $keepHTML whether to keep non markdownable HTML or to discard it 237 | * defaults to true (HTML will be kept) 238 | * @return void 239 | */ 240 | public function __construct($linkPosition = self::LINK_AFTER_CONTENT, $bodyWidth = MDFY_BODYWIDTH, $keepHTML = MDFY_KEEPHTML) 241 | { 242 | $this->linkPosition = $linkPosition; 243 | $this->keepHTML = $keepHTML; 244 | 245 | if ($bodyWidth > $this->minBodyWidth) { 246 | $this->bodyWidth = intval($bodyWidth); 247 | } else { 248 | $this->bodyWidth = false; 249 | } 250 | 251 | $this->parser = new Parser; 252 | $this->parser->noTagsInCode = true; 253 | 254 | // we don't have to do this every time 255 | $search = []; 256 | $replace = []; 257 | foreach ($this->escapeInText as $s => $r) { 258 | array_push($search, '@(?escapeInText = [ 262 | 'search' => $search, 263 | 'replace' => $replace 264 | ]; 265 | } 266 | 267 | /** 268 | * parse a HTML string 269 | * 270 | * @param string $html 271 | * @return string markdown formatted 272 | */ 273 | public function parseString($html) 274 | { 275 | $this->resetState(); 276 | 277 | $this->parser->html = $html; 278 | $this->parse(); 279 | 280 | return $this->output; 281 | } 282 | 283 | /** 284 | * set the position where the link reference will be displayed 285 | * 286 | * @param int $linkPosition 287 | * @return void 288 | */ 289 | public function setLinkPosition($linkPosition) 290 | { 291 | $this->linkPosition = $linkPosition; 292 | } 293 | 294 | /** 295 | * set keep HTML tags which cannot be converted to markdown 296 | * 297 | * @param bool $linkPosition 298 | * @return void 299 | */ 300 | public function setKeepHTML($keepHTML) 301 | { 302 | $this->keepHTML = $keepHTML; 303 | } 304 | 305 | /** 306 | * iterate through the nodes and decide what we 307 | * shall do with the current node 308 | * 309 | * @param void 310 | * @return void 311 | */ 312 | protected function parse() 313 | { 314 | $this->output = ''; 315 | 316 | // Drop tags 317 | $this->parser->html = preg_replace('#<(' . implode('|', $this->drop) . ')[^>]*>.*#sU', '', $this->parser->html); 318 | while ($this->parser->nextNode()) { 319 | switch ($this->parser->nodeType) { 320 | case 'doctype': 321 | break; 322 | case 'pi': 323 | case 'comment': 324 | if ($this->keepHTML) { 325 | $this->flushLinebreaks(); 326 | $this->out($this->parser->node); 327 | $this->setLineBreaks(2); 328 | } 329 | // else drop 330 | break; 331 | case 'text': 332 | $this->handleText(); 333 | break; 334 | case 'tag': 335 | if (in_array($this->parser->tagName, $this->ignore)) { 336 | break; 337 | } 338 | // If the previous tag was not a block element, we simulate a paragraph tag 339 | if ($this->parser->isBlockElement && $this->parser->isNextToInlineContext && !in_array($this->parent(), $this->allowMixedChildren)) { 340 | $this->setLineBreaks(2); 341 | } 342 | if ($this->parser->isStartTag) { 343 | $this->flushLinebreaks(); 344 | } 345 | if ($this->skipConversion) { 346 | $this->isMarkdownable(); // update notConverted 347 | $this->handleTagToText(); 348 | break; 349 | } 350 | 351 | // block elements 352 | if (!$this->parser->keepWhitespace && $this->parser->isBlockElement) { 353 | $this->fixBlockElementSpacing(); 354 | } 355 | 356 | // inline elements 357 | if (!$this->parser->keepWhitespace && $this->parser->isInlineContext) { 358 | $this->fixInlineElementSpacing(); 359 | } 360 | 361 | if ($this->isMarkdownable()) { 362 | if ($this->parser->isBlockElement && $this->parser->isStartTag && !$this->lastWasBlockTag && !empty($this->output)) { 363 | if (!empty($this->buffer)) { 364 | $str =& $this->buffer[count($this->buffer) - 1]; 365 | } else { 366 | $str =& $this->output; 367 | } 368 | if (substr($str, -strlen($this->indent) - 1) != "\n" . $this->indent) { 369 | $str .= "\n" . $this->indent; 370 | } 371 | } 372 | $func = 'handleTag_' . $this->parser->tagName; 373 | $this->$func(); 374 | if ($this->linkPosition == self::LINK_AFTER_PARAGRAPH && $this->parser->isBlockElement && !$this->parser->isStartTag && empty($this->parser->openTags)) { 375 | $this->flushFootnotes(); 376 | } 377 | if (!$this->parser->isStartTag) { 378 | $this->lastClosedTag = $this->parser->tagName; 379 | } 380 | } else { 381 | $this->handleTagToText(); 382 | $this->lastClosedTag = ''; 383 | } 384 | break; 385 | default: 386 | trigger_error('invalid node type', E_USER_ERROR); 387 | break; 388 | } 389 | $this->lastWasBlockTag = $this->parser->nodeType == 'tag' && $this->parser->isStartTag && $this->parser->isBlockElement; 390 | } 391 | if (!empty($this->buffer)) { 392 | // trigger_error('buffer was not flushed, this is a bug. please report!', E_USER_WARNING); 393 | while (!empty($this->buffer)) { 394 | $this->out($this->unbuffer()); 395 | } 396 | } 397 | // cleanup 398 | $this->output = rtrim(str_replace('&', '&', str_replace('<', '<', str_replace('>', '>', $this->output)))); 399 | // end parsing, flush stacked tags 400 | $this->flushFootnotes(); 401 | $this->stack = []; 402 | } 403 | 404 | /** 405 | * check if current tag can be converted to Markdown 406 | * 407 | * @param void 408 | * @return bool 409 | */ 410 | protected function isMarkdownable() 411 | { 412 | if (!isset($this->isMarkdownable[$this->parser->tagName])) { 413 | // simply not markdownable 414 | 415 | return false; 416 | } 417 | if ($this->parser->isStartTag) { 418 | $return = true; 419 | if ($this->keepHTML) { 420 | $diff = array_diff(array_keys($this->parser->tagAttributes), array_keys($this->isMarkdownable[$this->parser->tagName])); 421 | if (!empty($diff)) { 422 | // non markdownable attributes given 423 | $return = false; 424 | } 425 | } 426 | if ($return) { 427 | foreach ($this->isMarkdownable[$this->parser->tagName] as $attr => $type) { 428 | if ($type == 'required' && !isset($this->parser->tagAttributes[$attr])) { 429 | // required markdown attribute not given 430 | $return = false; 431 | break; 432 | } 433 | } 434 | } 435 | if (!$return) { 436 | array_push($this->notConverted, $this->parser->tagName . '::' . implode('/', $this->parser->openTags)); 437 | } 438 | 439 | return $return; 440 | } else { 441 | if (!empty($this->notConverted) && end($this->notConverted) === $this->parser->tagName . '::' . implode('/', $this->parser->openTags)) { 442 | array_pop($this->notConverted); 443 | 444 | return false; 445 | } 446 | 447 | return true; 448 | } 449 | } 450 | 451 | /** 452 | * output footnotes 453 | * 454 | * @param void 455 | * @return void 456 | */ 457 | protected function flushFootnotes() 458 | { 459 | $out = false; 460 | foreach ($this->footnotes as $k => $tag) { 461 | if (!isset($tag['unstacked'])) { 462 | if (!$out) { 463 | $out = true; 464 | $this->out("\n\n", true); 465 | } else { 466 | $this->out("\n", true); 467 | } 468 | $this->out(' [' . $tag['linkID'] . ']: ' . $this->getLinkReference($tag), true); 469 | $tag['unstacked'] = true; 470 | $this->footnotes[$k] = $tag; 471 | } 472 | } 473 | } 474 | 475 | /** 476 | * return formated link reference 477 | * 478 | * @param array $tag 479 | * @return string link reference 480 | */ 481 | protected function getLinkReference($tag) 482 | { 483 | return $tag['href'] . (isset($tag['title']) ? ' "' . $tag['title'] . '"' : ''); 484 | } 485 | 486 | /** 487 | * flush enqued linebreaks 488 | * 489 | * @param void 490 | * @return void 491 | */ 492 | protected function flushLinebreaks() 493 | { 494 | if ($this->lineBreaks && !empty($this->output)) { 495 | $this->out(str_repeat("\n" . $this->indent, $this->lineBreaks), true); 496 | } 497 | $this->lineBreaks = 0; 498 | } 499 | 500 | /** 501 | * handle non Markdownable tags 502 | * 503 | * @param void 504 | * @return void 505 | */ 506 | protected function handleTagToText() 507 | { 508 | if (!$this->keepHTML) { 509 | if (!$this->parser->isStartTag && $this->parser->isBlockElement) { 510 | $this->setLineBreaks(2); 511 | } 512 | } else { 513 | // don't convert to markdown inside this tag 514 | /** TODO: markdown extra **/ 515 | if (!$this->parser->isEmptyTag) { 516 | if ($this->parser->isStartTag) { 517 | if (!$this->skipConversion) { 518 | $this->skipConversion = $this->parser->tagName . '::' . implode('/', $this->parser->openTags); 519 | } 520 | } else { 521 | if ($this->skipConversion == $this->parser->tagName . '::' . implode('/', $this->parser->openTags)) { 522 | $this->skipConversion = false; 523 | } 524 | } 525 | } 526 | 527 | if ($this->parser->isBlockElement) { 528 | if ($this->parser->isStartTag) { 529 | // looks like ins or del are block elements now 530 | if (in_array($this->parent(), ['ins', 'del'])) { 531 | $this->out("\n", true); 532 | $this->indent(' '); 533 | } 534 | // don't indent inside
 tags
 535 |                     if ($this->parser->tagName == 'pre') {
 536 |                         $this->out($this->parser->node);
 537 |                         static $indent;
 538 |                         $indent = $this->indent;
 539 |                         $this->indent = '';
 540 |                     } else {
 541 |                         $this->out($this->parser->node . "\n" . $this->indent);
 542 |                         if (!$this->parser->isEmptyTag) {
 543 |                             $this->indent('  ');
 544 |                         } else {
 545 |                             $this->setLineBreaks(1);
 546 |                         }
 547 |                         $this->parser->html = ltrim($this->parser->html);
 548 |                     }
 549 |                 } else {
 550 |                     if (!$this->parser->keepWhitespace) {
 551 |                         $this->output = rtrim($this->output);
 552 |                     }
 553 |                     if ($this->parser->tagName != 'pre') {
 554 |                         $this->indent('  ');
 555 |                         $this->out("\n" . $this->indent . $this->parser->node);
 556 |                     } else {
 557 |                         // reset indentation
 558 |                         $this->out($this->parser->node);
 559 |                         static $indent;
 560 |                         $this->indent = $indent;
 561 |                     }
 562 | 
 563 |                     if (in_array($this->parent(), ['ins', 'del'])) {
 564 |                         // ins or del was block element
 565 |                         $this->out("\n");
 566 |                         $this->indent('  ');
 567 |                     }
 568 |                     if ($this->parser->tagName == 'li') {
 569 |                         $this->setLineBreaks(1);
 570 |                     } else {
 571 |                         $this->setLineBreaks(2);
 572 |                     }
 573 |                 }
 574 |             } else {
 575 |                 $this->out($this->parser->node);
 576 |             }
 577 |             if (in_array($this->parser->tagName, ['code', 'pre'])) {
 578 |                 if ($this->parser->isStartTag) {
 579 |                     $this->buffer();
 580 |                 } else {
 581 |                     // add stuff so cleanup just reverses this
 582 |                     $this->out(str_replace('<', '&lt;', str_replace('>', '&gt;', $this->unbuffer())));
 583 |                 }
 584 |             }
 585 |         }
 586 |     }
 587 | 
 588 |     /**
 589 |      * handle plain text
 590 |      *
 591 |      * @param void
 592 |      * @return void
 593 |      */
 594 |     protected function handleText()
 595 |     {
 596 |         if ($this->hasParent('pre') && strpos($this->parser->node, "\n") !== false) {
 597 |             $this->parser->node = str_replace("\n", "\n" . $this->indent, $this->parser->node);
 598 |         }
 599 |         if (!$this->hasParent('code') && !$this->hasParent('pre')) {
 600 |             // entity decode
 601 |             $this->parser->node = $this->decode($this->parser->node);
 602 |             if (!$this->skipConversion) {
 603 |                 // escape some chars in normal Text
 604 |                 $this->parser->node = preg_replace($this->escapeInText['search'], $this->escapeInText['replace'], $this->parser->node);
 605 |             }
 606 |         } else {
 607 |             $this->parser->node = str_replace(['"', '&apos'], ['"', '\''], $this->parser->node);
 608 |         }
 609 |         $this->out($this->parser->node);
 610 |         $this->lastClosedTag = '';
 611 |     }
 612 | 
 613 |     /**
 614 |      * handle  and  tags
 615 |      *
 616 |      * @param void
 617 |      * @return void
 618 |      */
 619 |     protected function handleTag_em()
 620 |     {
 621 |         $this->out('_', true);
 622 |     }
 623 | 
 624 |     protected function handleTag_i()
 625 |     {
 626 |         $this->handleTag_em();
 627 |     }
 628 | 
 629 |     /**
 630 |      * handle  and  tags
 631 |      *
 632 |      * @param void
 633 |      * @return void
 634 |      */
 635 |     protected function handleTag_strong()
 636 |     {
 637 |         $this->out('**', true);
 638 |     }
 639 | 
 640 |     protected function handleTag_b()
 641 |     {
 642 |         $this->handleTag_strong();
 643 |     }
 644 | 
 645 |     /**
 646 |      * handle 

tags 647 | * 648 | * @param void 649 | * @return void 650 | */ 651 | protected function handleTag_h1() 652 | { 653 | $this->handleHeader(1); 654 | } 655 | 656 | /** 657 | * handle

tags 658 | * 659 | * @param void 660 | * @return void 661 | */ 662 | protected function handleTag_h2() 663 | { 664 | $this->handleHeader(2); 665 | } 666 | 667 | /** 668 | * handle

tags 669 | * 670 | * @param void 671 | * @return void 672 | */ 673 | protected function handleTag_h3() 674 | { 675 | $this->handleHeader(3); 676 | } 677 | 678 | /** 679 | * handle

tags 680 | * 681 | * @param void 682 | * @return void 683 | */ 684 | protected function handleTag_h4() 685 | { 686 | $this->handleHeader(4); 687 | } 688 | 689 | /** 690 | * handle

tags 691 | * 692 | * @param void 693 | * @return void 694 | */ 695 | protected function handleTag_h5() 696 | { 697 | $this->handleHeader(5); 698 | } 699 | 700 | /** 701 | * handle
tags 702 | * 703 | * @param void 704 | * @return void 705 | */ 706 | protected function handleTag_h6() 707 | { 708 | $this->handleHeader(6); 709 | } 710 | 711 | /** 712 | * handle header tags (

-

) 713 | * 714 | * @param int $level 1-6 715 | * @return void 716 | */ 717 | protected function handleHeader($level) 718 | { 719 | if ($this->parser->isStartTag) { 720 | $this->out(str_repeat('#', $level) . ' ', true); 721 | } else { 722 | $this->setLineBreaks(2); 723 | } 724 | } 725 | 726 | /** 727 | * handle

tags 728 | * 729 | * @param void 730 | * @return void 731 | */ 732 | protected function handleTag_p() 733 | { 734 | if (!$this->parser->isStartTag) { 735 | $this->setLineBreaks(2); 736 | } 737 | } 738 | 739 | /** 740 | * handle tags 741 | * 742 | * @param void 743 | * @return void 744 | */ 745 | protected function handleTag_a() 746 | { 747 | if ($this->parser->isStartTag) { 748 | $this->buffer(); 749 | $this->handleTag_a_parser(); 750 | $this->stack(); 751 | } else { 752 | $tag = $this->unstack(); 753 | $buffer = $this->unbuffer(); 754 | $this->handleTag_a_converter($tag, $buffer); 755 | $this->out($this->handleTag_a_converter($tag, $buffer), true); 756 | } 757 | } 758 | 759 | /** 760 | * handle tags parsing 761 | * 762 | * @param void 763 | * @return void 764 | */ 765 | protected function handleTag_a_parser() 766 | { 767 | if (isset($this->parser->tagAttributes['title'])) { 768 | $this->parser->tagAttributes['title'] = $this->decode($this->parser->tagAttributes['title']); 769 | } else { 770 | $this->parser->tagAttributes['title'] = null; 771 | } 772 | $this->parser->tagAttributes['href'] = $this->decode(trim($this->parser->tagAttributes['href'])); 773 | } 774 | 775 | /** 776 | * handle tags conversion 777 | * 778 | * @param array $tag 779 | * @param string $buffer 780 | * @return string The markdownified link 781 | */ 782 | protected function handleTag_a_converter($tag, $buffer) 783 | { 784 | if (empty($tag['href']) && empty($tag['title'])) { 785 | // empty links... testcase mania, who would possibly do anything like that?! 786 | return '[' . $buffer . ']()'; 787 | } 788 | 789 | if ($buffer == $tag['href'] && empty($tag['title'])) { 790 | // 791 | return '<' . $buffer . '>'; 792 | } 793 | 794 | $bufferDecoded = $this->decode(trim($buffer)); 795 | if (substr($tag['href'], 0, 7) == 'mailto:' && 'mailto:' . $bufferDecoded == $tag['href']) { 796 | if (is_null($tag['title'])) { 797 | // 798 | return '<' . $bufferDecoded . '>'; 799 | } 800 | // [mail@example.com][1] 801 | // ... 802 | // [1]: mailto:mail@example.com Title 803 | $tag['href'] = 'mailto:' . $bufferDecoded; 804 | } 805 | 806 | if ($this->linkPosition == self::LINK_IN_PARAGRAPH) { 807 | return '[' . $buffer . '](' . $this->getLinkReference($tag) . ')'; 808 | } 809 | 810 | // [This link][id] 811 | foreach ($this->footnotes as $tag2) { 812 | if ($tag2['href'] == $tag['href'] && $tag2['title'] === $tag['title']) { 813 | $tag['linkID'] = $tag2['linkID']; 814 | break; 815 | } 816 | } 817 | if (!isset($tag['linkID'])) { 818 | $tag['linkID'] = count($this->footnotes) + 1; 819 | array_push($this->footnotes, $tag); 820 | } 821 | 822 | return '[' . $buffer . '][' . $tag['linkID'] . ']'; 823 | } 824 | 825 | /** 826 | * handle tags 827 | * 828 | * @param void 829 | * @return void 830 | */ 831 | protected function handleTag_img() 832 | { 833 | if (!$this->parser->isStartTag) { 834 | return; // just to be sure this is really an empty tag... 835 | } 836 | 837 | if (isset($this->parser->tagAttributes['title'])) { 838 | $this->parser->tagAttributes['title'] = $this->decode($this->parser->tagAttributes['title']); 839 | } else { 840 | $this->parser->tagAttributes['title'] = null; 841 | } 842 | if (isset($this->parser->tagAttributes['alt'])) { 843 | $this->parser->tagAttributes['alt'] = $this->decode($this->parser->tagAttributes['alt']); 844 | } else { 845 | $this->parser->tagAttributes['alt'] = null; 846 | } 847 | 848 | if (empty($this->parser->tagAttributes['src'])) { 849 | // support for "empty" images... dunno if this is really needed 850 | // but there are some test cases which do that... 851 | if (!empty($this->parser->tagAttributes['title'])) { 852 | $this->parser->tagAttributes['title'] = ' ' . $this->parser->tagAttributes['title'] . ' '; 853 | } 854 | $this->out('![' . $this->parser->tagAttributes['alt'] . '](' . $this->parser->tagAttributes['title'] . ')', true); 855 | 856 | return; 857 | } else { 858 | $this->parser->tagAttributes['src'] = $this->decode($this->parser->tagAttributes['src']); 859 | } 860 | 861 | $out = '![' . $this->parser->tagAttributes['alt'] . ']'; 862 | if ($this->linkPosition == self::LINK_IN_PARAGRAPH) { 863 | $out .= '(' . $this->parser->tagAttributes['src']; 864 | if ($this->parser->tagAttributes['title']) { 865 | $out .= ' "' . $this->parser->tagAttributes['title'] . '"'; 866 | } 867 | $out .= ')'; 868 | $this->out($out, true); 869 | return; 870 | } 871 | 872 | // ![This image][id] 873 | $link_id = false; 874 | if (!empty($this->footnotes)) { 875 | foreach ($this->footnotes as $tag) { 876 | if ($tag['href'] == $this->parser->tagAttributes['src'] 877 | && $tag['title'] === $this->parser->tagAttributes['title'] 878 | ) { 879 | $link_id = $tag['linkID']; 880 | break; 881 | } 882 | } 883 | } 884 | if (!$link_id) { 885 | $link_id = count($this->footnotes) + 1; 886 | $tag = [ 887 | 'href' => $this->parser->tagAttributes['src'], 888 | 'linkID' => $link_id, 889 | 'title' => $this->parser->tagAttributes['title'] 890 | ]; 891 | array_push($this->footnotes, $tag); 892 | } 893 | $out .= '[' . $link_id . ']'; 894 | 895 | $this->out($out, true); 896 | } 897 | 898 | /** 899 | * handle tags 900 | * 901 | * @param void 902 | * @return void 903 | */ 904 | protected function handleTag_code() 905 | { 906 | if ($this->hasParent('pre')) { 907 | // ignore code blocks inside

 908 | 
 909 |             return;
 910 |         }
 911 |         if ($this->parser->isStartTag) {
 912 |             $this->buffer();
 913 |         } else {
 914 |             $buffer = $this->unbuffer();
 915 |             // use as many backticks as needed
 916 |             preg_match_all('#`+#', $buffer, $matches);
 917 |             if (!empty($matches[0])) {
 918 |                 rsort($matches[0]);
 919 | 
 920 |                 $ticks = '`';
 921 |                 while (true) {
 922 |                     if (!in_array($ticks, $matches[0])) {
 923 |                         break;
 924 |                     }
 925 |                     $ticks .= '`';
 926 |                 }
 927 |             } else {
 928 |                 $ticks = '`';
 929 |             }
 930 |             if ($buffer[0] == '`' || substr($buffer, -1) == '`') {
 931 |                 $buffer = ' ' . $buffer . ' ';
 932 |             }
 933 |             $this->out($ticks . $buffer . $ticks, true);
 934 |         }
 935 |     }
 936 | 
 937 |     /**
 938 |      * handle 
 tags
 939 |      *
 940 |      * @param void
 941 |      * @return void
 942 |      */
 943 |     protected function handleTag_pre()
 944 |     {
 945 |         if ($this->keepHTML && $this->parser->isStartTag) {
 946 |             // check if a simple  follows
 947 |             if (!preg_match('#^\s*#Us', $this->parser->html)) {
 948 |                 // this is no standard markdown code block
 949 |                 $this->handleTagToText();
 950 | 
 951 |                 return;
 952 |             }
 953 |         }
 954 |         $this->indent('    ');
 955 |         if (!$this->parser->isStartTag) {
 956 |             $this->setLineBreaks(2);
 957 |         } else {
 958 |             $this->parser->html = ltrim($this->parser->html);
 959 |         }
 960 |     }
 961 | 
 962 |     /**
 963 |      * handle 
tags 964 | * 965 | * @param void 966 | * @return void 967 | */ 968 | protected function handleTag_blockquote() 969 | { 970 | $this->indent('> '); 971 | } 972 | 973 | /** 974 | * handle