require "tmpdir" require "open3" require "erb" require 'pathname' require 'fileutils' module ErbLatex # A template is an latex file that contains embedded ERB code # It can optionally have a layout be rendered to either a file # or StringIO instance # @example for a hypothetical Rails controller # tmpl = ErbLatex::Template.new( 'article.tex', # :layout => 'layout.tex' # :data => { :sentence=>"hello, this is doge" } # ) # render :pdf => tmpl.to_stringio.read # class Template # @attribute [r] log # @return [String] the log from the last xelatex run # @attribute [r] pass_count # @return [Fixnum] how many passes it took to compile the tex into a PDF attr_reader :log, :pass_count # @attribute [rw] layout # @return [String] path to a file to use for layout # @attribute [rw] packages_path # @return [String] path to a directory to search for packages # @attribute [rw] partials_path # @return [String] path to a directory to search for partials to include attr_accessor :layout, :packages_path, :partials_path # create a new Template # @param view_file [String] path to the latex template # @option options [String] :layout path to a latex template that calls yield # @option options [Hash] :data an instance variable will be created in the view for each key/value pair def initialize( view_file, options={} ) @data = options[:data] || {} @layout = options[:layout] @packages_path = options[:packages_path] @partials_path = options[:partials_path] @view = Pathname.new( view_file ) @log = '' end # Sets the data to be used for the template # @param hash [Hash] data to set for the template # An instance variable will be created for the template # for each key in the hash with it's value set accordingly def data=( hash ) @data = hash end # @return [String] the suggested filename for this template. # It removes the extension from the name and replaces it with '.pdf' def suggested_filename @view.basename.to_s.gsub(/\..*$/, '.pdf') end # Save the PDF to the file # @param file [String,IO] if file is a String, the PDF is moved to the path indicated (most efficient). # Otherwise, file is considered an instance of IO, and write is called on it with the PDF contents # @return [String,IO] the file def to_file( file = suggested_filename ) execute do | contents | if file.is_a?(String) FileUtils.mv contents, file else file.write contents.read file.rewind end end file end # @return [StringIO] containing the the PDF def to_stringio to_file( ::ErbLatex::StringIO.new(suggested_filename) ) end # Compile the Latex template into a PDF file # @yield [Pathname] complete path to the PDF file # @raise [LatexError] if the xelatex process does not complete successfully def execute latex = compile_latex Dir.mktmpdir do | dir | @pass_count = 0 @log = '' success = false while log_suggests_rerunning? && @pass_count < 5 @pass_count += 1 success = execute_xelatex(latex,dir) end pdf_file = Pathname.new(dir).join( "output.pdf" ) if success && pdf_file.exist? yield pdf_file else errors = @log.scan(/\*\!\s(.*?)\n\s*\n/m).map{|e| e.first.gsub(/\n/,'') }.join("; ") STDERR.puts @log raise LatexError.new( errors.empty? ? "xelatex compile error" : errors, @log ) end end end # @return [Pathname] layout file def layout_file Pathname.new( layout ) end # Runs the ERB pre-process on the latex file # @return [String] latex with ERB substitutions performed # @raise [LatexError] if the xelatex process does not complete successfully def compile_latex begin context = ErbLatex::Context.new( @partials_path || @view.dirname, @data ) content = ERB.new( @view.read, 0, '-' ).result( context.getBinding ) if layout ERB.new( layout_file.read, nil, '-' ).result( context.getBinding{ content }) else content end rescue RuntimeError,LocalJumpError=>e raise LatexError.new( "ERB compile raised #{e.class} on #{@view}", e.backtrace ) end end private # @return [Boolean] True if the log is empty(not ran yet), or contains the string "Rerun" def log_suggests_rerunning? @log.empty? || !! ( @log =~ /Rerun/ ) end # Execute xelatex on the file. # @param latex [String] contents of the template after running ERB on it # @param dir [String] path to the temporary working directory def execute_xelatex( latex, dir ) success = false @log = '' if @packages_path ENV['TEXINPUTS'] = "#{@packages_path}:" end Open3.popen2e( ErbLatex.xelatex_binary, "--no-shell-escape", "-shell-restricted", "-jobname=output", "-output-directory=#{dir}", ) do |stdin, output, wait_thr| stdin.write latex stdin.close @log = output.read.strip success = ( 0 == wait_thr.value ) end success end end end