diff --git a/lib/blankshell.rb b/lib/mmmd/blankshell.rb similarity index 100% rename from lib/blankshell.rb rename to lib/mmmd/blankshell.rb diff --git a/lib/rbmark/renderers.rb b/lib/mmmd/renderers.rb similarity index 100% rename from lib/rbmark/renderers.rb rename to lib/mmmd/renderers.rb diff --git a/lib/rbmark/renderers/html.rb b/lib/mmmd/renderers/html.rb similarity index 98% rename from lib/rbmark/renderers/html.rb rename to lib/mmmd/renderers/html.rb index de8dd2c..8503572 100644 --- a/lib/rbmark/renderers/html.rb +++ b/lib/mmmd/renderers/html.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -require 'rbmark' - -module RBMark +module MMMD module Renderers # HTML Renderer class HTML diff --git a/lib/mmmd/renderers/plainterm.rb b/lib/mmmd/renderers/plainterm.rb new file mode 100644 index 0000000..27dec04 --- /dev/null +++ b/lib/mmmd/renderers/plainterm.rb @@ -0,0 +1,449 @@ +# 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 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 + } + }.freeze + + DEFAULT_EFFECT_PRIORITY = { + 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 = 80 - (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 diff --git a/lib/rubymark b/lib/rubymark new file mode 120000 index 0000000..5f5df8f --- /dev/null +++ b/lib/rubymark @@ -0,0 +1 @@ +mmmd \ No newline at end of file diff --git a/mmmdpp.rb b/mmmdpp.rb index b3bb345..2cc7043 100644 --- a/mmmdpp.rb +++ b/mmmdpp.rb @@ -1,386 +1,3 @@ -#!/usr/bin/ruby -# frozen_string_literal: true - -require_relative 'lib/blankshell' -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(' ') - end - output.append(line.lstrip) - newtext = output.join("\n") - newtext - 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 - end - - DEFAULT_STYLE = { - "PointBlank::DOM::Paragraph" => { - "inline" => true, - "indent" => true - }, - "PointBlank::DOM::Text" => { - "inline" => true - }, - "PointBlank::DOM::SetextHeading1" => { - "inline" => true, - "center" => true, - "bold" => true, - "extra_newlines" => true, - "underline_full_block" => true - }, - "PointBlank::DOM::SetextHeading2" => { - "inline" => true, - "center" => true, - "underline_block" => true - }, - "PointBlank::DOM::ATXHeading1" => { - "inline" => true, - "center" => true, - "bold" => true, - "extra_newlines" => true, - "underline_full_block" => true - }, - "PointBlank::DOM::ATXHeading2" => { - "inline" => true, - "center" => true, - "underline_block" => true - }, - "PointBlank::DOM::ATXHeading3" => { - "inline" => true, - "underline" => true, - "bold" => true - }, - "PointBlank::DOM::ATXHeading4" => { - "inline" => true, - "bold" => true, - "underline" => true - }, - "PointBlank::DOM::ATXHeading5" => { - "inline" => true, - "underline" => true, - }, - "PointBlank::DOM::ATXHeading6" => { - "inline" => true, - "underline" => true - }, - "PointBlank::DOM::InlineImage" => { - "inline" => true - }, - "PointBlank::DOM::InlineLink" => { - "inline" => true - }, - "PointBlank::DOM::InlinePre" => { - "inline" => true - }, - "PointBlank::DOM::InlineEmphasis" => { - "inline" => true, - "italics" => true - }, - "PointBlank::DOM::InlineStrong" => { - "inline" => true, - "bold" => true - }, - "PointBlank::DOM::ULBlock" => { - "bullet" => true - }, - "PointBlank::DOM::OLBlock" => { - "numbered" => 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], - ["extra_newlines", false], - ["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 = PointBlank::DOM::Document.parse(input) - @color_mode = options.fetch("color", true) - @ansi_mode = options.fetch("ansi", true) - @style = ::MDPP::DEFAULT_STYLE.dup - 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 - _render(@doc.children, @doc.properties) - end - - private - - def _render(children, props) - blocks = children.map do |child| - if child.is_a? ::PointBlank::DOM::Text or - child.is_a? ::PointBlank::DOM::CodeBlock - child.content - elsif child.is_a? ::PointBlank::DOM::InlineBreak - "\n" - 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 - 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 if __FILE__ == $0 text = $stdin.read diff --git a/view_structure.rb b/view_structure.rb index 079dd2f..8196dbd 100644 --- a/view_structure.rb +++ b/view_structure.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative 'lib/blankshell.rb' +require_relative 'lib/mmmd/blankshell.rb' structure = PointBlank::DOM::Document.parse(File.read(ARGV[0])) def red(string)