3 * @copyright Copyright (C) 2010-2023, the Friendica project
5 * @license GNU AGPL version 3 or any later version
7 * This program is free software: you can redistribute it and/or modify
8 * it under the terms of the GNU Affero General Public License as
9 * published by the Free Software Foundation, either version 3 of the
10 * License, or (at your option) any later version.
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU Affero General Public License for more details.
17 * You should have received a copy of the GNU Affero General Public License
18 * along with this program. If not, see <https://www.gnu.org/licenses/>.
22 namespace Friendica\Content\Text;
26 use Friendica\Model\Photo;
27 use Friendica\Model\Post;
30 * Tumblr Neue Post Format
31 * @see https://www.tumblr.com/docs/npf
35 private static $heading_subtype = [];
38 * Convert BBCode into NPF (Tumblr Neue Post Format)
40 * @param string $bbcode
41 * @param integer $uri_id
44 public static function fromBBCode(string $bbcode, int $uri_id): array
46 $bbcode = self::prepareBody($bbcode);
48 $html = BBCode::convertForUriId($uri_id, $bbcode, BBCode::NPF);
53 $doc = new DOMDocument();
55 $doc->formatOutput = true;
56 if (!@$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'))) {
60 self::setHeadingSubStyles($doc);
62 $element = $doc->getElementsByTagName('body')->item(0);
64 list($npf, $text, $formatting) = self::routeChildren($element, $uri_id, true, []);
66 return self::addLinkBlockForUriId($uri_id, 0, $npf);
70 * Fetch the heading types
72 * @param DOMDocument $doc
75 private static function setHeadingSubStyles(DOMDocument $doc)
77 self::$heading_subtype = [];
78 foreach (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as $element) {
79 if ($doc->getElementsByTagName($element)->count() > 0) {
80 if (empty(self::$heading_subtype)) {
81 self::$heading_subtype[$element] = 'heading1';
83 self::$heading_subtype[$element] = 'heading2';
90 * Prepare the BBCode for the NPF conversion
92 * @param string $bbcode
95 private static function prepareBody(string $bbcode): string
97 $shared = BBCode::fetchShareAttributes($bbcode);
98 if (!empty($shared)) {
99 $bbcode = $shared['shared'];
102 $bbcode = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $bbcode);
104 if (preg_match_all("#\[url=([^\]]+?)\]\s*\[img=([^\[\]]*)\]([^\[\]]*)\[\/img\]\s*\[/url\]#ism", $bbcode, $pictures, PREG_SET_ORDER)) {
105 foreach ($pictures as $picture) {
106 if (preg_match('#/photo/.*-[01]\.#ism', $picture[2]) && (preg_match('#/photo/.*-0\.#ism', $picture[1]) || preg_match('#/photos/.*/image/#ism', $picture[1]))) {
107 $bbcode = str_replace($picture[0], "\n\n[img=" . str_replace('-1.', '-0.', $picture[2]) . "]" . $picture[3] . "[/img]\n\n", $bbcode);
112 $bbcode = preg_replace("/\[img\=(.*?)\](.*?)\[\/img\]/ism", "\n\n[img=$1]$2[/img]\n\n", $bbcode);
114 if (preg_match_all("#\[url=([^\]]+?)\]\s*\[img\]([^\[]+?)\[/img\]\s*\[/url\]#ism", $bbcode, $pictures, PREG_SET_ORDER)) {
115 foreach ($pictures as $picture) {
116 if (preg_match('#/photo/.*-[01]\.#ism', $picture[2]) && (preg_match('#/photo/.*-0\.#ism', $picture[1]) || preg_match('#/photos/.*/image/#ism', $picture[1]))) {
117 $bbcode = str_replace($picture[0], "\n\n[img]" . str_replace('-1.', '-0.', $picture[2]) . "[/img]\n\n", $bbcode);
122 $bbcode = preg_replace("/\[img\](.*?)\[\/img\]/ism", "\n\n[img]$1[/img]\n\n", $bbcode);
125 $oldbbcode = $bbcode;
126 $bbcode = str_replace(["\n\n\n"], ["\n\n"], $bbcode);
127 } while ($oldbbcode != $bbcode);
129 return trim($bbcode);
133 * Walk recursively through the HTML
135 * @param DOMElement $element
136 * @param integer $uri_id
137 * @param boolean $parse_structure
138 * @param array $callstack
140 * @param string $text
141 * @param array $formatting
144 private static function routeChildren(DOMElement $element, int $uri_id, bool $parse_structure, array $callstack, array $npf = [], string $text = '', array $formatting = []): array
146 if ($parse_structure && $text) {
147 list($npf, $text, $formatting) = self::addBlock($text, $formatting, $npf, $callstack);
150 $callstack[] = $element->nodeName;
151 $level = self::getLevelByCallstack($callstack);
153 foreach ($element->childNodes as $child) {
154 switch ($child->nodeName) {
157 list($npf, $text, $formatting) = self::addFormatting($child, $uri_id, 'bold', $callstack, $npf, $text, $formatting);
162 list($npf, $text, $formatting) = self::addFormatting($child, $uri_id, 'italic', $callstack, $npf, $text, $formatting);
166 list($npf, $text, $formatting) = self::addFormatting($child, $uri_id, 'strikethrough', $callstack, $npf, $text, $formatting);
171 list($npf, $text, $formatting) = self::addFormatting($child, $uri_id, '', $callstack, $npf, $text, $formatting);
182 $text .= $child->textContent;
187 // Ignore tables and spoilers
191 list($npf, $text, $formatting) = self::addInlineLink($child, $uri_id, $callstack, $npf, $text, $formatting);
195 $npf = self::addImageBlock($child, $uri_id, $level, $npf);
200 $npf = self::addMediaBlock($child, $uri_id, $level, $npf);
204 list($npf, $text, $formatting) = self::routeChildren($child, $uri_id, true, $callstack, $npf, $text, $formatting);
209 if ($parse_structure && $text) {
210 list($npf, $text, $formatting) = self::addBlock($text, $formatting, $npf, $callstack);
212 return [$npf, $text, $formatting];
216 * Return the correct indent level
218 * @param array $callstack
221 private static function getLevelByCallstack(array $callstack): int
224 foreach ($callstack as $entry) {
225 if (in_array($entry, ['ol', 'ul', 'blockquote'])) {
229 return max(0, $level - 1);
233 * Detect the subtype via the HTML element callstack
235 * @param array $callstack
236 * @param string $text
239 private static function getSubTypeByCallstack(array $callstack, string $text): string
242 foreach ($callstack as $entry) {
245 $subtype = 'ordered-list-item';
249 $subtype = 'unordered-list-item';
253 $subtype = self::$heading_subtype[$entry];
257 $subtype = self::$heading_subtype[$entry];
261 $subtype = self::$heading_subtype[$entry];
265 $subtype = self::$heading_subtype[$entry];
269 $subtype = self::$heading_subtype[$entry];
273 $subtype = self::$heading_subtype[$entry];
277 $subtype = mb_strlen($text) < 100 ? 'quote' : 'indented';
281 $subtype = 'indented';
293 * Add formatting for a text block
295 * @param DOMElement $element
296 * @param integer $uri_id
297 * @param string $type
298 * @param array $callstack
300 * @param string $text
301 * @param array $formatting
304 private static function addFormatting(DOMElement $element, int $uri_id, string $type, array $callstack, array $npf, string $text, array $formatting): array
306 $start = mb_strlen($text);
308 list($npf, $text, $formatting) = self::routeChildren($element, $uri_id, false, $callstack, $npf, $text, $formatting);
313 'end' => mb_strlen($text),
317 return [$npf, $text, $formatting];
321 * Add an inline link for a text block
323 * @param DOMElement $element
324 * @param integer $uri_id
325 * @param array $callstack
327 * @param string $text
328 * @param array $formatting
331 private static function addInlineLink(DOMElement $element, int $uri_id, array $callstack, array $npf, string $text, array $formatting): array
333 $start = mb_strlen($text);
335 list($npf, $text, $formatting) = self::routeChildren($element, $uri_id, false, $callstack, $npf, $text, $formatting);
338 foreach ($element->attributes as $key => $attribute) {
339 $attributes[$key] = trim($attribute->value);
341 if (!empty($attributes['href'])) {
344 'end' => mb_strlen($text),
346 'url' => $attributes['href']
349 return [$npf, $text, $formatting];
355 * @param string $text
356 * @param array $formatting
358 * @param array $callstack
361 private static function addBlock(string $text, array $formatting, array $npf, array $callstack): array
369 if (!empty($formatting)) {
370 $block['formatting'] = $formatting;
373 $level = self::getLevelByCallstack($callstack);
375 $block['indent_level'] = $level;
378 $subtype = self::getSubTypeByCallstack($callstack, $text);
380 $block['subtype'] = $subtype;
382 unset($block['subtype']);
386 return [$npf, '', []];
390 * Add a block for a preview picture
392 * @param array $media
393 * @param array $block
396 private static function addPoster(array $media, array $block): array
399 if (!empty($media['preview'])) {
400 $poster['url'] = $media['preview'];
402 if (!empty($media['preview-width'])) {
403 $poster['width'] = $media['preview-width'];
405 if (!empty($media['preview-height'])) {
406 $poster['height'] = $media['preview-height'];
408 if (!empty($poster)) {
409 $block['poster'] = [$poster];
415 * Add a link block from the HTML attachment of a given post uri-id
417 * @param integer $uri_id
418 * @param integer $level
422 private static function addLinkBlockForUriId(int $uri_id, int $level, array $npf): array
424 foreach (Post\Media::getByURIId($uri_id, [Post\Media::HTML]) as $link) {
425 $host = parse_url($link['url'], PHP_URL_HOST);
426 if (in_array($host, ['www.youtube.com', 'youtu.be'])) {
429 'provider' => 'youtube',
430 'url' => $link['url'],
432 } elseif (in_array($host, ['vimeo.com'])) {
435 'provider' => 'vimeo',
436 'url' => $link['url'],
438 } elseif (in_array($host, ['open.spotify.com'])) {
441 'provider' => 'spotify',
442 'url' => $link['url'],
447 'url' => $link['url'],
449 if (!empty($link['name'])) {
450 $block['title'] = $link['name'];
452 if (!empty($link['description'])) {
453 $block['description'] = $link['description'];
455 if (!empty($link['author-name'])) {
456 $block['author'] = $link['author-name'];
458 if (!empty($link['publisher-name'])) {
459 $block['site_name'] = $link['publisher-name'];
464 $block['indent_level'] = $level;
467 $npf[] = self::addPoster($link, $block);
475 * @param DOMElement $element
476 * @param integer $uri_id
477 * @param integer $level
481 private static function addImageBlock(DOMElement $element, int $uri_id, int $level, array $npf): array
484 foreach ($element->attributes as $key => $attribute) {
485 $attributes[$key] = trim($attribute->value);
487 if (empty($attributes['src'])) {
496 if (!empty($attributes['alt'])) {
497 $block['alt_text'] = $attributes['alt'];
500 if (!empty($attributes['title']) && (($attributes['alt'] ?? '') != $attributes['title'])) {
501 $block['caption'] = $attributes['title'];
504 $rid = Photo::ridFromURI($attributes['src']);
506 $photos = Photo::selectToArray([], ['resource-id' => $rid]);
507 foreach ($photos as $photo) {
508 $block['media'][] = [
509 'type' => $photo['type'],
510 'url' => str_replace('-0.', '-' . $photo['scale'] . '.', $attributes['src']),
511 'width' => $photo['width'],
512 'height' => $photo['height'],
515 if (empty($attributes['alt']) && !empty($photos[0]['desc'])) {
516 $block['alt_text'] = $photos[0]['desc'];
518 } elseif ($media = Post\Media::getByURL($uri_id, $attributes['src'], [Post\Media::IMAGE])) {
519 $block['media'][] = [
520 'type' => $media['mimetype'],
521 'url' => $media['url'],
522 'width' => $media['width'],
523 'height' => $media['height'],
525 if (empty($attributes['alt']) && !empty($media['description'])) {
526 $block['alt_text'] = $media['description'];
529 $block['media'][] = ['url' => $attributes['src']];
533 $block['indent_level'] = $level;
542 * Add an audio or video block
544 * @param DOMElement $element
545 * @param integer $uri_id
546 * @param integer $level
550 private static function addMediaBlock(DOMElement $element, int $uri_id, int $level, array $npf): array
553 foreach ($element->attributes as $key => $attribute) {
554 $attributes[$key] = trim($attribute->value);
556 if (empty($attributes['src'])) {
560 $media = Post\Media::getByURL($uri_id, $attributes['src'], [Post\Media::AUDIO, Post\Media::VIDEO]);
561 if (!empty($media)) {
562 switch ($media['type']) {
563 case Post\Media::AUDIO:
567 'type' => $media['mimetype'],
568 'url' => $media['url'],
572 if (!empty($media['name'])) {
573 $block['title'] = $media['name'];
574 } elseif (!empty($media['description'])) {
575 $block['title'] = $media['description'];
578 $block = self::addPoster($media, $block);
581 case Post\Media::VIDEO:
585 'type' => $media['mimetype'],
586 'url' => $media['url'],
590 $block = self::addPoster($media, $block);
596 'text' => $element->textContent,
600 'end' => mb_strlen($element->textContent),
602 'url' => $attributes['src']
609 $block['indent_level'] = $level;