$line) { $matches = null; if (preg_match($regex, $line)) { $regex = self::CONT_BLOCK_PATTERN; if (preg_match('/^(\s+)/', $line, $matches)) { $space = strlen($matches[1]); } else { $space = 0; } $min_space = min($min_space, $space); } } $regex = self::START_BLOCK_PATTERN; if ($min_space) { foreach ($lines as $key => $line) { if (preg_match($regex, $line)) { $regex = self::CONT_BLOCK_PATTERN; $lines[$key] = substr($line, $min_space); } } } // The input text may have linewraps in it, like this: // // - derp derp derp derp // derp derp derp derp // - blarp blarp blarp blarp // // Group text lines together into list items, stored in $items. So the // result in the above case will be: // // array( // array( // "- derp derp derp derp", // " derp derp derp derp", // ), // array( // "- blarp blarp blarp blarp", // ), // ); $item = array(); $starts_at = null; $regex = self::START_BLOCK_PATTERN; foreach ($lines as $line) { $match = null; if (preg_match($regex, $line, $match)) { if (!$starts_at && !empty($match[1])) { $starts_at = $match[1]; } $regex = self::CONT_BLOCK_PATTERN; if ($item) { $items[] = $item; $item = array(); } } $item[] = $line; } if ($item) { $items[] = $item; } if (!$starts_at) { $starts_at = 1; } // Process each item to normalize the text, remove line wrapping, and // determine its depth (indentation level) and style (ordered vs unordered). // // We preserve consecutive linebreaks and interpret them as paragraph // breaks. // // Given the above example, the processed array will look like: // // array( // array( // 'text' => 'derp derp derp derp derp derp derp derp', // 'depth' => 0, // 'style' => '-', // ), // array( // 'text' => 'blarp blarp blarp blarp', // 'depth' => 0, // 'style' => '-', // ), // ); $has_marks = false; foreach ($items as $key => $item) { // Trim space around newlines, to strip trailing whitespace and formatting // indentation. $item = preg_replace('/ *(\n+) */', '\1', implode("\n", $item)); // Replace single newlines with a space. Preserve multiple newlines as // paragraph breaks. $item = preg_replace('/(? $text, 'depth' => $depth, 'style' => $style, 'mark' => $mark, ); } $items = array_values($items); // Users can create a sub-list by indenting any deeper amount than the // previous list, so these are both valid: // // - a // - b // // - a // - b // // In the former case, we'll have depths (0, 2). In the latter case, depths // (0, 4). We don't actually care about how many spaces there are, only // how many list indentation levels (that is, we want to map both of // those cases to (0, 1), indicating "outermost list" and "first sublist"). // // This is made more complicated because lists at two different indentation // levels might be at the same list level: // // - a // - b // - c // - d // // Here, 'b' and 'd' are at the same list level (2) but different indent // levels (2, 4). // // Users can also create "staircases" like this: // // - a // - b // # c // // While this is silly, we'd like to render it as faithfully as possible. // // In order to do this, we convert the list of nodes into a tree, // normalizing indentation levels and inserting dummy nodes as necessary to // make the tree well-formed. See additional notes at buildTree(). // // In the case above, the result is a tree like this: // // - // - // - a // - b // # c $l = 0; $r = count($items); $tree = $this->buildTree($items, $l, $r, $cur_level = 0); // We may need to open a list on a node, but they do not have // list style information yet. We need to propagate list style information // backward through the tree. In the above example, the tree now looks // like this: // // - // - // - a // - b // # c $this->adjustTreeStyleInformation($tree); // Finally, we have enough information to render the tree. $out = $this->renderTree($tree, 0, $has_marks, $starts_at); if ($this->getEngine()->isTextMode()) { $out = implode('', $out); $out = rtrim($out, "\n"); $out = preg_replace('/ +$/m', '', $out); return $out; } return phutil_implode_html('', $out); } /** * See additional notes in @{method:markupText}. */ private function buildTree(array $items, $l, $r, $cur_level) { if ($l == $r) { return array(); } if ($cur_level > self::MAXIMUM_LIST_NESTING_DEPTH) { // This algorithm is recursive and we don't need you blowing the stack // with your oh-so-clever 50,000-item-deep list. Cap indentation levels // at a reasonable number and just shove everything deeper up to this // level. $nodes = array(); for ($ii = $l; $ii < $r; $ii++) { $nodes[] = array( 'level' => $cur_level, 'items' => array(), ) + $items[$ii]; } return $nodes; } $min = $l; for ($ii = $r - 1; $ii >= $l; $ii--) { if ($items[$ii]['depth'] <= $items[$min]['depth']) { $min = $ii; } } $min_depth = $items[$min]['depth']; $nodes = array(); if ($min != $l) { $nodes[] = array( 'text' => null, 'level' => $cur_level, 'style' => null, 'mark' => null, 'items' => $this->buildTree($items, $l, $min, $cur_level + 1), ); } $last = $min; for ($ii = $last + 1; $ii < $r; $ii++) { if ($items[$ii]['depth'] == $min_depth) { $nodes[] = array( 'level' => $cur_level, 'items' => $this->buildTree($items, $last + 1, $ii, $cur_level + 1), ) + $items[$last]; $last = $ii; } } $nodes[] = array( 'level' => $cur_level, 'items' => $this->buildTree($items, $last + 1, $r, $cur_level + 1), ) + $items[$last]; return $nodes; } /** * See additional notes in @{method:markupText}. */ private function adjustTreeStyleInformation(array &$tree) { // The effect here is just to walk backward through the nodes at this level // and apply the first style in the list to any empty nodes we inserted // before it. As we go, also recurse down the tree. $style = '-'; for ($ii = count($tree) - 1; $ii >= 0; $ii--) { if ($tree[$ii]['style'] !== null) { // This is the earliest node we've seen with style, so set the // style to its style. $style = $tree[$ii]['style']; } else { // This node has no style, so apply the current style. $tree[$ii]['style'] = $style; } if ($tree[$ii]['items']) { $this->adjustTreeStyleInformation($tree[$ii]['items']); } } } /** * See additional notes in @{method:markupText}. */ private function renderTree( array $tree, $level, $has_marks, $starts_at = 1) { $style = idx(head($tree), 'style'); $out = array(); if (!$this->getEngine()->isTextMode()) { switch ($style) { case '#': $tag = 'ol'; break; case '-': $tag = 'ul'; break; } $start_attr = null; if (ctype_digit(phutil_string_cast($starts_at)) && $starts_at > 1) { $start_attr = hsprintf(' start="%d"', $starts_at); } if ($has_marks) { $out[] = hsprintf( '<%s class="remarkup-list remarkup-list-with-checkmarks"%s>', $tag, $start_attr); } else { $out[] = hsprintf( '<%s class="remarkup-list"%s>', $tag, $start_attr); } $out[] = "\n"; } $number = $starts_at; foreach ($tree as $item) { if ($this->getEngine()->isTextMode()) { if ($item['text'] === null) { // Don't render anything. } else { $indent = str_repeat(' ', 2 * $level); $out[] = $indent; if ($item['mark'] !== null) { if ($item['mark']) { $out[] = '[X] '; } else { $out[] = '[ ] '; } } else { switch ($style) { case '#': $out[] = $number.'. '; $number++; break; case '-': $out[] = '- '; break; } } $parts = preg_split('/\n{2,}/', $item['text']); foreach ($parts as $key => $part) { if ($key != 0) { $out[] = "\n\n ".$indent; } $out[] = $this->applyRules($part); } $out[] = "\n"; } } else { if ($item['text'] === null) { $out[] = hsprintf('
  • '); } else { if ($item['mark'] !== null) { if ($item['mark'] == true) { $out[] = hsprintf( '
  • '); } else { $out[] = hsprintf( '
  • '); } $out[] = phutil_tag( 'input', array( 'type' => 'checkbox', 'checked' => ($item['mark'] ? 'checked' : null), 'disabled' => 'disabled', )); $out[] = ' '; } else { $out[] = hsprintf('
  • '); } $parts = preg_split('/\n{2,}/', $item['text']); foreach ($parts as $key => $part) { if ($key != 0) { $out[] = array( "\n", phutil_tag('br'), phutil_tag('br'), "\n", ); } $out[] = $this->applyRules($part); } } } if ($item['items']) { $subitems = $this->renderTree($item['items'], $level + 1, $has_marks); foreach ($subitems as $i) { $out[] = $i; } } if (!$this->getEngine()->isTextMode()) { $out[] = hsprintf("
  • \n"); } } if (!$this->getEngine()->isTextMode()) { switch ($style) { case '#': $out[] = hsprintf(''); break; case '-': $out[] = hsprintf(''); break; } } return $out; } }