rubymark/lib/mmmd/renderers/plainterm.rb

460 lines
13 KiB
Ruby

# frozen_string_literal: true
# Attempt to source a provider for the wide char width calculator
# (TODO)
module MMMD
# Module for managing terminal output
module TextManager
# ANSI SGR escape code for bg color
# @param text [String]
# @param options [Hash]
# @return [String]
def bg(text, options)
color = options['bg']
if color.is_a? Integer
"\e[48;5;#{color}m#{text}\e[49m"
elsif color.is_a? String and color.match?(/\A#[A-Fa-f0-9]{6}\Z/)
vector = color.scan(/[A-Fa-f0-9]{2}/).map { |x| x.to_i(16) }
"\e[48;2;#{vector[0]};#{vector[1]};#{vector[2]}\e[49m"
else
Kernel.warn "WARNING: Invalid color - #{color}"
text
end
end
# ANSI SGR escape code for fg color
# @param text [String]
# @param options [Hash]
# @return [String]
def fg(text, options)
color = options['fg']
if color.is_a? Integer
"\e[38;5;#{color}m#{text}\e[39m"
elsif color.is_a? String and color.match?(/\A#[A-Fa-f0-9]{6}\Z/)
vector = color.scan(/[A-Fa-f0-9]{2}/).map { |x| x.to_i(16) }
"\e[38;2;#{vector[0]};#{vector[1]};#{vector[2]}\e[39m"
else
Kernel.warn "WARNING: Invalid color - #{color}"
text
end
end
# ANSI SGR escape code for bold text
# @param text [String]
# @param options [Hash]
# @return [String]
def bold(text, _options)
"\e[1m#{text}\e[22m"
end
# ANSI SGR escape code for italics text
# @param text [String]
# @param options [Hash]
# @return [String]
def italics(text, _options)
"\e[3m#{text}\e[23m"
end
# ANSI SGR escape code for underline text
# @param text [String]
# @param options [Hash]
# @return [String]
def underline(text, _options)
"\e[4m#{text}\e[24m"
end
# ANSI SGR escape code for strikethrough text
# @param text [String]
# @param options [Hash]
# @return [String]
def strikethrough(text, _options)
"\e[9m#{text}\e[29m"
end
# Word wrapping algorithm
# @param text [String]
# @param width [Integer]
# @return [String]
def wordwrap(text, width)
words = text.split(/( +)/)
output = []
line = ""
length = 0
until words.empty?
word = words.shift
wordlength = smort_length(word)
if wordlength > width
words.prepend(word[width..])
word = word[..width - 1]
end
if length + wordlength + 1 > width
output.append(line.lstrip)
line = word
length = wordlength
next
end
length += wordlength
line += word
end
output.append(line.lstrip)
output.join("\n")
end
# (TODO: smorter stronger better faster)
# SmЯt™ word length
# @param text [String]
# @return [Integer]
def smort_length(text)
text.gsub(/\e\[[^m]+m/, '').length
end
# Left-justify a line while ignoring terminal control codes
# @param text [String]
# @param size [Integer]
# @return [String]
def ljust_cc(text, size)
text.lines.map do |line|
textlength = smort_length(line)
textlength < size ? line + " " * (size - textlength) : line
end.join("\n")
end
# Right-justify a line while ignoring terminal control codes
# @param text [String]
# @param size [Integer]
# @return [String]
def rjust_cc(text, size)
text.lines.map do |line|
textlength = smort_length(line)
textlength < size ? " " * (size - textlength) + line : line
end.join("\n")
end
# Center-justify a line while ignoring terminal control codes
# @param text [String]
# @param size [Integer]
# @return [String]
def center_cc(text, size)
text.lines.map do |line|
textlength = smort_length(line)
if textlength < size
freelength = size - textlength
rightlength = freelength / 2
leftlength = freelength - rightlength
" " * leftlength + line + " " * rightlength
else
line
end
end.join("\n")
end
# Draw a screen-width box around text
# @param text [String]
# @param options [Hash]
# @return [String]
def box(text, options)
size = options[:hsize] - 2
text = wordwrap(text, (size * 0.8).floor).lines.filter_map do |line|
"#{ljust_cc(line, size)}" unless line.empty?
end.join("\n")
<<~TEXT
#{'─' * size}╮
#{text}
#{'─' * size}╯
TEXT
end
# Draw a horizontal rule
def hrule(_text, options)
size = options[:hsize]
" #{'─' * (size - 2)} "
end
# Draw text right-justified
def rjust(text, options)
size = options[:hsize]
wordwrap(text, (size * 0.8).floor).lines.filter_map do |line|
rjust_cc(line, size) unless line.empty?
end.join("\n")
end
# Draw text centered
def center(text, options)
size = options[:hsize]
wordwrap(text, (size * 0.8).floor).lines.filter_map do |line|
center_cc(line, size) unless line.empty?
end.join("\n")
end
# Underline the last line of the text piece
def underline_block(text, options)
textlines = text.lines
last = "".match(/()()()/)
textlines.each do |x|
current = x.match(/\A(\s*)(.+?)(\s*)\Z/)
last = current if smort_length(current[2]) > smort_length(last[2])
end
ltxt = last[1]
ctxt = textlines.last.slice(last.offset(2)[0]..last.offset(2)[1] - 1)
rtxt = last[3]
textlines[-1] = [ltxt, underline(ctxt, options), rtxt].join('')
textlines.join("")
end
# Add extra newlines around the text
def extra_newlines(text, options)
size = options[:hsize]
textlines = text.lines
textlines.prepend("#{' ' * size}\n")
textlines.append("\n#{' ' * size}\n")
textlines.join("")
end
# Underline last line edge to edge
def underline_full_block(text, options)
textlines = text.lines
last_line = textlines.last.match(/^.*$/)[0]
textlines[-1] = "#{underline(last_line, options)}\n"
textlines.join("")
end
# Indent all lines
def indent(text, _options)
_indent(text)
end
# Indent all lines (inner)
def _indent(text)
text.lines.map do |line|
" #{line}"
end.join("")
end
# Left overline all lines
def leftline(text, _options)
text.lines.map do |line|
"#{line}"
end.join("")
end
# Bulletpoints
def bullet(text, _options)
"-#{_indent(text)[1..]}"
end
# Numbers
def numbered(text, options)
number = options[:number]
length = number.to_s.length + 1
(length / 4 + 1).times { text = _indent(text) }
"#{number}.#{text[length..]}"
end
end
module Renderers
module PlaintermConstants
DEFAULT_STYLE = {
"PointBlank::DOM::Paragraph" => {
indent: true,
increase_level: true
},
"PointBlank::DOM::Text" => {},
"PointBlank::DOM::SetextHeading1" => {
center: true,
bold: true,
extra_newlines: true,
underline_full_block: true
},
"PointBlank::DOM::SetextHeading2" => {
center: true,
underline_block: true
},
"PointBlank::DOM::ATXHeading1" => {
center: true,
bold: true,
extra_newlines: true,
underline_full_block: true
},
"PointBlank::DOM::ATXHeading2" => {
center: true,
underline_block: true
},
"PointBlank::DOM::ATXHeading3" => {
underline: true,
bold: true
},
"PointBlank::DOM::ATXHeading4" => {
bold: true,
underline: true
},
"PointBlank::DOM::ATXHeading5" => {
underline: true
},
"PointBlank::DOM::ATXHeading6" => {
underline: true
},
"PointBlank::DOM::InlineImage" => {
underline: true
},
"PointBlank::DOM::InlineLink" => {
underline: true
},
"PointBlank::DOM::InlinePre" => {},
"PointBlank::DOM::InlineEmphasis" => {
italics: true
},
"PointBlank::DOM::InlineStrong" => {
bold: true
},
"PointBlank::DOM::ULListElement" => {
bullet: true,
increase_level: true
},
"PointBlank::DOM::OLListElement" => {
numbered: true,
increase_level: true
},
"PointBlank::DOM::QuoteBlock" => {
leftline: true,
increase_level: true
},
"PointBlank::DOM::HorizontalRule" => {
hrule: true
}
}.freeze
DEFAULT_EFFECT_PRIORITY = {
hrule: 10_500,
numbered: 10_000,
leftline: 9500,
bullet: 9000,
indent: 8500,
underline_full_block: 8000,
underline_block: 7500,
extra_newlines: 7000,
center: 6000,
rjust: 5500,
box: 5000,
underline: 4000,
italics: 3500,
bold: 3000,
fg: 2500,
bg: 2000,
strikethrough: 1500
}.freeze
# Class for managing styles and style overrides
class StyleManager
class << self
# Define a default style for specified class
# @param key [String] class name
# @param style [Hash] style
# @return [void]
def define_style(key, style)
@style ||= DEFAULT_STYLE.dup
@style[key] = style
end
# Define an effect priority value
# @param key [String] effect name
# @param priority [Integer] value of the priority
# @return [void]
def define_effect_priority(key, priority)
@effect_priority ||= DEFAULT_EFFECT_PRIORITY.dup
@effect_priority[key] = priority
end
# Get computed style
# @return [Hash]
def style
@style ||= DEFAULT_STYLE.dup
end
# Get computed effect priority
# @return [Hash]
def effect_priority
@effect_priority ||= DEFAULT_EFFECT_PRIORITY.dup
end
end
def initialize(overrides)
@style = self.class.style
@effect_priority = self.class.effect_priority
@style = @style.merge(overrides["style"]) if overrides["style"]
end
attr_reader :style, :effect_priority
end
end
# Primary document renderer
class Plainterm
include ::MMMD::TextManager
# @param input [String]
# @param options [Hash]
def initialize(input, options)
@doc = input
@color_mode = options.fetch("color", true)
@ansi_mode = options.fetch("ansi", true)
style_manager = PlaintermConstants::StyleManager.new(options)
@style = style_manager.style
@effect_priority = style_manager.effect_priority
@effects = @effect_priority.to_a.sort_by(&:last).map(&:first)
@options = options
@options["hsize"] ||= 80
end
# Return rendered text
# @return [String]
def render
_render(@doc, @options)
end
private
def _render(element, options, inline: false, level: 0, index: 0)
modeswitch = element.is_a?(::PointBlank::DOM::LeafBlock) ||
element.is_a?(::PointBlank::DOM::Paragraph)
inline ||= modeswitch
level += calculate_level_increase(element)
text = if element.children.empty?
element.content
else
element.children.map.with_index do |child, index|
_render(child, options, inline: inline,
level: level,
index: index)
end.join(inline ? '' : "\n\n")
end
run_filters(text, element, level: level,
modeswitch: modeswitch,
index: index)
end
def run_filters(text, element, level:, modeswitch:, index:)
element_style = @style[element.class.name]
return text unless element_style
hsize = @options["hsize"] - (4 * level)
text = wordwrap(text, hsize) if modeswitch
params = element_style.dup
params[:hsize] = hsize
params[:number] = index + 1
@effects.each do |effect|
text = method(effect).call(text, params) if element_style[effect]
end
text
end
def calculate_level_increase(element)
level = 0
element_style = @style[element.class.name]
level += 1 if element_style && element_style[:increase_level]
level
end
end
end
end