PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 All 69 releases
fluent-support / app / Modules / MCP / Helpers / HtmlToMarkdown.php

HtmlToMarkdown.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.4.5, at app/Modules/MCP/Helpers/HtmlToMarkdown.php

371 lines 12.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSupport\App\Modules\MCP\Helpers;
4
5 /**
6 * Small DOM-based HTML to Markdown converter for MCP output.
7 *
8 * Agents read ticket threads and integration widgets as text. Flattening HTML
9 * to one line destroys pasted logs, lists and tables, so this keeps the
10 * structure as Markdown. It is a reader, not a round-trip converter: nothing
11 * is escaped and unknown tags just render their children.
12 *
13 * Bundled instead of a Composer package because vendor libraries are not
14 * namespace-scoped and would clash with other plugins shipping the same one.
15 */
16 class HtmlToMarkdown
17 {
18 // Placeholders survive the whitespace cleanup and are swapped back at the end.
19 const INDENT = "\x01";
20 const PRE = "\x02";
21 // A line break that merges with its neighbours, so stacked <div>s read as lines, not paragraphs.
22 const SOFT = "\x03";
23
24 private static $skipTags = ['script', 'style', 'head', 'title', 'meta', 'link', 'noscript', 'template'];
25
26 private static $blockTags = [
27 'p', 'section', 'article', 'header', 'footer', 'main', 'aside', 'nav',
28 'address', 'figure', 'figcaption', 'dl', 'dt', 'dd', 'center', 'form', 'fieldset',
29 ];
30
31 /** @var string[] */
32 private $preBlocks = [];
33
34 public static function convert($html)
35 {
36 $html = (string) $html;
37 if (trim($html) === '') {
38 return '';
39 }
40
41 // Plain text: keep its own line breaks.
42 if (strpos($html, '<') === false) {
43 $text = html_entity_decode($html, ENT_QUOTES, 'UTF-8');
44 return self::tidy(str_replace(["\r\n", "\r"], "\n", $text));
45 }
46
47 // ext/dom is enabled by default but can be compiled out; degrade to text.
48 if (!class_exists('\DOMDocument')) {
49 return self::tidy(wp_strip_all_tags($html));
50 }
51
52 $dom = new \DOMDocument();
53 $libXmlState = libxml_use_internal_errors(true);
54 $loaded = $dom->loadHTML(
55 '<?xml encoding="UTF-8"?>' . $html,
56 LIBXML_NOERROR | LIBXML_NOWARNING
57 );
58 libxml_clear_errors();
59 libxml_use_internal_errors($libXmlState);
60
61 // Walk the document element rather than a wrapper <div> of our own: an
62 // unbalanced </div> in stored email HTML would close the wrapper early and
63 // everything after it would be silently dropped. <head> is skipped below and
64 // <html>/<body> just render their children, so this covers content libxml
65 // places after a stray </body> too.
66 $root = $loaded ? $dom->documentElement : null;
67 if (!$root) {
68 return self::tidy(wp_strip_all_tags($html));
69 }
70
71 $converter = new self();
72 $markdown = $converter->children($root);
73
74 return $converter->restorePre(self::tidy($markdown));
75 }
76
77 private static function tidy($text)
78 {
79 $text = preg_replace('/[^\S\n]+/u', ' ', $text);
80 $text = preg_replace_callback('/ *(?:[\n' . self::SOFT . '] *)+/', function ($match) {
81 return substr_count($match[0], "\n") >= 2 ? "\n\n" : "\n";
82 }, $text);
83 $text = str_replace(self::INDENT, ' ', $text);
84
85 return trim($text);
86 }
87
88 private function restorePre($text)
89 {
90 return preg_replace_callback('/' . self::PRE . '(\d+)' . self::PRE . '/', function ($match) {
91 return $this->preBlocks[(int) $match[1]] ?? '';
92 }, $text);
93 }
94
95 private function children(\DOMNode $node)
96 {
97 $out = '';
98 foreach ($node->childNodes as $child) {
99 $out .= $this->node($child);
100 }
101
102 return $out;
103 }
104
105 private function node(\DOMNode $node)
106 {
107 if ($node instanceof \DOMText) {
108 $text = str_replace([self::INDENT, self::PRE, self::SOFT], '', $node->nodeValue);
109 return preg_replace('/[\s\x{00A0}]+/u', ' ', $text);
110 }
111
112 if (!$node instanceof \DOMElement) {
113 return '';
114 }
115
116 $tag = strtolower($node->tagName);
117
118 if (in_array($tag, self::$skipTags, true)) {
119 return '';
120 }
121
122 switch ($tag) {
123 case 'br':
124 return "\n";
125 case 'hr':
126 return "\n\n---\n\n";
127 case 'div':
128 $content = trim($this->children($node));
129 if ($content === '') {
130 // Gmail writes a blank line as <div><br></div>.
131 return $node->getElementsByTagName('br')->length ? "\n\n" : '';
132 }
133 return self::SOFT . $content . self::SOFT;
134 case 'h1':
135 case 'h2':
136 case 'h3':
137 case 'h4':
138 case 'h5':
139 case 'h6':
140 $text = $this->inline($node);
141 return $text === '' ? '' : "\n\n" . str_repeat('#', (int) $tag[1]) . ' ' . $text . "\n\n";
142 case 'strong':
143 case 'b':
144 return $this->wrap($node, '**');
145 case 'em':
146 case 'i':
147 return $this->wrap($node, '_');
148 case 's':
149 case 'del':
150 case 'strike':
151 return $this->wrap($node, '~~');
152 case 'code':
153 return $this->wrap($node, '`');
154 case 'pre':
155 return $this->pre($node);
156 case 'a':
157 return $this->link($node);
158 case 'img':
159 return $this->image($node);
160 case 'ul':
161 case 'ol':
162 return $this->listBlock($node, $tag === 'ol');
163 case 'li':
164 return "\n- " . $this->itemContent($node) . "\n";
165 case 'blockquote':
166 return $this->blockquote($node);
167 case 'table':
168 return $this->table($node);
169 }
170
171 if (in_array($tag, self::$blockTags, true)) {
172 return "\n\n" . trim($this->children($node)) . "\n\n";
173 }
174
175 return $this->children($node);
176 }
177
178 // Inline contexts (headings, links, table cells) have no line breaks: a soft
179 // break from a nested <div> folds into a space like a newline does.
180 private function inline(\DOMNode $node)
181 {
182 return trim(preg_replace('/[\s' . self::SOFT . ']*[\n' . self::SOFT . '][\s' . self::SOFT . ']*/', ' ', $this->children($node)));
183 }
184
185 // List items keep line breaks but a soft break is a plain newline there.
186 private function itemContent(\DOMNode $node)
187 {
188 $content = str_replace(self::SOFT, "\n", $this->children($node));
189
190 return trim(preg_replace('/\n{2,}/', "\n", $content));
191 }
192
193 // Keep the surrounding spaces outside the markers: "a **b** c", not "a** b **c".
194 private function wrap(\DOMNode $node, $marker)
195 {
196 $raw = $this->children($node);
197 $text = trim($raw);
198 if ($text === '') {
199 return $raw;
200 }
201
202 $lead = preg_match('/^\s/u', $raw) ? ' ' : '';
203 $trail = preg_match('/\s$/u', $raw) ? ' ' : '';
204
205 return $lead . $marker . $text . $marker . $trail;
206 }
207
208 private function pre(\DOMElement $node)
209 {
210 // Some editors put <br> inside <pre>; textContent would drop them.
211 foreach (iterator_to_array($node->getElementsByTagName('br')) as $br) {
212 $br->parentNode->replaceChild($node->ownerDocument->createTextNode("\n"), $br);
213 }
214
215 $text = str_replace(["\r\n", "\r", "\xC2\xA0"], ["\n", "\n", ' '], $node->textContent);
216 $text = trim($text, "\n");
217 if (trim($text) === '') {
218 return '';
219 }
220
221 $this->preBlocks[] = "```\n" . $text . "\n```";
222
223 return "\n\n" . self::PRE . (count($this->preBlocks) - 1) . self::PRE . "\n\n";
224 }
225
226 private function link(\DOMElement $node)
227 {
228 $text = $this->inline($node);
229 $href = trim($node->getAttribute('href'));
230
231 if ($href === '' || $href[0] === '#' || stripos($href, 'javascript:') === 0) {
232 return $text;
233 }
234
235 $bareHref = preg_replace('#^(mailto:|tel:)#i', '', $href);
236 if ($text === '' || $text === $href || $text === $bareHref) {
237 return $bareHref;
238 }
239
240 return '[' . $text . '](' . $href . ')';
241 }
242
243 private function image(\DOMElement $node)
244 {
245 // Tracking pixels.
246 if (in_array($node->getAttribute('width'), ['0', '1'], true) || in_array($node->getAttribute('height'), ['0', '1'], true)) {
247 return '';
248 }
249
250 $alt = trim(preg_replace('/\s+/', ' ', $node->getAttribute('alt')));
251 $src = trim($node->getAttribute('src'));
252
253 if ($src === '' || stripos($src, 'data:') === 0) {
254 return $alt !== '' ? '[image: ' . $alt . ']' : '[image]';
255 }
256
257 return '![' . $alt . '](' . $src . ')';
258 }
259
260 private function listBlock(\DOMElement $node, $ordered)
261 {
262 $lines = [];
263 $number = $ordered && is_numeric($node->getAttribute('start')) ? (int) $node->getAttribute('start') : 1;
264
265 foreach ($node->childNodes as $child) {
266 if (!$child instanceof \DOMElement || strtolower($child->tagName) !== 'li') {
267 // Stray text or a nested list without an <li> wrapper.
268 $stray = trim($this->node($child));
269 if ($stray !== '') {
270 $lines[] = $stray;
271 }
272 continue;
273 }
274
275 $prefix = $ordered ? ($number++) . '. ' : '- ';
276 $content = $this->itemContent($child);
277 $indent = str_repeat(self::INDENT, strlen($prefix));
278
279 $lines[] = $prefix . str_replace("\n", "\n" . $indent, $content);
280 }
281
282 return $lines ? "\n\n" . implode("\n", $lines) . "\n\n" : '';
283 }
284
285 private function blockquote(\DOMElement $node)
286 {
287 $content = trim(self::tidy($this->children($node)));
288 if ($content === '') {
289 return '';
290 }
291
292 return "\n\n> " . str_replace("\n", "\n> ", $content) . "\n\n";
293 }
294
295 private function table(\DOMElement $node)
296 {
297 $rows = [];
298 foreach ($this->tableRows($node) as $tr) {
299 $cells = [];
300 foreach ($tr->childNodes as $cell) {
301 if ($cell instanceof \DOMElement && in_array(strtolower($cell->tagName), ['td', 'th'], true)) {
302 $cells[] = $cell;
303 }
304 }
305 if ($cells) {
306 $rows[] = $cells;
307 }
308 }
309
310 if (!$rows) {
311 return $this->children($node);
312 }
313
314 $columns = max(array_map('count', $rows));
315
316 // Email layout tables (nested tables, single column) read better as plain blocks.
317 if ($columns < 2 || $node->getElementsByTagName('table')->length > 0) {
318 $out = '';
319 foreach ($rows as $cells) {
320 foreach ($cells as $cell) {
321 $out .= self::SOFT . trim($this->children($cell)) . self::SOFT;
322 }
323 }
324 return "\n" . $out . "\n";
325 }
326
327 $lines = [];
328 foreach ($rows as $index => $cells) {
329 $values = [];
330 foreach ($cells as $cell) {
331 $values[] = str_replace('|', '\|', $this->inline($cell));
332 }
333 $values = array_pad($values, $columns, '');
334 $lines[] = '| ' . implode(' | ', $values) . ' |';
335
336 if ($index === 0) {
337 $lines[] = '|' . str_repeat(' --- |', $columns);
338 }
339 }
340
341 return "\n\n" . implode("\n", $lines) . "\n\n";
342 }
343
344 /**
345 * Rows of this table only, not of tables nested inside its cells.
346 *
347 * @return \DOMElement[]
348 */
349 private function tableRows(\DOMElement $table)
350 {
351 $rows = [];
352 foreach ($table->childNodes as $child) {
353 if (!$child instanceof \DOMElement) {
354 continue;
355 }
356 $tag = strtolower($child->tagName);
357 if ($tag === 'tr') {
358 $rows[] = $child;
359 } elseif (in_array($tag, ['thead', 'tbody', 'tfoot'], true)) {
360 foreach ($child->childNodes as $tr) {
361 if ($tr instanceof \DOMElement && strtolower($tr->tagName) === 'tr') {
362 $rows[] = $tr;
363 }
364 }
365 }
366 }
367
368 return $rows;
369 }
370 }
371