mirror of
https://github.com/markqvist/Reticulum.git
synced 2026-06-10 15:03:30 -07:00
712 lines
26 KiB
Python
712 lines
26 KiB
Python
# Reticulum License
|
|
#
|
|
# Copyright (c) 2016-2026 Mark Qvist
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
# of this software and associated documentation files (the "Software"), to deal
|
|
# in the Software without restriction, including without limitation the rights
|
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
# copies of the Software, and to permit persons to whom the Software is
|
|
# furnished to do so, subject to the following conditions:
|
|
#
|
|
# - The Software shall not be used in any kind of system which includes amongst
|
|
# its functions the ability to purposefully do harm to human beings.
|
|
#
|
|
# - The Software shall not be used, directly or indirectly, in the creation of
|
|
# an artificial intelligence, machine learning or language model training
|
|
# dataset, including but not limited to any use that contributes to the
|
|
# training or development of such a model or algorithm.
|
|
#
|
|
# - The above copyright notice and this permission notice shall be included in
|
|
# all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
# SOFTWARE.
|
|
|
|
import re
|
|
import RNS
|
|
|
|
class MarkdownToMicron:
|
|
BOLD = "`!"
|
|
BOLD_END = "`!"
|
|
ITALIC = "`*"
|
|
ITALIC_END = "`*"
|
|
UNDERLINE = "`_"
|
|
UNDERLINE_END = "`_"
|
|
|
|
CODE_BG = "`BT282828"
|
|
CODE_BG_INLINE = "`BT383838"
|
|
CODE_FG = "`Fddd"
|
|
CODE_RESET = "`f`b"
|
|
|
|
LITERAL_START = "`="
|
|
LITERAL_END = "`="
|
|
|
|
BULLET = "•"
|
|
|
|
# Regex patterns for markdown elements
|
|
HEADER_RE = re.compile(r'^(#{1,6})\s+(.+)$')
|
|
CODE_FENCE_RE = re.compile(r'^(\s*)```(.*)$')
|
|
HORIZONTAL_RULE_RE = re.compile(r'^(\s*)(---+|===+|\*\*\*+|___+)\s*$')
|
|
UNORDERED_LIST_RE = re.compile(r'^(\s*)([-*+])\s+(.+)$')
|
|
|
|
# Table patterns
|
|
TABLE_ROW_RE = re.compile(r'^\s*\|?(.+?)\|?\s*$')
|
|
TABLE_SEP_RE = re.compile(r'^\s*\|?(?:\s*:?-+:?\s*\|)+\s*$')
|
|
|
|
# Quote pattern
|
|
QUOTE_RE = re.compile(r'^>\s?(.*)$')
|
|
|
|
# Inline patterns (processed in order of specificity)
|
|
LINK_RE = re.compile(r'\[([^\]]+)\]\(([^)]+)\)')
|
|
INLINE_CODE_RE = re.compile(r'`([^`]+)`')
|
|
BOLD_RE = re.compile(r'\*\*(.+?)\*\*|__(.+?)__')
|
|
ITALIC_RE = re.compile(r'\*(.+?)\*|_(.+?)_')
|
|
|
|
TABLE_H = "─"
|
|
TABLE_V = "│"
|
|
TABLE_TL = "┌"
|
|
TABLE_TR = "┐"
|
|
TABLE_BL = "└"
|
|
TABLE_BR = "┘"
|
|
TABLE_ML = "├"
|
|
TABLE_MR = "┤"
|
|
TABLE_TM = "┬"
|
|
TABLE_BM = "┴"
|
|
TABLE_MM = "┼"
|
|
|
|
TABLE_MIN_COL_WIDTH = 3
|
|
|
|
def __init__(self, max_width=100, syntax_highlighter=None, url_scope=None):
|
|
self.max_width = max_width
|
|
self.local_url_scope = url_scope or ":/page/"
|
|
self.__local_url_scope = self.local_url_scope
|
|
self.syntax_highlighter = syntax_highlighter
|
|
self.wcwidth = None
|
|
|
|
try:
|
|
import wcwidth
|
|
self.wcwidth = wcwidth
|
|
|
|
except: RNS.log(f"The wcwidth module is unavailable, display width calculations for some glyphs will be incorrect", RNS.LOG_WARNING)
|
|
|
|
def set_url_scope(self, url_scope): self.local_url_scope = url_scope
|
|
def restore_url_scope(self, url_scope): self.local_url_scope = self.__local_url_scope
|
|
|
|
def display_width(self, text):
|
|
if not self.wcwidth: return len(text)
|
|
else:
|
|
# wcswidth returns -1 for non-printable strings,
|
|
# fallback to len in this case
|
|
w = self.wcwidth.wcswidth(text)
|
|
return w if w is not None and w >= 0 else len(text)
|
|
|
|
def format_block(self, text, url_scope=None):
|
|
lines = text.split('\n')
|
|
result_lines = []
|
|
in_code_block = False
|
|
code_block_lang = None
|
|
code_buffer = []
|
|
in_table = False
|
|
table_buffer = []
|
|
in_quote = False
|
|
quote_buffer = []
|
|
|
|
def flush_quote_buffer():
|
|
nonlocal result_lines, quote_buffer, in_quote
|
|
if not quote_buffer:
|
|
in_quote = False
|
|
return
|
|
|
|
para = " ".join(quote_buffer)
|
|
formatted = self._format_inline(para)
|
|
|
|
effective_width = self.max_width - 3
|
|
if effective_width < 1: effective_width = 1
|
|
wrapped_lines = self._wrap_text(formatted, effective_width)
|
|
for wrapped_line in wrapped_lines: result_lines.append(f" │ {wrapped_line}")
|
|
|
|
quote_buffer = []
|
|
in_quote = False
|
|
|
|
def flush_table_buffer():
|
|
nonlocal result_lines, table_buffer, in_table
|
|
if not table_buffer:
|
|
in_table = False
|
|
return
|
|
|
|
if len(table_buffer) >= 2 and self._is_table_separator(table_buffer[1]):
|
|
formatted_lines = self.format_table(table_buffer)
|
|
result_lines.extend(formatted_lines)
|
|
|
|
else:
|
|
for line in table_buffer: result_lines.append(self.format_line(line))
|
|
|
|
table_buffer = []
|
|
in_table = False
|
|
|
|
def flush_code_block():
|
|
nonlocal result_lines, code_buffer, code_block_lang
|
|
if not code_buffer:
|
|
return
|
|
|
|
code_content = '\n'.join(code_buffer)
|
|
|
|
if self.syntax_highlighter and code_block_lang:
|
|
try:
|
|
highlighted = self.syntax_highlighter.highlight(code_content, language=code_block_lang)
|
|
result_lines.append(f"{self.CODE_BG}{self.CODE_FG}")
|
|
result_lines.append(highlighted)
|
|
result_lines.append(self.CODE_RESET)
|
|
|
|
except Exception:
|
|
# Fallback to plain literal block on any error
|
|
result_lines.append(f"{self.CODE_BG}{self.CODE_FG}")
|
|
result_lines.append(self.LITERAL_START)
|
|
result_lines.append(self._escape_literals(code_content))
|
|
result_lines.append(self.LITERAL_END)
|
|
result_lines.append(self.CODE_RESET)
|
|
else:
|
|
result_lines.append(f"{self.CODE_BG}{self.CODE_FG}")
|
|
result_lines.append(self.LITERAL_START)
|
|
result_lines.append(self._escape_literals(code_content))
|
|
result_lines.append(self.LITERAL_END)
|
|
result_lines.append(self.CODE_RESET)
|
|
|
|
code_buffer = []
|
|
|
|
for line in lines:
|
|
is_fence, lang_hint = self._detect_code_fence(line)
|
|
|
|
if line.startswith("-") and not line.startswith("---") and not line.startswith("- "): line = f"\\{line}"
|
|
if line.startswith(">"): line = f"\\{line}"
|
|
if line.startswith("<"): line = f"\\{line}"
|
|
|
|
if is_fence:
|
|
# Flush any pending structures before code fence
|
|
flush_quote_buffer()
|
|
flush_table_buffer()
|
|
|
|
if not in_code_block:
|
|
# Opening fence, start buffering
|
|
in_code_block = True
|
|
code_block_lang = lang_hint.strip() if lang_hint else None
|
|
code_buffer = []
|
|
|
|
else:
|
|
# Closing fence, flush highlighted code
|
|
flush_code_block()
|
|
in_code_block = False
|
|
code_block_lang = None
|
|
|
|
else:
|
|
# Buffer code lines for later highlighting
|
|
if in_code_block: code_buffer.append(line)
|
|
else:
|
|
quote_match = self.QUOTE_RE.match(line)
|
|
if quote_match:
|
|
if not in_quote:
|
|
flush_table_buffer()
|
|
in_quote = True
|
|
quote_buffer = []
|
|
|
|
quote_buffer.append(quote_match.group(1))
|
|
|
|
else:
|
|
if in_quote:
|
|
flush_quote_buffer()
|
|
if line.strip() != "":
|
|
if self._is_table_row(line):
|
|
in_table = True
|
|
table_buffer = [line]
|
|
|
|
else:
|
|
formatted = self.format_line(line)
|
|
result_lines.append(formatted)
|
|
|
|
# Pass through blank line as separator
|
|
else: result_lines.append("")
|
|
|
|
else:
|
|
if self._is_table_row(line):
|
|
if not in_table:
|
|
in_table = True
|
|
table_buffer = [line]
|
|
|
|
else: table_buffer.append(line)
|
|
|
|
else:
|
|
# Line breaks table, flush buffer
|
|
if in_table: flush_table_buffer()
|
|
formatted = self.format_line(line)
|
|
result_lines.append(formatted)
|
|
|
|
# Handle unclosed structures
|
|
if in_quote: flush_quote_buffer()
|
|
if in_table: flush_table_buffer()
|
|
if in_code_block: flush_code_block()
|
|
|
|
return '\n'.join(result_lines)
|
|
|
|
def format_line(self, line, mode="normal"):
|
|
if mode == "codeblock": return self._escape_literals(line)
|
|
|
|
if self.HORIZONTAL_RULE_RE.match(line): return self._format_horizontal_rule()
|
|
|
|
header_match = self.HEADER_RE.match(line)
|
|
if header_match: return self._format_header(header_match)
|
|
|
|
list_match = self.UNORDERED_LIST_RE.match(line)
|
|
if list_match: return self._format_list_item(list_match)
|
|
|
|
line = self._format_inline(line)
|
|
|
|
return line
|
|
|
|
def _format_inline(self, text):
|
|
code_blocks = []
|
|
def extract_code(match):
|
|
code_blocks.append(match.group(1))
|
|
return f"\x00CODE{len(code_blocks)-1}\x00"
|
|
|
|
links = []
|
|
def extract_link(match):
|
|
links.append((match.group(1), match.group(2)))
|
|
return f"\x00LINK{len(links)-1}\x00"
|
|
|
|
text = self.LINK_RE.sub(extract_link, text)
|
|
text = self.INLINE_CODE_RE.sub(extract_code, text)
|
|
text = self.BOLD_RE.sub(self._bold_sub, text)
|
|
text = self.ITALIC_RE.sub(self._italic_sub, text)
|
|
|
|
def restore_link(match):
|
|
idx = int(match.group(1))
|
|
text, url = links[idx]
|
|
|
|
anchor_components = url.split("#")
|
|
url = anchor_components[0]
|
|
anchor = anchor_components[1] if len(anchor_components) > 1 else ""
|
|
|
|
if not ":/" in url:
|
|
url = f"{self.local_url_scope}{url}"
|
|
if anchor: url = f"{url}|anchor={anchor}"
|
|
|
|
text = text.replace('`', '')
|
|
return f"`!`[{text}`{url}]`!"
|
|
|
|
text = re.sub(r'\x00LINK(\d+)\x00', restore_link, text)
|
|
|
|
def restore_code(match):
|
|
idx = int(match.group(1))
|
|
content = code_blocks[idx]
|
|
content = content.replace('`', '\\`')
|
|
return f"{self.CODE_BG_INLINE}{self.CODE_FG}{content}{self.CODE_RESET}"
|
|
|
|
text = re.sub(r'\x00CODE(\d+)\x00', restore_code, text)
|
|
return text
|
|
|
|
def _highlight_inline_code(self, content):
|
|
if not self.syntax_highlighter: return None
|
|
return self.syntax_highlighter.highlight(content, language=None)
|
|
|
|
def _bold_sub(self, match):
|
|
content = match.group(1) or match.group(2)
|
|
return f"{self.BOLD}{content}{self.BOLD_END}"
|
|
|
|
def _italic_sub(self, match):
|
|
content = match.group(1) or match.group(2)
|
|
return f"{self.ITALIC}{content}{self.ITALIC_END}"
|
|
|
|
def _format_header(self, match):
|
|
hashes = match.group(1)
|
|
content = match.group(2)
|
|
level = len(hashes)
|
|
prefix = ">" * min(level, 6)
|
|
return f"{prefix}{self._format_inline(content)}"
|
|
|
|
def _format_list_item(self, match):
|
|
indent = match.group(1)
|
|
content = match.group(3)
|
|
content = self._format_inline(content)
|
|
return f"{indent} {self.BULLET} {content}"
|
|
|
|
def _format_horizontal_rule(self):
|
|
return "-"
|
|
|
|
def _detect_code_fence(self, line):
|
|
match = self.CODE_FENCE_RE.match(line)
|
|
if match:
|
|
# match.group(2) contains everything after the backticks (language hint)
|
|
return True, match.group(2)
|
|
return False, ""
|
|
|
|
def _is_table_row(self, line):
|
|
if '|' not in line: return False
|
|
match = self.TABLE_ROW_RE.match(line)
|
|
if match is None: return False
|
|
content = match.group(1)
|
|
return '|' in content or line.strip().startswith('|')
|
|
|
|
def _is_table_separator(self, line):
|
|
if '|' not in line: return False
|
|
match = self.TABLE_SEP_RE.match(line)
|
|
return match is not None
|
|
|
|
def _escape_literals(self, text):
|
|
return text.replace('`', '\\`')
|
|
|
|
def format_table(self, rows, align="c"):
|
|
if len(rows) < 2: return rows
|
|
|
|
# Parse header and separator
|
|
header_cells = self._parse_table_row(rows[0])
|
|
alignments = self._parse_table_alignments(rows[1])
|
|
|
|
# Ensure alignment count matches header cells
|
|
while len(alignments) < len(header_cells): alignments.append('left')
|
|
alignments = alignments[:len(header_cells)]
|
|
|
|
# Parse data rows
|
|
data_rows = []
|
|
for i in range(2, len(rows)):
|
|
cells = self._parse_table_row(rows[i])
|
|
while len(cells) < len(header_cells): cells.append("")
|
|
cells = cells[:len(header_cells)]
|
|
data_rows.append(cells)
|
|
|
|
# Calculate column widths based on content
|
|
num_cols = len(header_cells)
|
|
col_widths = [0] * num_cols
|
|
|
|
all_rows = [header_cells] + data_rows
|
|
for row in all_rows:
|
|
for i, cell in enumerate(row):
|
|
formatted = self._format_inline(cell)
|
|
width = self._visible_width(formatted)
|
|
col_widths[i] = max(col_widths[i], width)
|
|
|
|
# Apply minimum width and calculate total
|
|
col_widths = [max(w, self.TABLE_MIN_COL_WIDTH) for w in col_widths]
|
|
|
|
# Check max_width constraint
|
|
# Total = sum of columns + 3 chars per column (space + 2 borders) + 1 for final border
|
|
total_width = sum(col_widths) + (num_cols * 3) + 1
|
|
|
|
if total_width > self.max_width:
|
|
# Reduce widest columns proportionally
|
|
excess = total_width - self.max_width
|
|
indexed_widths = [(i, w) for i, w in enumerate(col_widths)]
|
|
indexed_widths.sort(key=lambda x: -x[1])
|
|
|
|
for i, w in indexed_widths:
|
|
if excess <= 0: break
|
|
reduction = min(excess, w - self.TABLE_MIN_COL_WIDTH)
|
|
col_widths[i] -= reduction
|
|
excess -= reduction
|
|
|
|
# Build formatted table
|
|
result = []
|
|
|
|
# Alignment start
|
|
if align: result.append(f"`{align}")
|
|
|
|
# Top border
|
|
border = self.TABLE_TL
|
|
for i, w in enumerate(col_widths):
|
|
border += self.TABLE_H * (w + 2)
|
|
if i < len(col_widths) - 1: border += self.TABLE_TM
|
|
else: border += self.TABLE_TR
|
|
|
|
result.append(self._escape_literals(border))
|
|
|
|
# Header row
|
|
header_line = self.TABLE_V
|
|
for i, cell in enumerate(header_cells):
|
|
formatted = self._format_inline(cell)
|
|
padded = self._pad_cell(formatted, col_widths[i], 'left')
|
|
header_line += f" {padded} {self.TABLE_V}"
|
|
result.append(self._escape_literals(header_line))
|
|
|
|
# Separator row
|
|
sep_line = self.TABLE_ML
|
|
for i, w in enumerate(col_widths):
|
|
cell_width = w + 2
|
|
sep_line += self.TABLE_H * cell_width
|
|
|
|
if i < len(col_widths) - 1: sep_line += self.TABLE_MM
|
|
else: sep_line += self.TABLE_MR
|
|
|
|
result.append(self._escape_literals(sep_line))
|
|
|
|
# Data rows
|
|
for row in data_rows:
|
|
row_line = self.TABLE_V
|
|
for i, cell in enumerate(row):
|
|
formatted = self._format_inline(cell)
|
|
padded = self._pad_cell(formatted, col_widths[i], alignments[i])
|
|
row_line += f" {padded} {self.TABLE_V}"
|
|
|
|
result.append(row_line)
|
|
|
|
# Bottom border
|
|
border = self.TABLE_BL
|
|
for i, w in enumerate(col_widths):
|
|
border += self.TABLE_H * (w + 2)
|
|
if i < len(col_widths) - 1: border += self.TABLE_BM
|
|
else: border += self.TABLE_BR
|
|
|
|
result.append(self._escape_literals(border))
|
|
|
|
# End alignment
|
|
if align: result.append("`a")
|
|
|
|
return result
|
|
|
|
def format_table_raw(self, rows, align="c"):
|
|
if len(rows) < 2: return rows
|
|
|
|
# Parse header and separator
|
|
header_cells = self._parse_table_row(rows[0])
|
|
alignments = self._parse_table_alignments(rows[1])
|
|
|
|
# Ensure alignment count matches header cells
|
|
while len(alignments) < len(header_cells): alignments.append('left')
|
|
alignments = alignments[:len(header_cells)]
|
|
|
|
# Parse data rows
|
|
data_rows = []
|
|
for i in range(2, len(rows)):
|
|
cells = self._parse_table_row(rows[i])
|
|
while len(cells) < len(header_cells): cells.append("")
|
|
cells = cells[:len(header_cells)]
|
|
data_rows.append(cells)
|
|
|
|
# Calculate column widths based on raw content
|
|
num_cols = len(header_cells)
|
|
col_widths = [0] * num_cols
|
|
|
|
all_rows = [header_cells] + data_rows
|
|
for row in all_rows:
|
|
for i, cell in enumerate(row):
|
|
width = self._visible_width(cell)
|
|
col_widths[i] = max(col_widths[i], width)
|
|
|
|
# Apply minimum width and calculate total
|
|
col_widths = [max(w, self.TABLE_MIN_COL_WIDTH) for w in col_widths]
|
|
|
|
# Check max_width constraint
|
|
total_width = sum(col_widths) + (num_cols * 3) + 1
|
|
|
|
if total_width > self.max_width:
|
|
# Reduce widest columns proportionally
|
|
excess = total_width - self.max_width
|
|
indexed_widths = [(i, w) for i, w in enumerate(col_widths)]
|
|
indexed_widths.sort(key=lambda x: -x[1])
|
|
|
|
for i, w in indexed_widths:
|
|
if excess <= 0: break
|
|
reduction = min(excess, w - self.TABLE_MIN_COL_WIDTH)
|
|
col_widths[i] -= reduction
|
|
excess -= reduction
|
|
|
|
# Build formatted table
|
|
result = []
|
|
|
|
# Alignment start
|
|
if align: result.append(f"`{align}")
|
|
|
|
# Top border
|
|
border = self.TABLE_TL
|
|
for i, w in enumerate(col_widths):
|
|
border += self.TABLE_H * (w + 2)
|
|
if i < len(col_widths) - 1: border += self.TABLE_TM
|
|
else: border += self.TABLE_TR
|
|
|
|
result.append(self._escape_literals(border))
|
|
|
|
# Header row
|
|
header_line = self.TABLE_V
|
|
for i, cell in enumerate(header_cells):
|
|
padded = self._pad_cell(cell, col_widths[i], 'left')
|
|
header_line += f" {padded} {self.TABLE_V}"
|
|
result.append(header_line)
|
|
|
|
# Separator row - clean horizontal lines without alignment markers
|
|
sep_line = self.TABLE_ML
|
|
for i, w in enumerate(col_widths):
|
|
cell_width = w + 2
|
|
sep_line += self.TABLE_H * cell_width
|
|
|
|
if i < len(col_widths) - 1: sep_line += self.TABLE_MM
|
|
else: sep_line += self.TABLE_MR
|
|
|
|
result.append(self._escape_literals(sep_line))
|
|
|
|
# Data rows (with alignment)
|
|
for row in data_rows:
|
|
row_line = self.TABLE_V
|
|
for i, cell in enumerate(row):
|
|
padded = self._pad_cell(cell, col_widths[i], alignments[i])
|
|
row_line += f" {padded} {self.TABLE_V}"
|
|
|
|
result.append(row_line)
|
|
|
|
# Bottom border
|
|
border = self.TABLE_BL
|
|
for i, w in enumerate(col_widths):
|
|
border += self.TABLE_H * (w + 2)
|
|
if i < len(col_widths) - 1: border += self.TABLE_BM
|
|
else: border += self.TABLE_BR
|
|
|
|
result.append(self._escape_literals(border))
|
|
|
|
# End alignment
|
|
if align: result.append("`a")
|
|
|
|
return result
|
|
|
|
def _parse_table_row(self, line):
|
|
line = line.strip()
|
|
if line.startswith('|'): line = line[1:]
|
|
if line.endswith('|'): line = line[:-1]
|
|
|
|
cells = []
|
|
current = ""
|
|
escaped = False
|
|
for char in line:
|
|
if escaped:
|
|
current += char
|
|
escaped = False
|
|
elif char == '\\':
|
|
escaped = True
|
|
elif char == '|':
|
|
cells.append(current.strip())
|
|
current = ""
|
|
else:
|
|
current += char
|
|
|
|
cells.append(current.strip())
|
|
return cells
|
|
|
|
def _parse_table_alignments(self, line):
|
|
cells = self._parse_table_row(line)
|
|
alignments = []
|
|
for cell in cells:
|
|
cell = cell.strip()
|
|
if cell.startswith(':') and cell.endswith(':'): alignments.append('center')
|
|
elif cell.endswith(':'): alignments.append('right')
|
|
else: alignments.append('left')
|
|
|
|
return alignments
|
|
|
|
def _visible_width(self, text):
|
|
text = re.sub(r'`[FB][0-9a-fA-F]{3}', '', text)
|
|
text = re.sub(r'`[FB]T[0-9a-fA-F]{6}', '', text)
|
|
text = re.sub(r'`[!*_=]', '', text)
|
|
text = re.sub(r'`f`b', '', text)
|
|
text = re.sub(r'`f', '', text)
|
|
text = re.sub(r'`b', '', text)
|
|
return self.display_width(text)
|
|
|
|
def _pad_cell(self, text, width, align):
|
|
text = self._truncate_cell(text, width)
|
|
text_width = self._visible_width(text)
|
|
padding = width - text_width
|
|
|
|
if align == 'right':
|
|
return " " * padding + text
|
|
elif align == 'center':
|
|
left = padding // 2
|
|
right = padding - left
|
|
return " " * left + text + " " * right
|
|
else:
|
|
return text + " " * padding
|
|
|
|
def _truncate_cell(self, text, width):
|
|
if self._visible_width(text) <= width: return text
|
|
|
|
stripped = text
|
|
stripped = re.sub(r'`[FB][0-9a-fA-F]{3}', '', stripped)
|
|
stripped = re.sub(r'`[!*_]', '', stripped)
|
|
stripped = re.sub(r'`f`b', '', stripped)
|
|
|
|
if len(stripped) <= width - 1: return text
|
|
|
|
truncated = stripped[:width - 1] + "…"
|
|
return truncated
|
|
|
|
def _wrap_text(self, text, width):
|
|
if not text: return [""]
|
|
|
|
words = text.split(' ')
|
|
lines = []
|
|
current_line = ""
|
|
current_width = 0
|
|
|
|
for word in words:
|
|
if not word: continue
|
|
|
|
word_width = self._visible_width(word)
|
|
|
|
# Check if word alone exceeds width to force break it
|
|
if word_width > width:
|
|
if current_line:
|
|
lines.append(current_line)
|
|
current_line = ""
|
|
current_width = 0
|
|
|
|
# Force break the long word character by character
|
|
remaining = word
|
|
while remaining:
|
|
# Binary search for how many characters fit
|
|
low, high = 1, len(remaining)
|
|
fit_chars = 0
|
|
|
|
while low <= high:
|
|
mid = (low + high) // 2
|
|
test_substr = remaining[:mid]
|
|
test_width = self._visible_width(test_substr)
|
|
|
|
if test_width <= width:
|
|
fit_chars = mid
|
|
low = mid + 1
|
|
else:
|
|
high = mid - 1
|
|
|
|
if fit_chars == 0: fit_chars = 1 # Need to force progress
|
|
|
|
lines.append(remaining[:fit_chars])
|
|
remaining = remaining[fit_chars:]
|
|
|
|
continue
|
|
|
|
# Check if word fits on current line
|
|
space_width = 1 if current_line else 0
|
|
if current_width + space_width + word_width <= width:
|
|
if current_line:
|
|
current_line += " " + word
|
|
current_width += space_width + word_width
|
|
else:
|
|
current_line = word
|
|
current_width = word_width
|
|
else:
|
|
# Flush current line and start new one
|
|
lines.append(current_line)
|
|
current_line = word
|
|
current_width = word_width
|
|
|
|
# Don't forget the last line
|
|
if current_line: lines.append(current_line)
|
|
|
|
return lines if lines else [""]
|
|
|
|
|
|
def convert_markdown_to_micron(text):
|
|
converter = MarkdownToMicron()
|
|
return converter.format_block(text)
|