480 lines
12 KiB
Ruby
Executable File
480 lines
12 KiB
Ruby
Executable File
#!/usr/bin/ruby
|
|
# frozen_string_literal: true
|
|
|
|
require 'optparse'
|
|
require 'rbmark'
|
|
require 'io/console'
|
|
require 'io/console/size'
|
|
|
|
module MDPP
|
|
# Module for managing terminal output
|
|
module TextManager
|
|
# ANSI SGR escape code for bg color
|
|
# @param text [String]
|
|
# @param properties [Hash]
|
|
# @return [String]
|
|
def bg(text, properties)
|
|
color = properties['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 properties [Hash]
|
|
# @return [String]
|
|
def fg(text, properties)
|
|
color = properties['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]
|
|
# @return [String]
|
|
def bold(text)
|
|
"\e[1m#{text}\e[22m"
|
|
end
|
|
|
|
# ANSI SGR escape code for italics text
|
|
# @param text [String]
|
|
# @return [String]
|
|
def italics(text)
|
|
"\e[3m#{text}\e[23m"
|
|
end
|
|
|
|
# ANSI SGR escape code for underline text
|
|
# @param text [String]
|
|
# @return [String]
|
|
def underline(text)
|
|
"\e[4m#{text}\e[24m"
|
|
end
|
|
|
|
# ANSI SGR escape code for strikethrough text
|
|
# @param text [String]
|
|
# @return [String]
|
|
def strikethrough(text)
|
|
"\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 = ""
|
|
until words.empty?
|
|
word = words.shift
|
|
if word.length > width
|
|
words.prepend(word[width..])
|
|
word = word[..width - 1]
|
|
end
|
|
if line.length + word.length + 1 > width
|
|
output.append(line.lstrip)
|
|
line = word
|
|
next
|
|
end
|
|
line = [line, word].join(line.end_with?("\n") ? '' : ' ')
|
|
end
|
|
output.append(line.lstrip)
|
|
output.join("\n")
|
|
end
|
|
|
|
# Draw a screen-width box around text
|
|
# @param text [String]
|
|
# @param center_margins [Integer]
|
|
# @return [String]
|
|
def box(text)
|
|
size = IO.console.winsize[1] - 2
|
|
text = wordwrap(text, (size * 0.8).floor).lines.filter_map do |line|
|
|
"│#{line.strip.ljust(size)}│" unless line.empty?
|
|
end.join("\n")
|
|
<<~TEXT
|
|
╭#{'─' * size}╮
|
|
#{text}
|
|
╰#{'─' * size}╯
|
|
TEXT
|
|
end
|
|
|
|
# Draw text right-justified
|
|
def rjust(text)
|
|
size = IO.console.winsize[1]
|
|
wordwrap(text, (size * 0.8).floor).lines.filter_map do |line|
|
|
line.strip.rjust(size) unless line.empty?
|
|
end.join("\n")
|
|
end
|
|
|
|
# Draw text centered
|
|
def center(text)
|
|
size = IO.console.winsize[1]
|
|
wordwrap(text, (size * 0.8).floor).lines.filter_map do |line|
|
|
line.strip.center(size) unless line.empty?
|
|
end.join("\n")
|
|
end
|
|
|
|
# Underline the last line of the text piece
|
|
def underline_block(text)
|
|
textlines = text.lines
|
|
last = "".match(/()()()/)
|
|
textlines.each do |x|
|
|
current = x.match(/\A(\s*)(.+?)(\s*)\Z/)
|
|
last = current if current[2].length > last[2].length
|
|
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), rtxt].join('')
|
|
textlines.join("")
|
|
end
|
|
|
|
# Add extra newlines around the text
|
|
def extra_newlines(text)
|
|
size = IO.console.winsize[1]
|
|
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)
|
|
textlines = text.lines
|
|
textlines[-1] = underline(textlines.last)
|
|
textlines.join("")
|
|
end
|
|
|
|
# Indent all lines
|
|
def indent(text, properties)
|
|
_indent(text, level: properties['level'])
|
|
end
|
|
|
|
# Indent all lines (inner)
|
|
def _indent(text, **_useless)
|
|
text.lines.map do |line|
|
|
" #{line}"
|
|
end.join("")
|
|
end
|
|
|
|
# Bulletpoints
|
|
def bullet(text, _number, properties)
|
|
level = properties['level']
|
|
"-#{_indent(text, level: level)[1..]}"
|
|
end
|
|
|
|
# Numbers
|
|
def numbered(text, number, properties)
|
|
level = properties['level']
|
|
"#{number}.#{_indent(text, level: level)[number.to_s.length + 1..]}"
|
|
end
|
|
|
|
# Sideline for quotes
|
|
def sideline(text)
|
|
text.lines.map do |line|
|
|
"│ #{line}"
|
|
end.join("")
|
|
end
|
|
|
|
# Long bracket for code blocks
|
|
def longbracket(text, properties)
|
|
textlines = text.lines
|
|
textlines = textlines.map do |line|
|
|
"│ #{line}"
|
|
end
|
|
textlines.prepend("┌ (#{properties['element'][:language]})\n")
|
|
textlines.append("\n└\n")
|
|
textlines.join("")
|
|
end
|
|
|
|
# Add text to bibliography
|
|
def bibliography(text, properties)
|
|
return "#{text}[#{properties['element'][:link]}]" if @options['nb']
|
|
|
|
@bibliography.append([text, properties['element'][:link]])
|
|
"#{text}[#{@bibliography.length + 1}]"
|
|
end
|
|
end
|
|
|
|
DEFAULT_STYLE = {
|
|
"RBMark::DOM::Paragraph" => {
|
|
"inline" => true,
|
|
"indent" => true
|
|
},
|
|
"RBMark::DOM::Text" => {
|
|
"inline" => true
|
|
},
|
|
"RBMark::DOM::Heading1" => {
|
|
"inline" => true,
|
|
"center" => true,
|
|
"bold" => true,
|
|
"extra_newlines" => true,
|
|
"underline_full_block" => true
|
|
},
|
|
"RBMark::DOM::Heading2" => {
|
|
"inline" => true,
|
|
"center" => true,
|
|
"underline_block" => true
|
|
},
|
|
"RBMark::DOM::Heading3" => {
|
|
"inline" => true,
|
|
"underline" => true,
|
|
"bold" => true,
|
|
"indent" => true
|
|
},
|
|
"RBMark::DOM::Heading4" => {
|
|
"inline" => true,
|
|
"underline" => true,
|
|
"indent" => true
|
|
},
|
|
"RBMark::DOM::InlineImage" => {
|
|
"bibliography" => true,
|
|
"inline" => true
|
|
},
|
|
"RBMark::DOM::InlineLink" => {
|
|
"bibliography" => true,
|
|
"inline" => true
|
|
},
|
|
"RBMark::DOM::InlinePre" => {
|
|
"inline" => true
|
|
},
|
|
"RBMark::DOM::InlineStrike" => {
|
|
"inline" => true,
|
|
"strikethrough" => true
|
|
},
|
|
"RBMark::DOM::InlineUnder" => {
|
|
"inline" => true,
|
|
"underline" => true
|
|
},
|
|
"RBMark::DOM::InlineItalics" => {
|
|
"inline" => true,
|
|
"italics" => true
|
|
},
|
|
"RBMark::DOM::InlineBold" => {
|
|
"inline" => true,
|
|
"bold" => true
|
|
},
|
|
"RBMark::DOM::QuoteBlock" => {
|
|
"sideline" => true
|
|
},
|
|
"RBMark::DOM::CodeBlock" => {
|
|
"longbracket" => true
|
|
},
|
|
"RBMark::DOM::ULBlock" => {
|
|
"bullet" => true
|
|
},
|
|
"RBMark::DOM::OLBlock" => {
|
|
"numbered" => true
|
|
},
|
|
"RBMark::DOM::HorizontalRule" => {
|
|
"extra_newlines" => true
|
|
},
|
|
"RBMark::DOM::IndentBlock" => {
|
|
"indent" => true
|
|
}
|
|
}.freeze
|
|
|
|
STYLE_PRIO0 = [
|
|
["numbered", true],
|
|
["bullet", true]
|
|
].freeze
|
|
|
|
STYLE_PRIO1 = [
|
|
["center", false],
|
|
["rjust", false],
|
|
["box", false],
|
|
["indent", true],
|
|
["underline", false],
|
|
["bold", false],
|
|
["italics", false],
|
|
["strikethrough", false],
|
|
["bg", true],
|
|
["fg", true],
|
|
["bibliography", true],
|
|
["extra_newlines", false],
|
|
["sideline", false],
|
|
["longbracket", true],
|
|
["underline_block", false],
|
|
["underline_full_block", false]
|
|
].freeze
|
|
|
|
# Primary document renderer
|
|
class Renderer
|
|
include ::MDPP::TextManager
|
|
|
|
# @param input [String]
|
|
# @param options [Hash]
|
|
def initialize(input, options)
|
|
@doc = RBMark::DOM::Document.parse(input)
|
|
@style = ::MDPP::DEFAULT_STYLE.dup
|
|
@bibliography = []
|
|
@options = options
|
|
return unless options['style']
|
|
|
|
@style = @style.map do |k, v|
|
|
v = v.merge(**options['style'][k]) if options['style'][k]
|
|
[k, v]
|
|
end.to_h
|
|
end
|
|
|
|
# Return rendered text
|
|
# @return [String]
|
|
def render
|
|
text = _render(@doc.children, @doc.properties)
|
|
text += _render_bibliography unless @bibliography.empty? or
|
|
@options['nb']
|
|
text
|
|
end
|
|
|
|
private
|
|
|
|
def _render_bibliography
|
|
size = IO.console.winsize[1]
|
|
text = "\n#{'─' * size}\n"
|
|
text += @bibliography.map.with_index do |element, index|
|
|
"- [#{index + 1}] #{wordwrap(element.join(': '), size - 15)}"
|
|
end.join("\n")
|
|
text
|
|
end
|
|
|
|
def _render(children, props)
|
|
blocks = children.map do |child|
|
|
case child
|
|
when ::RBMark::DOM::Text then child.content
|
|
when ::RBMark::DOM::InlineBreak then "\n"
|
|
when ::RBMark::DOM::HorizontalRule
|
|
size = IO.console.winsize[1]
|
|
"─" * size
|
|
else
|
|
child_props = get_props(child, props)
|
|
calc_wordwrap(
|
|
_render(child.children,
|
|
child_props),
|
|
props, child_props
|
|
)
|
|
end
|
|
end
|
|
apply_props(blocks, props)
|
|
end
|
|
|
|
def calc_wordwrap(obj, props, obj_props)
|
|
size = IO.console.winsize[1]
|
|
return obj if obj_props['center'] or
|
|
obj_props['rjust']
|
|
|
|
if !props['inline'] and obj_props['inline']
|
|
wordwrap(obj, size - 2 * (props['level'].to_i + 1))
|
|
else
|
|
obj
|
|
end
|
|
end
|
|
|
|
def get_props(obj, props)
|
|
new_props = @style[obj.class.to_s].dup || {}
|
|
if props["level"]
|
|
new_props["level"] = props["level"]
|
|
new_props["level"] += 1 unless new_props["inline"]
|
|
else
|
|
new_props["level"] = 2
|
|
end
|
|
new_props["element"] = obj.properties
|
|
new_props
|
|
end
|
|
|
|
def apply_props(blockarray, properties)
|
|
blockarray = prio0(blockarray, properties)
|
|
text = blockarray.join(properties['inline'] ? "" : "\n\n")
|
|
.gsub(/\n{2,}/, "\n\n")
|
|
prio1(text, properties)
|
|
end
|
|
|
|
def prio0(blocks, props)
|
|
::MDPP::STYLE_PRIO0.filter { |x| props.include? x[0] }.each do |style|
|
|
blocks = blocks.map.with_index do |block, index|
|
|
if style[1]
|
|
method(style[0].to_s).call(block, index + 1, props)
|
|
else
|
|
method(style[0].to_s).call(block, index + 1)
|
|
end
|
|
end
|
|
end
|
|
blocks
|
|
end
|
|
|
|
def prio1(block, props)
|
|
::MDPP::STYLE_PRIO1.filter { |x| props.include? x[0] }.each do |style|
|
|
block = if style[1]
|
|
method(style[0].to_s).call(block, props)
|
|
else
|
|
method(style[0].to_s).call(block)
|
|
end
|
|
end
|
|
block
|
|
end
|
|
end
|
|
end
|
|
|
|
options = {}
|
|
OptionParser.new do |opts|
|
|
opts.banner = <<~TEXT
|
|
MDPP - Markdown PrettyPrint based on RBMark parser
|
|
Usage: mdpp [options] <file | ->
|
|
TEXT
|
|
|
|
opts.on("-h", "--help", "Prints this help message") do
|
|
puts opts
|
|
exit 0
|
|
end
|
|
|
|
opts.on("-e", "--extension EXTENSION",
|
|
"require EXTENSION before parsing") do |libname|
|
|
require libname
|
|
end
|
|
|
|
opts.on(
|
|
"-c",
|
|
"--config CONFIG",
|
|
"try to load CONFIG (~/.config/mdpp.rb is loaded by default)"
|
|
) do |config|
|
|
# rubocop:disable Security/Eval
|
|
options.merge!(eval(File.read(config))) if File.exist?(config)
|
|
# rubocop:enable Security/Eval
|
|
end
|
|
|
|
opts.on(
|
|
"-b",
|
|
"--no-bibliography",
|
|
"Do not print bibliography (links, references, etc.) at the bottom"
|
|
) do
|
|
options["nb"] = true
|
|
end
|
|
end.parse!
|
|
|
|
# rubocop:disable Security/Eval
|
|
if File.exist?("#{ENV['HOME']}/.config/mdpp.rb")
|
|
options.merge!(eval(File.read("#{ENV['HOME']}/.config/mdpp.rb")))
|
|
end
|
|
# rubocop:enable Security/Eval
|
|
|
|
text = if ARGV[0].nil? or ARGV[0] == "-"
|
|
$stdin.read
|
|
else
|
|
File.read(ARGV[0])
|
|
end
|
|
renderer = MDPP::Renderer.new(text, options)
|
|
puts renderer.render
|