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 static $heading_subtype = [];
37 static public function fromBBCode(string $bbcode, int $uri_id): array
39 $bbcode = self::prepareBody($bbcode);
41 $html = BBCode::convert($bbcode, false, BBCode::CONNECTORS);
46 $doc = new DOMDocument();
47 $doc->formatOutput = true;
48 if (!@$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'))) {
52 self::setHeadingSubStyles($doc);
54 $element = $doc->getElementsByTagName('body')->item(0);
56 list($npf, $text, $formatting) = self::routeChildren($element, $uri_id, true, []);
58 return self::addLinkBlockForUriId($uri_id, 0, $npf);
61 static function setHeadingSubStyles($doc)
63 self::$heading_subtype = [];
64 foreach (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as $element) {
65 if ($doc->getElementsByTagName($element)->count() > 0) {
66 if (empty(self::$heading_subtype)) {
67 self::$heading_subtype[$element] = 'heading1';
69 self::$heading_subtype[$element] = 'heading2';
75 static private function prepareBody(string $body): string
77 $shared = BBCode::fetchShareAttributes($body);
78 if (!empty($shared)) {
79 $body = $shared['shared'];
82 $body = BBCode::removeAttachment($body);
84 $body = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $body);
86 if (preg_match_all("#\[url=([^\]]+?)\]\s*\[img=([^\[\]]*)\]([^\[\]]*)\[\/img\]\s*\[/url\]#ism", $body, $pictures, PREG_SET_ORDER)) {
87 foreach ($pictures as $picture) {
88 if (preg_match('#/photo/.*-[01]\.#ism', $picture[2]) && (preg_match('#/photo/.*-0\.#ism', $picture[1]) || preg_match('#/photos/.*/image/#ism', $picture[1]))) {
89 $body = str_replace($picture[0], "\n\n[img=" . str_replace('-1.', '-0.', $picture[2]) . "]" . $picture[3] . "[/img]\n\n", $body);
94 $body = preg_replace("/\[img\=(.*?)\](.*?)\[\/img\]/ism", "\n\n[img=$1]$2[/img]\n\n", $body);
96 if (preg_match_all("#\[url=([^\]]+?)\]\s*\[img\]([^\[]+?)\[/img\]\s*\[/url\]#ism", $body, $pictures, PREG_SET_ORDER)) {
97 foreach ($pictures as $picture) {
98 if (preg_match('#/photo/.*-[01]\.#ism', $picture[2]) && (preg_match('#/photo/.*-0\.#ism', $picture[1]) || preg_match('#/photos/.*/image/#ism', $picture[1]))) {
99 $body = str_replace($picture[0], "\n\n[img]" . str_replace('-1.', '-0.', $picture[2]) . "[/img]\n\n", $body);
104 $body = preg_replace("/\[img\](.*?)\[\/img\]/ism", "\n\n[img]$1[/img]\n\n", $body);
105 $body = preg_replace("/\[audio\](.*?)\[\/audio\]/ism", "\n\n[audio]$1[/audio]\n\n", $body);
106 $body = preg_replace("/\[video\](.*?)\[\/video\]/ism", "\n\n[video]$1[/video]\n\n", $body);
110 $body = str_replace(["\n\n\n"], ["\n\n"], $body);
111 } while ($oldbody != $body);
116 static private function routeChildren(DOMElement $element, int $uri_id, bool $parse_structure, array $callstack, array $npf = [], string $text = '', array $formatting = []): array
118 if ($parse_structure && $text) {
119 list($npf, $text, $formatting) = self::addBlock($text, $formatting, $npf, $callstack);
122 $callstack[] = $element->nodeName;
123 $level = self::getLevelByCallstack($callstack);
125 foreach ($element->childNodes as $child) {
126 switch ($child->nodeName) {
129 list($npf, $text, $formatting) = self::addFormatting($child, $uri_id, 'bold', $callstack, $npf, $text, $formatting);
134 list($npf, $text, $formatting) = self::addFormatting($child, $uri_id, 'italic', $callstack, $npf, $text, $formatting);
138 list($npf, $text, $formatting) = self::addFormatting($child, $uri_id, 'strikethrough', $callstack, $npf, $text, $formatting);
143 list($npf, $text, $formatting) = self::addFormatting($child, $uri_id, '', $callstack, $npf, $text, $formatting);
154 $text .= $child->textContent;
159 // Ignore tables and spoilers
164 list($npf, $text, $formatting) = self::addInlineLink($child, $uri_id, $callstack, $npf, $text, $formatting);
166 $npf = self::addLinkBlock($child, $uri_id, $level, $npf);
171 $npf = self::addImageBlock($child, $uri_id, $level, $npf);
175 list($npf, $text, $formatting) = self::routeChildren($child, $uri_id, true, $callstack, $npf, $text, $formatting);
180 if ($parse_structure && $text) {
181 list($npf, $text, $formatting) = self::addBlock($text, $formatting, $npf, $callstack);
183 return [$npf, $text, $formatting];
186 static private function getLevelByCallstack($callstack): int
188 // Deactivated, since Tumblr seems to have issues with the indent level
192 foreach ($callstack as $entry) {
193 if (in_array($entry, ['ol', 'ul', 'blockquote'])) {
197 return max(0, $level - 1);
200 static private function getSubTypeByCallstack($callstack, string $text): string
203 foreach ($callstack as $entry) {
206 $subtype = 'ordered-list-item';
210 $subtype = 'unordered-list-item';
214 $subtype = self::$heading_subtype[$entry];
218 $subtype = self::$heading_subtype[$entry];
222 $subtype = self::$heading_subtype[$entry];
226 $subtype = self::$heading_subtype[$entry];
230 $subtype = self::$heading_subtype[$entry];
234 $subtype = self::$heading_subtype[$entry];
238 $subtype = strlen($text) < 100 ? 'quote' : 'indented';
242 $subtype = 'indented';
253 static private function addFormatting(DOMElement $element, int $uri_id, string $type, array $callstack, array $npf, string $text, array $formatting): array
255 $start = mb_strlen($text);
256 list($npf, $text, $formatting) = self::routeChildren($element, $uri_id, false, $callstack, $npf, $text, $formatting);
261 'end' => mb_strlen($text),
265 return [$npf, $text, $formatting];
268 static private function addInlineLink(DOMElement $element, int $uri_id, array $callstack, array $npf, string $text, array $formatting): array
270 $start = mb_strlen($text);
271 list($npf, $text, $formatting) = self::routeChildren($element, $uri_id, false, $callstack, $npf, $text, $formatting);
274 foreach ($element->attributes as $key => $attribute) {
275 $attributes[$key] = trim($attribute->value);
277 if (!empty($attributes['href'])) {
280 'end' => mb_strlen($text),
282 'url' => $attributes['href']
285 return [$npf, $text, $formatting];
288 static private function addBlock(string $text, array $formatting, array $npf, array $callstack): array
296 // Deactivated since Tumblr has got issues with it
297 //if (!empty($formatting)) {
298 // $block['formatting'] = $formatting;
301 $level = self::getLevelByCallstack($callstack);
303 $block['indent_level'] = $level;
306 $subtype = self::getSubTypeByCallstack($callstack, $text);
308 $block['subtype'] = $subtype;
310 unset($block['subtype']);
316 return [$npf, $text, $formatting];
319 static private function addPoster(array $media, array $block): array
322 if (!empty($media['preview'])) {
323 $poster['url'] = $media['preview'];
325 if (!empty($media['preview-width'])) {
326 $poster['width'] = $media['preview-width'];
328 if (!empty($media['preview-height'])) {
329 $poster['height'] = $media['preview-height'];
331 if (!empty($poster)) {
332 $block['poster'] = [$poster];
337 static private function addLinkBlockForUriId(int $uri_id, int $level, array $npf): array
339 foreach (Post\Media::getByURIId($uri_id, [Post\Media::HTML]) as $link) {
340 $host = parse_url($link['url'], PHP_URL_HOST);
341 if (in_array($host, ['www.youtube.com', 'youtu.be'])) {
344 'provider' => 'youtube',
345 'url' => $link['url'],
347 } elseif (in_array($host, ['vimeo.com'])) {
350 'provider' => 'vimeo',
351 'url' => $link['url'],
353 } elseif (in_array($host, ['open.spotify.com'])) {
356 'provider' => 'spotify',
357 'url' => $link['url'],
362 'url' => $link['url'],
364 if (!empty($link['name'])) {
365 $block['title'] = $link['name'];
367 if (!empty($link['description'])) {
368 $block['description'] = $link['description'];
370 if (!empty($link['author-name'])) {
371 $block['author'] = $link['author-name'];
373 if (!empty($link['publisher-name'])) {
374 $block['site_name'] = $link['publisher-name'];
379 $block['indent_level'] = $level;
382 $npf[] = self::addPoster($link, $block);
387 static private function addImageBlock(DOMElement $element, int $uri_id, int $level, array $npf): array
390 foreach ($element->attributes as $key => $attribute) {
391 $attributes[$key] = trim($attribute->value);
393 if (empty($attributes['src'])) {
402 if (!empty($attributes['alt'])) {
403 $block['alt_text'] = $attributes['alt'];
406 if (!empty($attributes['title']) && (($attributes['alt'] ?? '') != $attributes['title'])) {
407 $block['caption'] = $attributes['title'];
410 $rid = Photo::ridFromURI($attributes['src']);
412 $photos = Photo::selectToArray([], ['resource-id' => $rid]);
413 foreach ($photos as $photo) {
414 $block['media'][] = [
415 'type' => $photo['type'],
416 'url' => str_replace('-0.', '-' . $photo['scale'] . '.', $attributes['src']),
417 'width' => $photo['width'],
418 'height' => $photo['height'],
421 if (empty($attributes['alt']) && !empty($photos[0]['desc'])) {
422 $block['alt_text'] = $photos[0]['desc'];
424 } elseif ($media = Post\Media::getByURL($uri_id, $attributes['src'], [Post\Media::IMAGE])) {
425 $block['media'][] = [
426 'type' => $media['mimetype'],
427 'url' => $media['url'],
428 'width' => $media['width'],
429 'height' => $media['height'],
431 if (empty($attributes['alt']) && !empty($media['description'])) {
432 $block['alt_text'] = $media['description'];
435 $block['media'][] = ['url' => $attributes['src']];
439 $block['indent_level'] = $level;
447 static private function addLinkBlock(DOMElement $element, int $uri_id, int $level, array $npf): array
450 foreach ($element->attributes as $key => $attribute) {
451 $attributes[$key] = trim($attribute->value);
453 if (empty($attributes['href'])) {
457 $media = Post\Media::getByURL($uri_id, $attributes['href'], [Post\Media::AUDIO, Post\Media::VIDEO]);
458 if (!empty($media)) {
459 switch ($media['type']) {
460 case Post\Media::AUDIO:
464 'type' => $media['mimetype'],
465 'url' => $media['url'],
469 if (!empty($media['name'])) {
470 $block['title'] = $media['name'];
471 } elseif (!empty($media['description'])) {
472 $block['title'] = $media['description'];
475 $block = self::addPoster($media, $block);
478 case Post\Media::VIDEO:
482 'type' => $media['mimetype'],
483 'url' => $media['url'],
487 $block = self::addPoster($media, $block);
493 'text' => $element->textContent,
494 // Deactivated, since Tumblr has got issues with the formatting
497 // 'end' => strlen($element->textContent),
499 // 'url' => $attributes['href']
505 $block['indent_level'] = $level;