max number of attributes
* @var array $ValidTags
*/
private static $ValidTags = [
'b' => 0,
'u' => 0,
'i' => 0,
's' => 0,
'*' => 0,
'#' => 0,
#'ch' => 0,
#'uch' => 0,
'artist' => 0,
'user' => 0,
'n' => 0,
'inlineurl' => 0,
'inlinesize' => 1,
'headline' => 1,
'align' => 1,
'color' => 1,
'colour' => 1,
'size' => 1,
'url' => 1,
'img' => 1,
'quote' => 1,
'pre' => 1,
'code' => 1,
'tex' => 0,
'hide' => 1,
'spoiler' => 1,
'plain' => 0,
'important' => 0,
'torrent' => 0,
'rule' => 0,
'embed' => 0,
];
/**
* Array of smilies; code => image file in STATIC_SERVER/common/smileys
* @var array $Smileys
*/
private static $Smileys = [];
/**
* Processed version of the $Smileys array, see {@link smileys}
* @var array $ProcessedSmileys
*/
private static $ProcessedSmileys = [];
/**
* Whether or not to turn images into URLs (used inside [quote] tags).
* This is an integer reflecting the number of levels we're doing that
* transition, i.e. images will only be displayed as images if $NoImg <= 0.
* By setting this variable to a negative number you can delay the
* transition to a deeper level of quotes.
* @var int $NoImg
*/
private static $NoImg = 0;
/**
* Internal counter for the level of recursion in to_html
* @var int $Levels
*/
private static $Levels = 0;
/**
* The maximum amount of nesting allowed (exclusive)
* In reality n-1 nests are shown.
* @var int $MaximumNests
*/
private static $MaximumNests = 10;
/**
* Used to detect and disable parsing (e.g. TOC) within quotes
* @var int $InQuotes
*/
private static $InQuotes = 0;
/**
* Used to [hide] quote trains starting with the specified depth (inclusive)
* @var int $NestsBeforeHide
*
* This defaulted to 5 but was raised to 10 to effectively "disable" it until
* an optimal number of nested [quote] tags is chosen. The variable $MaximumNests
* effectively overrides this variable, if $MaximumNests is less than the value
* of $NestsBeforeHide.
*/
private static $NestsBeforeHide = 10;
/**
* Array of headlines for Table of Contents (TOC)
* @var array $HeadLines
*/
private static $Headlines;
/**
* Counter for making headline URLs unique
* @var int $HeadLines
*/
private static $HeadlineID = 0;
/**
* Depth
* @var array $HeadlineLevels
*/
private static $HeadlineLevels = array('1', '2', '3', '4');
/**
* TOC enabler
* @var bool $TOC
*/
public static $TOC = false;
/**
* Output BBCode as XHTML
*
* This used to suck and now it only blows.
* If $Str isn't BBcode, call Parsedown in safe mode.
* Otherwise, continue with the default parser.
*
* @param string $Str BBCode text
* @param bool $OutputTOC Ouput TOC near (above) text
* @param int $Min See {@link parse_toc}
* @return string
*/
public static function full_format($Str, $OutputTOC = true, $Min = 3)
{
$ENV = ENV::go();
# Here's the magic pattern:
if (!preg_match(
"/$ENV->BBCODE_REGEX/s",
$Str
)) {
$Parsedown = new ParsedownExtra();
$Parsedown->setSafeMode(true);
$Str = html_entity_decode($Str, ENT_QUOTES | ENT_HTML5, 'UTF-8');
# Markdown ToC not happening yet
# Shouldn't parse_toc() output HTML
/*
self::$Headlines = [];
if (self::$TOC) {
$List = [];
foreach (count(1, 6) as $x) {
array_push(
$List,
preg_replace(
"/(\#{$x})([^#].*)\1/i",
"$2",
$Str
)
);
}
}
*/
return $P = $Parsedown->text($Str);
/*
return $P =
((self::$TOC && $OutputTOC)
? self::parse_toc($Min)
: null)
. $Parsedown->text($Str);
*/
} else {
global $Debug;
$Debug->set_flag('BBCode start');
self::$Headlines = [];
$Str = display_str($Str);
# Checkboxes: broken and stupid
/*
$Str = preg_replace('/\[\\[(ch|uch)]\]/i', '', $Str);
$Str = preg_replace('/\[ch\]/i', '[ch][/ch]', $Str);
$Str = preg_replace('/\[uch\]/i', '[uch][/uch]', $Str);
*/
// Inline links
$URLPrefix = '(\[url\]|\[url\=|\[img\=|\[img\])';
$Str = preg_replace('/'.$URLPrefix.'\s+/i', '$1', $Str);
$Str = preg_replace('/(?set_flag('BBCode end');
return $HTML;
}
}
/**
* strip_bbcode()
*/
public static function strip_bbcode($Str)
{
$Str = display_str($Str);
// Inline links
$Str = preg_replace(
'/(? $i) {
$Array[$ArrayPos] = substr($Str, $i, $TagPos - $i);
++$ArrayPos;
$i = $TagPos;
}
// 2) See if it's a [[wiki-link]] or an ordinary tag, and get the tag name
if (!empty($Tag[4][0])) { // Wiki-link
$WikiLink = true;
$TagName = substr($Tag[4][0], 2, -2);
$Attrib = '';
} else { // 3) If it's not a wiki link:
$WikiLink = false;
$TagName = strtolower(substr($Tag[2][0], 1));
// 3a) check it against the self::$ValidTags array to see if it's actually a tag and not [bullshit]
if (!isset(self::$ValidTags[$TagName])) {
$Array[$ArrayPos] = substr($Str, $i, ($TagPos - $i) + strlen($Tag[0][0]));
$i = $TagPos + strlen($Tag[0][0]);
++$ArrayPos;
continue;
}
$MaxAttribs = self::$ValidTags[$TagName];
// 3b) Get the attribute, if it exists [name=attribute]
if (!empty($Tag[3][0])) {
$Attrib = substr($Tag[3][0], 1);
} else {
$Attrib = '';
}
}
// 4) Move the pointer past the end of the tag
$i = $TagPos + strlen($Tag[0][0]);
// 5) Find out where the tag closes (beginning of [/tag])
// Unfortunately, BBCode doesn't have nice standards like XHTML
// [*], [img=...], and http:// follow different formats
// Thus, we have to handle these before we handle the majority of tags
// 5a) Different for different types of tag. Some tags don't close, others are weird like [*]
if ($TagName === 'img' && !empty($Tag[3][0])) { //[img=...]
$Block = ''; // Nothing inside this tag
// Don't need to touch $i
} elseif ($TagName === 'inlineurl') { // We did a big replace early on to turn http:// into [inlineurl]http://
// Let's say the block can stop at a newline or a space
$CloseTag = strcspn($Str, " \n\r", $i);
if ($CloseTag === false) { // Block finishes with URL
$CloseTag = $Len;
}
if (preg_match('/[!,.?:]+$/', substr($Str, $i, $CloseTag), $Match)) {
$CloseTag -= strlen($Match[0]);
}
$URL = substr($Str, $i, $CloseTag);
if (substr($URL, -1) === ')' && substr_count($URL, '(') < substr_count($URL, ')')) {
$CloseTag--;
$URL = substr($URL, 0, -1);
}
$Block = $URL; // Get the URL
// strcspn returns the number of characters after the offset $i, not after the beginning of the string
// Therefore, we use += instead of the = everywhere else
$i += $CloseTag; // 5d) Move the pointer past the end of the [/close] tag.
} elseif ($WikiLink === true || $TagName === 'n') {
// Don't need to do anything - empty tag with no closing
} elseif ($TagName === '*' || $TagName === '#') {
// We're in a list. Find where it ends
$NewLine = $i;
do { // Look for \n[*]
$NewLine = strpos($Str, "\n", $NewLine + 1);
} while ($NewLine !== false && substr($Str, $NewLine + 1, 3) === "[$TagName]");
$CloseTag = $NewLine;
if ($CloseTag === false) { // Block finishes with list
$CloseTag = $Len;
}
$Block = substr($Str, $i, $CloseTag - $i); // Get the list
$i = $CloseTag; // 5d) Move the pointer past the end of the [/close] tag.
} else {
// 5b) If it's a normal tag, it may have versions of itself nested inside
$CloseTag = $i - 1;
$InTagPos = $i - 1;
$NumInOpens = 0;
$NumInCloses = -1;
$InOpenRegex = '/\[('.$TagName.')';
if ($MaxAttribs > 0) {
$InOpenRegex .= "(=[^\n'\"\[\]]+)?";
}
$InOpenRegex .= '\]/i';
// Every time we find an internal open tag of the same type, search for the next close tag
// (as the first close tag won't do - it's been opened again)
do {
$CloseTag = strpos($StrLC, "[/$TagName]", $CloseTag + 1);
if ($CloseTag === false) {
$CloseTag = $Len;
break;
} else {
$NumInCloses++; // Majority of cases
}
// Is there another open tag inside this one?
$OpenTag = preg_match($InOpenRegex, $Str, $InTag, PREG_OFFSET_CAPTURE, $InTagPos + 1);
if (!$OpenTag || $InTag[0][1] > $CloseTag) {
break;
} else {
$InTagPos = $InTag[0][1];
$NumInOpens++;
}
} while ($NumInOpens > $NumInCloses);
// Find the internal block inside the tag
$Block = substr($Str, $i, $CloseTag - $i); // 5c) Get the contents between [open] and [/close] and call it the block
$i = $CloseTag + strlen($TagName) + 3; // 5d) Move the pointer past the end of the [/close] tag
}
// 6) Depending on what type of tag we're dealing with, create an array with the attribute and block
switch ($TagName) {
case 'inlineurl':
$Array[$ArrayPos] = array('Type' => 'inlineurl', 'Attr' => $Block, 'Val' => '');
break;
case 'url':
$Array[$ArrayPos] = array('Type' => 'img', 'Attr' => $Attrib, 'Val' => $Block);
if (empty($Attrib)) { // [url]http://...[/url] - always set URL to attribute
$Array[$ArrayPos] = array('Type' => 'url', 'Attr' => $Block, 'Val' => '');
} else {
$Array[$ArrayPos] = array('Type' => 'url', 'Attr' => $Attrib, 'Val' => self::parse($Block));
}
break;
case 'quote':
$Array[$ArrayPos] = array('Type' => 'quote', 'Attr' => self::parse($Attrib), 'Val' => self::parse($Block));
break;
case 'img':
case 'image':
if (empty($Block)) {
$Block = $Attrib;
}
$Array[$ArrayPos] = array('Type' => 'img', 'Val' => $Block);
break;
case 'aud':
case 'mp3':
case 'audio':
if (empty($Block)) {
$Block = $Attrib;
}
$Array[$ArrayPos] = array('Type' => 'aud', 'Val' => $Block);
break;
case 'user':
$Array[$ArrayPos] = array('Type' => 'user', 'Val' => $Block);
break;
case 'artist':
$Array[$ArrayPos] = array('Type' => 'artist', 'Val' => $Block);
break;
case 'torrent':
$Array[$ArrayPos] = array('Type' => 'torrent', 'Val' => $Block);
break;
case 'tex':
$Array[$ArrayPos] = array('Type' => 'tex', 'Val' => $Block);
break;
case 'rule':
$Array[$ArrayPos] = array('Type' => 'rule', 'Val' => $Block);
break;
case 'pre':
case 'code':
case 'plain':
$Block = strtr($Block, array('[inlineurl]' => ''));
$Callback = function ($matches) {
$n = $matches[2];
$text = '';
if ($n < 5 && $n > 0) {
$e = str_repeat('=', $matches[2] + 1);
$text = $e . $matches[3] . $e;
}
return $text;
};
$Block = preg_replace_callback('/\[(headline)\=(\d)\](.*?)\[\/\1\]/i', $Callback, $Block);
$Block = preg_replace('/\[inlinesize\=3\](.*?)\[\/inlinesize\]/i', '====$1====', $Block);
$Block = preg_replace('/\[inlinesize\=5\](.*?)\[\/inlinesize\]/i', '===$1===', $Block);
$Block = preg_replace('/\[inlinesize\=7\](.*?)\[\/inlinesize\]/i', '==$1==', $Block);
$Array[$ArrayPos] = array('Type' => $TagName, 'Val' => $Block);
break;
case 'spoiler':
case 'hide':
$Array[$ArrayPos] = array('Type' => 'hide', 'Attr' => $Attrib, 'Val' => self::parse($Block));
break;
case 'embed':
$Array[$ArrayPos] = array('Type' => 'embed', 'Val' => $Block);
break;
case '#':
case '*':
$Array[$ArrayPos] = array('Type' => 'list');
$Array[$ArrayPos]['Val'] = explode("[$TagName]", $Block);
$Array[$ArrayPos]['ListType'] = $TagName === '*' ? 'ul' : 'ol';
$Array[$ArrayPos]['Tag'] = $TagName;
foreach ($Array[$ArrayPos]['Val'] as $Key => $Val) {
$Array[$ArrayPos]['Val'][$Key] = self::parse(trim($Val));
}
break;
case 'n':
$ArrayPos--;
break; // n serves only to disrupt bbcode (backwards compatibility - use [pre])
default:
if ($WikiLink === true) {
$Array[$ArrayPos] = array('Type' => 'wiki','Val' => $TagName);
} else {
// Basic tags, like [b] or [size=5]
$Array[$ArrayPos] = array('Type' => $TagName, 'Val' => self::parse($Block));
if (!empty($Attrib) && $MaxAttribs > 0) {
$Array[$ArrayPos]['Attr'] = strtolower($Attrib);
}
}
}
$ArrayPos++; // 7) Increment array pointer, start again (past the end of the [/close] tag)
}
return $Array;
}
/**
* Generates a navigation list for TOC
* @param int $Min Minimum number of headlines required for a TOC list
*/
public static function parse_toc($Min = 3)
{
if (count(self::$Headlines) > $Min) {
$list = '
', $level);
$list .= "\n\n";
return $list;
}
}
/**
* Generates the list items and proper depth
*
* First check if the item should be higher than the current level
* - Close the list and previous lists
*
* Then check if the item should go lower than the current level
* - If the list doesn't open on level one, use the Offset
* - Open appropriate sub lists
*
* Otherwise the item is on the same as level as the previous item
*
* @param int $ItemLevel Current item level
* @param int $Level Current list level
* @param str $List reference to an XHTML string
* @param int $i Iterator digit
* @param int $Offset If the list doesn't start at level 1
*/
private static function headline_level(&$ItemLevel, &$Level, &$List, $i, &$Offset)
{
if ($ItemLevel < $Level) {
$diff = $Level - $ItemLevel;
$List .= '' . str_repeat('', $diff);
} elseif ($ItemLevel > $Level) {
$diff = $ItemLevel - $Level;
if ($Offset > 0) {
$List .= str_repeat('
';
}
}
/*
* Hax prevention
*
* That's the original comment on this.
* Most likely this was implemented to avoid anyone nesting enough elements
* to reach PHP's memory limit as nested elements are solved recursively.
* Original value of 10, it is now replaced in favor of $MaximumNests.
*
* If this line is ever executed then something is, in fact, being haxed.
* The if before the block type switch for different tags should always be limiting ahead of this line.
*/
private static function to_html($Array)
{
self::$Levels++;
// Hax prevention, breaks upon exceeding nests
if (self::$Levels > self::$MaximumNests) {
return $Block['Val'];
}
$Str = '';
foreach ($Array as $Block) {
if (is_string($Block)) {
$Str .= self::smileys($Block);
continue;
}
if (self::$Levels < self::$MaximumNests) {
switch ($Block['Type']) {
case 'b':
$Str .= ''.self::to_html($Block['Val']).'';
break;
case 'u':
$Str .= ''.self::to_html($Block['Val']).'';
break;
case 'i':
$Str .= ''.self::to_html($Block['Val'])."";
break;
case 's':
$Str .= ''.self::to_html($Block['Val']).'';
break;
case 'important':
$Str .= ''.self::to_html($Block['Val']).'';
break;
case 'user':
$Str .= ''.$Block['Val'].'';
break;
case 'artist':
$Str .= ''.$Block['Val'].'';
break;
case 'rule':
$Rule = trim(strtolower($Block['Val']));
if ($Rule[0] !== 'r' && $Rule[0] !== 'h') {
$Rule = 'r'.$Rule;
}
$Str .= ''.preg_replace('/[aA-zZ]/', '', $Block['Val']).'';
break;
case 'torrent':
$Pattern = '/('.SITE_DOMAIN.'\/torrents\.php.*[\?&]id=)?(\d+)($|&|\#).*/i';
$Matches = [];
if (preg_match($Pattern, $Block['Val'], $Matches)) {
if (isset($Matches[2])) {
$GroupID = $Matches[2];
$Groups = Torrents::get_groups(array($GroupID), true, true, false);
if ($Groups[$GroupID]) {
$Group = $Groups[$GroupID];
$Str .= Artists::display_artists($Group['Artists']).'';
} else {
$Str .= '[torrent]'.str_replace('[inlineurl]', '', $Block['Val']).'[/torrent]';
}
}
} else {
$Str .= '[torrent]'.str_replace('[inlineurl]', '', $Block['Val']).'[/torrent]';
}
break;
case 'wiki':
$Str .= ''.$Block['Val'].'';
break;
case 'tex':
$Str .= '';
break;
case 'plain':
$Str .= $Block['Val'];
break;
case 'pre':
$Str .= '
'.$Block['Val'].'
';
break;
case 'code':
$Str .= ''.$Block['Val'].'';
break;
/*
case 'ch':
$Str .= '';
break;
*/
/*
case 'uch':
$Str .= '';
break;
*/
case 'list':
$Str .= "<$Block[ListType] class=\"postlist\">";
foreach ($Block['Val'] as $Line) {
$Str .= '
';
}
break;
case 'color':
case 'colour':
$ValidAttribs = array('aqua', 'black', 'blue', 'fuchsia', 'green', 'grey', 'lime', 'maroon', 'navy', 'olive', 'purple', 'red', 'silver', 'teal', 'white', 'yellow');
if (!in_array($Block['Attr'], $ValidAttribs) && !preg_match('/^#[0-9a-f]{6}$/', $Block['Attr'])) {
$Str .= '[color='.$Block['Attr'].']'.self::to_html($Block['Val']).'[/color]';
} else {
$Str .= ''.self::to_html($Block['Val']).'';
}
break;
case 'headline':
$text = self::to_html($Block['Val']);
$raw = self::raw_text($Block['Val']);
if (!in_array($Block['Attr'], self::$HeadlineLevels)) {
$Str .= sprintf('%1$s%2$s%1$s', str_repeat('=', $Block['Attr'] + 1), $text);
} else {
$id = '_' . crc32($raw . self::$HeadlineID);
if (self::$InQuotes === 0) {
self::$Headlines[] = array($Block['Attr'], $raw, $id);
}
$Str .= sprintf('%2$s', ($Block['Attr'] + 2), $text, $id);
self::$HeadlineID++;
}
break;
case 'inlinesize':
case 'size':
$ValidAttribs = array('1', '2', '3', '4', '5', '6', '7', '8', '9', '10');
if (!in_array($Block['Attr'], $ValidAttribs)) {
$Str .= '[size='.$Block['Attr'].']'.self::to_html($Block['Val']).'[/size]';
} else {
$Str .= ''.self::to_html($Block['Val']).'';
}
break;
case 'quote':
self::$NoImg++; // No images inside quote tags
self::$InQuotes++;
if (self::$InQuotes === self::$NestsBeforeHide) { // Put quotes that are nested beyond the specified limit in [hide] tags.
$Str .= 'Older quotes: Show';
$Str .= '
';
}
if (!empty($Block['Attr'])) {
$Exploded = explode('|', self::to_html($Block['Attr']));
if (isset($Exploded[1]) && (is_numeric($Exploded[1]) || (in_array($Exploded[1][0], array('a', 't', 'c', 'r')) && is_numeric(substr($Exploded[1], 1))))) {
// The part after | is either a number or starts with a, t, c or r, followed by a number (forum post, artist comment, torrent comment, collage comment or request comment, respectively)
$PostID = trim($Exploded[1]);
$Str .= ''.$Exploded[0].' wrote: ';
} else {
$Str .= ''.$Exploded[0].' wrote: ';
}
}
$Str .= '
'.self::to_html($Block['Val']).'
';
if (self::$InQuotes === self::$NestsBeforeHide) { //Close quote the deeply nested quote [hide].
$Str .= '
'; // Ensure new line after quote train hiding
}
self::$NoImg--;
self::$InQuotes--;
break;
case 'hide':
$Str .= ''.(($Block['Attr']) ? $Block['Attr'] : 'Hidden text').': Show';
$Str .= '
'.self::to_html($Block['Val']).'
';
break;
case 'img':
if (self::$NoImg > 0 && self::valid_url($Block['Val'])) {
$Str .= ''.$Block['Val'].' (image)';
break;
}
if (!self::valid_url($Block['Val'], '\.(jpe?g|gif|png|bmp|tiff)')) {
$Str .= '[img]'.$Block['Val'].'[/img]';
} else {
$LocalURL = self::local_url($Block['Val']);
if ($LocalURL) {
$Str .= '';
} else {
$Str .= '';
}
}
break;
case 'aud':
if (self::$NoImg > 0 && self::valid_url($Block['Val'])) {
$Str .= ''.$Block['Val'].' (audio)';
break;
}
if (!self::valid_url($Block['Val'], '\.(mp3|ogg|wav)')) {
$Str .= '[aud]'.$Block['Val'].'[/aud]';
} else {
// todo: Proxy this for staff?
$Str .= '';
}
break;
case 'url':
// Make sure the URL has a label
if (empty($Block['Val'])) {
$Block['Val'] = $Block['Attr'];
$NoName = true; // If there isn't a Val for this
} else {
$Block['Val'] = self::to_html($Block['Val']);
$NoName = false;
}
if (!self::valid_url($Block['Attr'])) {
$Str .= '[url='.$Block['Attr'].']'.$Block['Val'].'[/url]';
} else {
$LocalURL = self::local_url($Block['Attr']);
if ($LocalURL) {
if ($NoName) {
$Block['Val'] = substr($LocalURL, 1);
}
$Str .= ''.$Block['Val'].'';
} else {
$Str .= ''.$Block['Val'].'';
}
}
break;
case 'inlineurl':
if (!self::valid_url($Block['Attr'], '', true)) {
$Array = self::parse($Block['Attr']);
$Block['Attr'] = $Array;
$Str .= self::to_html($Block['Attr']);
} else {
$LocalURL = self::local_url($Block['Attr']);
if ($LocalURL) {
$Str .= ''.substr($LocalURL, 1).'';
} else {
$Str .= ''.$Block['Attr'].'';
}
}
break;
case 'embed':
$Val = str_replace(' ', '', $Block['Val']);
if (self::valid_url($Val) && substr($Val, -4) === 'webm') {
$Str .= '';
}
break;
}
}
}
self::$Levels--;
return $Str;
}
/**
* raw_text()
*/
private static function raw_text($Array)
{
$Str = '';
foreach ($Array as $Block) {
if (is_string($Block)) {
$Str .= $Block;
continue;
}
switch ($Block['Type']) {
case 'headline':
break;
case 'b':
case 'u':
case 'i':
case 's':
case 'color':
case 'size':
case 'quote':
case 'align':
$Str .= self::raw_text($Block['Val']);
break;
case 'tex': // Since this will never strip cleanly, just remove it
break;
case 'artist':
case 'user':
case 'wiki':
case 'pre':
case 'code':
case 'aud':
case 'img':
$Str .= $Block['Val'];
break;
case 'list':
foreach ($Block['Val'] as $Line) {
$Str .= $Block['Tag'].self::raw_text($Line);
}
break;
case 'url':
// Make sure the URL has a label
if (empty($Block['Val'])) {
$Block['Val'] = $Block['Attr'];
} else {
$Block['Val'] = self::raw_text($Block['Val']);
}
$Str .= $Block['Val'];
break;
case 'inlineurl':
if (!self::valid_url($Block['Attr'], '', true)) {
$Array = self::parse($Block['Attr']);
$Block['Attr'] = $Array;
$Str .= self::raw_text($Block['Attr']);
} else {
$Str .= $Block['Attr'];
}
break;
}
}
return $Str;
}
/**
* smileys()
*/
private static function smileys($Str)
{
return $Str;
/*
if (!empty(G::$LoggedUser['DisableSmileys'])) {
return $Str;
}
if (count(self::$ProcessedSmileys) === 0 && count(self::$Smileys) > 0) {
foreach (self::$Smileys as $Key => $Val) {
self::$ProcessedSmileys[$Key] = '';
}
reset(self::$ProcessedSmileys);
}
$Str = strtr($Str, self::$ProcessedSmileys);
return $Str;
*/
}
}
/*
// Uncomment this part to test the class via command line:
function display_str($Str) {
return $Str;
}
function check_perms($Perm) {
return true;
}
$Str = "hello
[pre]http://anonym.to/?http://whatshirts.portmerch.com/
====hi====
===hi===
==hi==[/pre]
====hi====
hi";
echo Text::full_format($Str);
echo "\n"
*/