diff --git a/.github/workflows/ubuntu.yml b/.github/workflows/ubuntu.yml index 3f86c15..e138c66 100644 --- a/.github/workflows/ubuntu.yml +++ b/.github/workflows/ubuntu.yml @@ -41,6 +41,7 @@ jobs: - name: Install requirements on ubuntu run: | + sudo apt update sudo apt install -y --no-install-recommends \ libczmq-dev \ python3 \ diff --git a/lib/iruby/display.rb b/lib/iruby/display.rb index 74b966a..67a6ae6 100644 --- a/lib/iruby/display.rb +++ b/lib/iruby/display.rb @@ -2,6 +2,22 @@ module IRuby module Display + DEFAULT_MIME_TYPE_FORMAT_METHODS = { + "text/html" => :to_html, + "text/markdown" => :to_markdown, + "image/svg+xml" => :to_svg, + "image/png" => :to_png, + "appliation/pdf" => :to_pdf, + "image/jpeg" => :to_jpeg, + "text/latex" => [:to_latex, :to_tex], + # NOTE: Do not include the entry of "application/json" because + # all objects can respond to `to_json` due to json library + # "application/json" => :to_json, + "application/javascript" => :to_javascript, + nil => :to_iruby, + "text/plain" => :inspect + }.freeze + class << self # @private def convert(obj, options) @@ -24,17 +40,38 @@ def display(obj, options = {}) raise 'Invalid mime type' unless exact_mime.include?('/') end - data = {} + data = render_mimebundle(obj, exact_mime, fuzzy_mime) + + # Render by additional formatters + render_by_registry(data, obj, exact_mime, fuzzy_mime) + + # Render by to_xxx methods + DEFAULT_MIME_TYPE_FORMAT_METHODS.each do |mime, methods| + next if mime.nil? && !data.empty? # for to_iruby - # Render additional representation - render(data, obj, exact_mime, fuzzy_mime) + next if mime && data.key?(mime) # do not overwrite - # IPython always requires a text representation - render(data, obj, 'text/plain', nil) unless data['text/plain'] + method = Array(methods).find {|m| obj.respond_to?(m) } + next if method.nil? + + result = obj.send(method) + case mime + when nil # to_iruby + case result + when Array + mime, result = result + else + warn(("Ignore the result of to_iruby method of %p because " + + "it does not return a pair of mime-type and formatted representation") % obj) + next + end + end + data[mime] = result + end # As a last resort, interpret string representation of the object # as the given mime type. - if exact_mime && data.none? { |m, _| exact_mime == m } + if exact_mime && !data.key?(exact_mime) data[exact_mime] = protect(exact_mime, obj) end @@ -67,7 +104,21 @@ def ascii?(mime) end end - def render(data, obj, exact_mime, fuzzy_mime) + private def render_mimebundle(obj, exact_mime, fuzzy_mime) + data = {} + if obj.respond_to?(:to_iruby_mimebundle) + include_mime = [exact_mime].compact + formats, metadata = obj.to_iruby_mimebundle(include: include_mime) + formats.each do |mime, value| + if fuzzy_mime.nil? || mime.include?(fuzzy_mime) + data[mime] = value + end + end + end + data + end + + private def render_by_registry(data, obj, exact_mime, fuzzy_mime) # Filter matching renderer by object type renderer = Registry.renderer.select { |r| r.match?(obj) } @@ -88,6 +139,8 @@ def render(data, obj, exact_mime, fuzzy_mime) # Return first render result which has the right mime type renderer.each do |r| mime, result = r.render(obj) + next if data.key?(mime) + if mime && result && (!exact_mime || exact_mime == mime) && (!fuzzy_mime || mime.include?(fuzzy_mime)) data[mime] = protect(mime, result) break @@ -98,6 +151,19 @@ def render(data, obj, exact_mime, fuzzy_mime) end end + private def render_by_to_iruby(data, obj) + if obj.respond_to?(:to_iruby) + result = obj.to_iruby + mime, rep = case result + when Array + result + else + [nil, result] + end + data[mime] = rep + end + end + class Representation attr_reader :object, :options @@ -120,6 +186,60 @@ def new(obj, options) end end + class FormatMatcher + def initialize(&block) + @block = block + end + + def call(obj) + @block.(obj) + end + + def inspect + "#{self.class.name}[%p]" % @block + end + end + + class RespondToFormatMatcher < FormatMatcher + def initialize(name) + super() {|obj| obj.respond_to?(name) } + @name = name + end + + attr_reader :name + + def inspect + "#{self.class.name}[respond_to?(%p)]" % name + end + end + + class TypeFormatMatcher < FormatMatcher + def initialize(class_block) + super() do |obj| + begin + self.klass === obj + # We have to rescue all exceptions since constant autoloading could fail with a different error + rescue Exception + false + end + end + @class_block = class_block + end + + def klass + @class_block.() + end + + def inspect + klass = begin + @class_block.() + rescue Exception + @class_block + end + "#{self.class.name}[%p]" % klass + end + end + class Renderer attr_reader :match, :mime, :priority @@ -159,25 +279,21 @@ def renderer ] def match(&block) - @match = block + @match = FormatMatcher.new(&block) priority 0 nil end def respond_to(name) - match { |obj| obj.respond_to?(name) } + @match = RespondToFormatMatcher.new(name) + priority 0 + nil end def type(&block) - match do |obj| - begin - block.call === obj - # We have to rescue all exceptions since constant autoloading could fail with a different error - rescue Exception - rescue #NameError - false - end - end + @match = TypeFormatMatcher.new(block) + priority 0 + nil end def priority(p) @@ -301,36 +417,17 @@ def format(mime = nil, &block) type { Gruff::Base } format 'image/png', &:to_blob - respond_to :to_html - format 'text/html', &:to_html - - respond_to :to_latex - format 'text/latex', &:to_latex - - respond_to :to_tex - format 'text/latex', &:to_tex - - respond_to :to_javascript - format 'text/javascript', &:to_javascript - - respond_to :to_svg + type { Rubyvis::Mark } format 'image/svg+xml' do |obj| - obj.render if defined?(Rubyvis) && Rubyvis::Mark === obj + obj.render obj.to_svg end - respond_to :to_iruby - format(&:to_iruby) - match { |obj| obj.respond_to?(:path) && obj.method(:path).arity == 0 && File.readable?(obj.path) } format do |obj| mime = MIME::Types.of(obj.path).first.to_s [mime, File.read(obj.path)] if SUPPORTED_MIMES.include?(mime) end - - type { Object } - priority(-1000) - format 'text/plain', &:inspect end end end diff --git a/test/iruby/display_test.rb b/test/iruby/display_test.rb new file mode 100644 index 0000000..7bdd788 --- /dev/null +++ b/test/iruby/display_test.rb @@ -0,0 +1,188 @@ +module IRubyTest + class DisplayTest < TestBase + def setup + @object = Object.new + @object.instance_variable_set(:@to_html_called, false) + @object.instance_variable_set(:@to_markdown_called, false) + @object.instance_variable_set(:@to_iruby_called, false) + @object.instance_variable_set(:@to_iruby_mimebundle_called, false) + + class << @object + attr_reader :to_html_called + attr_reader :to_markdown_called + attr_reader :to_iruby_called + attr_reader :to_iruby_mimebundle_called + + def html + "html" + end + + def markdown + "*markdown*" + end + + def inspect + "!!! inspect !!!" + end + end + end + + def define_to_html + class << @object + def to_html + @to_html_called = true + html + end + end + end + + def define_to_markdown + class << @object + def to_markdown + @to_markdown_called = true + markdown + end + end + end + + def define_to_iruby + class << @object + def to_iruby + @to_iruby_called = true + ["text/html", "to_iruby"] + end + end + end + + def define_to_iruby_mimebundle + class << @object + def to_iruby_mimebundle(include: []) + @to_iruby_mimebundle_called = true + mimes = if include.empty? + ["text/html", "text/markdown", "application/json"] + else + include + end + formats = mimes.map { |mime| + result = case mime + when "text/html" + "html" + when "text/markdown" + "**markdown**" + when "application/json" + %Q[{"mimebundle": "json"}] + end + [mime, result] + }.to_h + metadata = {} + return formats, metadata + end + end + end + + def assert_iruby_display(expected) + assert_equal(expected, + { + result: IRuby::Display.display(@object), + to_html_called: @object.to_html_called, + to_markdown_called: @object.to_markdown_called, + to_iruby_called: @object.to_iruby_called, + to_iruby_mimebundle_called: @object.to_iruby_mimebundle_called + }) + end + + sub_test_case("the object cannot handle all the mime types") do + def test_display + assert_iruby_display({ + result: {"text/plain" => "!!! inspect !!!"}, + to_html_called: false, + to_markdown_called: false, + to_iruby_called: false, + to_iruby_mimebundle_called: false + }) + end + end + + sub_test_case("the object can respond to to_iruby") do + def setup + super + define_to_iruby + end + + def test_display + assert_iruby_display({ + result: { + "text/html" => "to_iruby", + "text/plain" => "!!! inspect !!!" + }, + to_html_called: false, + to_markdown_called: false, + to_iruby_called: true, + to_iruby_mimebundle_called: false + }) + end + + sub_test_case("the object can respond to to_markdown") do + def setup + super + define_to_markdown + end + + def test_display + assert_iruby_display({ + result: { + "text/markdown" => "*markdown*", + "text/plain" => "!!! inspect !!!" + }, + to_html_called: false, + to_markdown_called: true, + to_iruby_called: false, + to_iruby_mimebundle_called: false + }) + end + end + + sub_test_case("the object can respond to to_html") do + def setup + super + define_to_html + end + + def test_display + assert_iruby_display({ + result: { + "text/html" => "html", + "text/plain" => "!!! inspect !!!" + }, + to_html_called: true, + to_markdown_called: false, + to_iruby_called: false, + to_iruby_mimebundle_called: false + }) + end + + sub_test_case("the object can respond to to_iruby_mimebundle") do + def setup + super + define_to_iruby_mimebundle + end + + def test_display + assert_iruby_display({ + result: { + "text/html" => "html", + "text/markdown" => "**markdown**", + "application/json" => %Q[{"mimebundle": "json"}], + "text/plain" => "!!! inspect !!!" + }, + to_html_called: false, + to_markdown_called: false, + to_iruby_called: false, + to_iruby_mimebundle_called: true + }) + end + end + end + end + end +end