# encoding: utf-8 require 'rexml/document' require 'rexml/formatters/transitive' require 'base64' module XhtmlReportGenerator VERSION = '4.0.1' # This is the main generator class. It can be instanced with custom javascript, css, and ruby files to allow # generation of arbitrary reports. # @attr [REXML::Document] document This is the html document / actual report # @attr [String] file path to the file where this report is saved to. Default: nil # @attr [Boolean] sync if true, the report will be written to disk after every modificaiton. Default: false # Note that sync = true can have severe performance impact, since the complete # html file is written to disk after each change. class Generator attr_accessor :document, :file, :sync # @param opts [Hash] See the example for an explanation of the valid symbols # @example Valid keys for the opts Hash # :title Title in the header section, defaults to "Title" # :js if specified, array of javascript files which are inlined into the html header section, after # the default included js files (check in sourcecode below). # :css if specified, array of css files which are inlined into the html header section after # the default included css files (check in sourcecode below). # :css_print if specified, array of css files which are inlined into the html header section with media=print # after the default included print css files (check in sourcecode below). def initialize(opts = {}) # define the default values resources = File.expand_path("../../resource/", __FILE__) defaults = { :title => "Title", :js => [ File.expand_path("js/jquery-3.2.1.min.js", resources), File.expand_path("d3v5.7.0/d3.min.js", resources), File.expand_path("c3v0.6.12/c3.min.js", resources), File.expand_path("js/split.min.js", resources), File.expand_path("js/layout_split.js", resources), File.expand_path("js/table_of_contents.js", resources), File.expand_path("js/toggle_linewrap.js", resources), ], :css => [ File.expand_path("css/style.css", resources), File.expand_path("c3v0.6.12/c3.min.css", resources), ], :css_print => [ File.expand_path("css/print.css", resources) ], #:custom_rb => File.expand_path("../custom.rb", __FILE__), } @sync = false opts[:title] = defaults[:title] if !opts.key?(:title) if opts.key?(:js) opts[:js] = defaults[:js] + opts[:js] else opts[:js] = defaults[:js] end if opts.key?(:css) opts[:css] = defaults[:css] + opts[:css] else opts[:css] = defaults[:css] end if opts.key?(:css_print) opts[:css_print] = defaults[:css_print] + opts[:css_print] else opts[:css_print] = defaults[:css_print] end @document = Generator.create_xhtml_document(opts[:title]) head = @document.elements["//head"] head.add_element("meta", {"charset" => "utf-8"}) # insert css opts[:css].each do |css_path| style = head.add_element("style", {"type" => "text/css"}) cdata(File.read(css_path), style) end # insert css for printing opts[:css_print].each do |css_path| style = head.add_element("style", {"type" => "text/css", "media"=>"print"}) cdata(File.read(css_path), style) end # inster js files opts[:js].each do |js_path| script = head.add_element("script", {"type" => "text/javascript"}) cdata(File.read(js_path), script) end document_changed() end # Surrounds CData tag with c-style comments to remain compatible with normal html. # For plain xhtml documents this is not needed. # Example /**/ # @param str [String] the string to be enclosed in cdata # @param parent_element [REXML::Element] the element to which cdata should be added # @return [REXML::Element] parent_element def cdata(str, parent_element) # somehow there is a problem with CDATA, any text added after will automatically go into the CDATA # so we have do add a dummy node after the CDATA and then add the text. parent_element.add_text("/*") parent_element.add(REXML::CData.new("*/\n"+str+"\n/*")) parent_element.add(REXML::Comment.new("dummy comment to make c-style comments for cdata work")) parent_element.add_text("*/") end # Check if the give string is a valid UTF-8 byte sequence. If it is not valid UTF-8, then # all invalid bytes are replaced by "\u2e2e" (\xe2\xb8\xae) ('REVERSED QUESTION MARK') because the default # replacement character "\uFFFD" ('QUESTION MARK IN DIAMOND BOX') is two slots wide and might # destroy mono spaced formatting # @param str [String] of any encoding # @return [String] UTF-8 encoded valid string def encoding_fixer(str) str = str.to_s # catch str = nil #if !str.force_encoding('UTF-8').valid_encoding? # str.encode!('UTF-8', 'ISO-8859-1', {:invalid => :replace, :undef => :replace, :xml => :text}) #end tmp = str.force_encoding('UTF-8').encode('UTF-8',{:invalid => :replace, :undef => :replace, :replace => "\u2e2e"}) # replace all special control chars as well but keep newline and whitespace "\u2e2e" tmp.force_encoding('binary').gsub!(/[\x00-\x07\x0C-\x1F]|\xef\xbf\xbe|\xef\xbf\xbf/n, "\xe2\xb8\xae".force_encoding('binary')) return tmp.force_encoding('UTF-8') end # Creates a minimal valid xhtml document including header title and body elements # @param title [String] Title in the header section def self.create_xhtml_document(title) # don't use version 1.1 - firefox has not yet a parser for xml 1.1 # https://bugzilla.mozilla.org/show_bug.cgi?id=233154 header = '' # change of doctype to for html5 compatibility header << '' doc = REXML::Document.new(header) html = doc.add_element("html", {"xmlns" => "http://www.w3.org/1999/xhtml"}) # create header head = html.add_element("head") t = head.add_element("title") t.text = title html.add_element("body") return doc end # returns the string representation of the xml document # @param indent [Number] indent for child elements. defaults to 0. # Note: if you change the indet this might destroy formatting of
sections # @return [String] formatted xml document def to_s(indent = 0) output = "" # note : transitive is needed to preserve newlines intags # note2: the hash options syntax is supported only from ruby version >= 2.0.0 we need the old style # for compatibility with 1.9.3 # @document.write({:output=>output, :indent=>indent, :transitive=>true}) # change to Formatters since document.write is deprecated f = REXML::Formatters::Transitive.new(indent) f.write(@document, output) return output end # Saves the xml document to a file. If no file is given, the file which was used most recently for this Generator # object will be overwritten. # @param file [String] absolute or relative path to the file to which will be written. Default: last file used. # @param mode [String] defaults to 'w', one of the file open modes that allows writing ['r+','w','w+','a','a+'] def write(file=@file, mode='w') # instance variables are nil if they were never initialized if file.nil? raise "no valid file given: '#{file}'" end @file = file File.open(file, "#{mode}:UTF-8") {|f| f.write(self.to_s.force_encoding(Encoding::UTF_8))} end # This method should be called after every change to the document. # Here we ensure the report is written to disk after each change # if #sync is true. If #sync is false this method does nothing def document_changed() if @sync if @file.nil? raise "You must call #write at least once before you can enable synced mode" end write() end end # creates the basic page layout and sets the current Element to the main content area (middle div) # @example The middle div is matched by the following xPath # //body/div[@id='middle'] # @param title [String] the title of the document # @param layout [Fixnum] one of 0,1,2,3 where 0 means minimal layout without left and right table of contents, # 1 means only left toc, 2 means only right toc, and 3 means full layout with left and right toc. def create_layout(title, layout=3) raise "invalid layout selector, choose from 0..3" if (layout < 0) || (layout > 3) @body = @document.elements["//body"] # only add the layout if it is not already there if !@layout head = @body.add_element("div", {"class" => "head", "id" => "head"}) head.add_element("button", {"id" => "pre_toggle_linewrap"}).add_text("Toggle Linewrap") if (layout & 0x1) != 0 div = @body.add_element("div", {"class" => "lefttoc split split-horizontal", "id" => "ltoc"}) div.add_text("Table of Contents") div.add_element("br") end @div_middle = @body.add_element("div", {"class" => "middle split split-horizontal", "id" => "middle"}) if (layout & 0x2) != 0 div = @body.add_element("div", {"class" => "righttoc split split-horizontal", "id" => "rtoc"}) div.add_text("Quick Links") div.add_element("br");div.add_element("br") end @body.add_element("p", {"class" => "#{layout}", "id" => "layout"}).add_text("this text should be hidden") @layout = true end @current = @document.elements["//body/div[@id='middle']"] set_title(title) document_changed() end # sets the title of the document in the section as well as in the layout header div # create_layout must be called before! # @param title [String] the text which will be insertead def set_title(title) if !@layout raise "call create_layout first" end pagetitle = @document.elements["//head/title"] pagetitle.text = title div = @document.elements["//body/div[@id='head']"] div.text = title document_changed() end # returns the title text of the report # @return [String] The title of the report def get_title() pagetitle = @document.elements["//head/title"] return pagetitle.text end # set the current element to the element or first element matched by the xpath expression. # The current element is the one which can be modified through highlighting. # @param xpath [REXML::Element|String] the element or an xpath string def set_current!(xpath) if xpath.is_a?(REXML::Element) @current = xpath elsif xpath.is_a?(String) @current = @document.elements[xpath] else raise "xpath is neither a String nor a REXML::Element" end end # returns the current xml element # @return [REXML::Element] the xml element after which the following elements will be added def get_current() return @current end # returns the plain text without any xml tags of the specified element and all its children # @param el [REXML::Element] The element from which to fetch the text children. Defaults to @current # @param recursive [Boolean] whether or not to recurse into the children of the given "el" # @return [String] text contents of xml node def get_element_text(el = @current, recursive = true) out = "" el.to_a.each { |child| if child.is_a?(REXML::Text) out << child.value() else if recursive out << get_element_text(child, true) end end } return out end # @param elem [REXML::Element] # @return [String] def element_to_string(elem) f = REXML::Formatters::Transitive.new(0) # use Transitive to preserve source formatting (e.g.tags) out = "" f.write(elem, out) return out end # @see #code # Instead of adding content to the report, this method returns the produced html code as a string. # This can be used to insert code into #custom_table (with the option data_is_xhtml: true) # @return [String] the code includingtags as a string def get_code_html(attrs={}, &block) temp = REXML::Element.new("pre") temp.add_attributes(attrs) raise "Block argument is mandatory" unless block_given? text = encoding_fixer(block.call()) temp.add_text(text) element_to_string(temp) end # Appends anode after the @current node # @param attrs [Hash] attributes for theelement. The following classes can be passed as attributes and are predefined with a different # background for your convenience !{"class" => "code0"} (light-blue), !{"class" => "code1"} (red-brown), # !{"class" => "code2"} (light-green), !{"class" => "code3"} (light-yellow). You may also specify your own background # as follows: !{"style" => "background: #FF00FF;"}. # @yieldreturn [String] the text to be added to theelement # @return [REXML::Element] the Element which was just added def code(attrs={}, &block) temp = REXML::Element.new("pre") temp.add_attributes(attrs) @div_middle.insert_after(@current, temp) @current = temp raise "Block argument is mandatory" unless block_given? text = encoding_fixer(block.call()) @current.add_text(text) document_changed() return @current end # Appends a