lib/asciidoctor/table.rb in asciidoctor-1.5.6.2 vs lib/asciidoctor/table.rb in asciidoctor-1.5.7

- old
+ new

@@ -1,10 +1,12 @@ # encoding: UTF-8 module Asciidoctor # Public: Methods and constants for managing AsciiDoc table content in a document. # It supports all three of AsciiDoc's table formats: psv, dsv and csv. class Table < AbstractBlock + # multipler / divisor for tuning precision of calculated result + DEFAULT_PRECISION_FACTOR = 10000.0 # Public: A data object that encapsulates the collection of rows (head, foot, body) for a table class Rows attr_accessor :head, :foot, :body @@ -22,11 +24,11 @@ # is the section name as a symbol. The second element is the Array of rows # in that section. The entries are in document order (head, foot, body). # # Returns a 2-dimentional Array of rows grouped by section. def by_section - [[:head, @head], [:foot, @foot], [:body, @body]] + [[:head, @head], [:body, @body], [:foot, @foot]] end end # Public: Get/Set the columns for this table attr_accessor :columns @@ -46,11 +48,11 @@ @rows = Rows.new @columns = [] @has_header_option = attributes.key? 'header-option' - # smell like we need a utility method here + # smells like we need a utility method here # to resolve an integer width from potential bogus input if (pcwidth = attributes['width']) if (pcwidth_intval = pcwidth.to_i) > 100 || pcwidth_intval < 1 pcwidth_intval = 100 unless pcwidth_intval == 0 && (pcwidth == '0' || pcwidth == '0%') end @@ -77,18 +79,25 @@ # Internal: Creates the Column objects from the column spec # # returns nothing def create_columns colspecs cols = [] + autowidth_cols = nil width_base = 0 colspecs.each do |colspec| - width_base += colspec['width'] + colwidth = colspec['width'] cols << (Column.new self, cols.size, colspec) + if colwidth < 0 + (autowidth_cols ||= []) << cols[-1] + else + width_base += colwidth + end end - unless (@columns = cols).empty? - @attributes['colcount'] = cols.size - assign_column_widths(width_base == 0 ? nil : width_base) + if (num_cols = (@columns = cols).size) > 0 + @attributes['colcount'] = num_cols + width_base = nil unless width_base > 0 || autowidth_cols + assign_column_widths width_base, autowidth_cols end nil end # Internal: Assign column widths to columns @@ -99,15 +108,27 @@ # This method assumes there's at least one column in the columns array. # # width_base - the total of the relative column values used for calculating percentage widths (default: nil) # # returns nothing - def assign_column_widths width_base = nil - pf = 10.0 ** 4 # precision factor (multipler / divisor) for managing precision of calculated result + def assign_column_widths width_base = nil, autowidth_cols = nil + pf = DEFAULT_PRECISION_FACTOR total_width = col_pcwidth = 0 if width_base + if autowidth_cols + if width_base > 100 + autowidth = 0 + logger.warn %(total column width must not exceed 100% when using autowidth columns; got #{width_base}%) + else + autowidth = ((100.0 - width_base) / autowidth_cols.size * pf).to_i / pf + autowidth = autowidth.to_i if autowidth.to_i == autowidth + width_base = 100 + end + autowidth_attrs = { 'width' => autowidth, 'autowidth-option' => '' } + autowidth_cols.each {|col| col.update_attributes autowidth_attrs } + end @columns.each {|col| total_width += (col_pcwidth = col.assign_width nil, width_base, pf) } else col_pcwidth = ((100 * pf / @columns.size).to_i) / pf # or... #col_pcwidth = (100.0 / @columns.size).truncate 4 @@ -196,13 +217,19 @@ end end # Public: Methods for managing the a cell in an AsciiDoc table. class Table::Cell < AbstractNode + # Public: Gets/Sets the location in the AsciiDoc source where this cell begins + attr_reader :source_location + # Public: Get/Set the Symbol style for this cell (default: nil) attr_accessor :style + # Public: Substitutions to be applied to content in this cell + attr_accessor :subs + # Public: An Integer of the number of columns this cell will span (default: nil) attr_accessor :colspan # Public: An Integer of the number of rows this cell will span (default: nil) attr_accessor :rowspan @@ -213,38 +240,51 @@ # Public: The internal Asciidoctor::Document for a cell that has the asciidoc style attr_reader :inner_document def initialize column, cell_text, attributes = {}, opts = {} super column, :cell + @source_location = opts[:cursor].dup if @document.sourcemap if column - cell_style = (in_header_row = column.table.header_row?) ? nil : column.attributes['style'] + cell_style = column.attributes['style'] unless (in_header_row = column.table.header_row?) # REVIEW feels hacky to inherit all attributes from column update_attributes column.attributes - else - in_header_row = cell_style = nil end + # NOTE if attributes is defined, we know this is a psv cell; implies text should be stripped if attributes - @colspan = attributes.delete 'colspan' - @rowspan = attributes.delete 'rowspan' - # TODO eventually remove the style attribute from the attributes hash - #cell_style = attributes.delete 'style' unless in_header_row || !(attributes.key? 'style') - cell_style = attributes['style'] unless in_header_row || !(attributes.key? 'style') - if opts[:strip_text] - if cell_style == :literal || cell_style == :verse - cell_text = cell_text.rstrip - cell_text = cell_text.slice 1, cell_text.length - 1 while cell_text.start_with? LF + if attributes.empty? + @colspan = @rowspan = nil + else + @colspan, @rowspan = (attributes.delete 'colspan'), (attributes.delete 'rowspan') + # TODO delete style attribute from @attributes if set + cell_style = attributes['style'] || cell_style unless in_header_row + update_attributes attributes + end + if cell_style == :asciidoc + asciidoc = true + inner_document_cursor = opts[:cursor] + if (cell_text = cell_text.rstrip).start_with? LF + lines_advanced = 1 + lines_advanced += 1 while (cell_text = cell_text.slice 1, cell_text.length).start_with? LF + # NOTE this only works if we remain in the same file + inner_document_cursor.advance lines_advanced else - cell_text = cell_text.strip + cell_text = cell_text.lstrip end + elsif (literal = cell_style == :literal) || cell_style == :verse + cell_text = cell_text.rstrip + # QUESTION should we use same logic as :asciidoc cell? strip leading space if text doesn't start with newline? + cell_text = cell_text.slice 1, cell_text.length while cell_text.start_with? LF + else + normal_psv = true + # NOTE AsciidoctorJ uses nil cell_text to create an empty cell + cell_text = cell_text ? cell_text.strip : '' end - update_attributes attributes else - @colspan = nil - @rowspan = nil + @colspan = @rowspan = nil end # NOTE only true for non-header rows - if cell_style == :asciidoc + if asciidoc # FIXME hide doctitle from nested document; temporary workaround to fix # nested document seeing doctitle and assuming it has its own document title parent_doctitle = @document.attributes.delete('doctitle') # NOTE we need to process the first line of content as it may not have been processed # the included content cannot expect to match conditional terminators in the remaining @@ -257,12 +297,20 @@ unless unprocessed_line1 == preprocessed_lines[0] && preprocessed_lines.size < 2 inner_document_lines.shift inner_document_lines.unshift(*preprocessed_lines) unless preprocessed_lines.empty? end end unless inner_document_lines.empty? - @inner_document = Document.new(inner_document_lines, :header_footer => false, :parent => @document, :cursor => opts[:cursor]) + @inner_document = Document.new(inner_document_lines, :header_footer => false, :parent => @document, :cursor => inner_document_cursor) @document.attributes['doctitle'] = parent_doctitle unless parent_doctitle.nil? + @subs = nil + elsif literal + @subs = BASIC_SUBS + else + if normal_psv && (cell_text.start_with? '[[') && LeadingInlineAnchorRx =~ cell_text + Parser.catalog_inline_anchor $1, $2, self, opts[:cursor], @document + end + @subs = NORMAL_SUBS end @text = cell_text @style = cell_style end @@ -273,11 +321,11 @@ # # This method shouldn't be used for cells that have the AsciiDoc style. # # Returns the converted String text for this Cell def text - apply_subs @text, (@style == :literal ? BASIC_SUBS : NORMAL_SUBS) + apply_subs @text, @subs end # Public: Set the String text. # # This method shouldn't be used for cells that have the AsciiDoc style. @@ -300,10 +348,20 @@ !@style || @style == :header ? p : Inline.new(parent, :quoted, p, :type => @style).convert end end end + # Public: Get the source file where this block started + def file + @source_location && @source_location.file + end + + # Public: Get the source line number where this block started + def lineno + @source_location && @source_location.lineno + end + def to_s "#{super.to_s} - [text: #@text, colspan: #{@colspan || 1}, rowspan: #{@rowspan || 1}, attributes: #@attributes]" end end @@ -312,10 +370,12 @@ # moves through the lines of the table using tail recursion. When a cell boundary # is located, the previous cell is closed, an instance of Table::Cell is # instantiated, the row is closed if the cell satisifies the column count and, # finally, a new buffer is allocated to track the next cell. class Table::ParserContext + include Logging + # Public: An Array of String keys that represent the table formats in AsciiDoc #-- # QUESTION should we recognize !sv as a valid format value? FORMATS = ['psv', 'csv', 'dsv', 'tsv'].to_set @@ -349,25 +409,23 @@ # Public: The cell delimiter compiled Regexp for this table. attr_reader :delimiter_re def initialize reader, table, attributes = {} - @reader = reader + @start_cursor_data = (@reader = reader).mark @table = table - # IMPORTANT if reader.cursor becomes a reference, this assignment would require .dup - @last_cursor = reader.cursor if attributes.key? 'format' if FORMATS.include?(xsv = attributes['format']) if xsv == 'tsv' # NOTE tsv is just an alias for csv with a tab separator @format = 'csv' elsif (@format = xsv) == 'psv' && table.document.nested? xsv = '!sv' end else - warn %(asciidoctor: ERROR: #{reader.prev_line_info}: illegal table format: #{xsv}) + logger.error message_with_context %(illegal table format: #{xsv}), :source_location => reader.cursor_at_prev_line @format, xsv = 'psv', (table.document.nested? ? '!sv' : 'psv') end else @format, xsv = 'psv', (table.document.nested? ? '!sv' : 'psv') end @@ -411,30 +469,32 @@ @delimiter_re.match(line) end # Public: Skip past the matched delimiter because it's inside quoted text. # - # returns the String after the match - def skip_past_delimiter(match) - @buffer = %(#{@buffer}#{match.pre_match}#{@delimiter}) - match.post_match + # Returns nothing + def skip_past_delimiter(pre) + @buffer = %(#{@buffer}#{pre}#{@delimiter}) + nil end # Public: Skip past the matched delimiter because it's escaped. # - # returns the String after the match - def skip_past_escaped_delimiter(match) - @buffer = %(#{@buffer}#{match.pre_match.chop}#{@delimiter}) - match.post_match + # Returns nothing + def skip_past_escaped_delimiter(pre) + @buffer = %(#{@buffer}#{pre.chop}#{@delimiter}) + nil end # Public: Determines whether the buffer has unclosed quotes. Used for CSV data. # # returns true if the buffer has unclosed quotes, false if it doesn't or it # isn't quoted data def buffer_has_unclosed_quotes? append = nil - if (record = append ? (buffer + append).strip : buffer.strip).start_with? '"' + if (record = append ? (@buffer + append).strip : @buffer.strip) == '"' + true + elsif record.start_with? '"' if ((trailing_quote = record.end_with? '"') && (record.end_with? '""')) || (record.start_with? '""') ((record = record.gsub '""', '').start_with? '"') && !(record.end_with? '"') else !trailing_quote end @@ -511,32 +571,30 @@ # row has been met, close the row and begin a new one. # # returns nothing def close_cell(eol = false) if @format == 'psv' - strip_text = true cell_text = @buffer @buffer = '' if (cellspec = take_cellspec) repeat = cellspec.delete('repeatcol') || 1 else - warn %(asciidoctor: ERROR: #{@last_cursor.line_info}: table missing leading separator, recovering automatically) + logger.error message_with_context 'table missing leading separator; recovering automatically', :source_location => Reader::Cursor.new(*@start_cursor_data) cellspec = {} repeat = 1 end else - strip_text = false cell_text = @buffer.strip @buffer = '' cellspec = nil repeat = 1 if @format == 'csv' if !cell_text.empty? && cell_text.include?('"') # this may not be perfect logic, but it hits the 99% if cell_text.start_with?('"') && cell_text.end_with?('"') # unquote - cell_text = cell_text[1...-1].strip + cell_text = cell_text.slice(1, cell_text.length - 2).strip end # collapse escaped quotes cell_text = cell_text.squeeze('"') end @@ -554,17 +612,17 @@ end end else # QUESTION is this right for cells that span columns? unless (column = @table.columns[@current_row.size]) - warn %(asciidoctor: ERROR: #{@last_cursor.line_info}: dropping cell because it exceeds specified number of columns) + logger.error message_with_context 'dropping cell because it exceeds specified number of columns', :source_location => @reader.cursor_before_mark return end end - cell = Table::Cell.new(column, cell_text, cellspec, :cursor => @last_cursor, :strip_text => strip_text) - @last_cursor = @reader.cursor + cell = Table::Cell.new(column, cell_text, cellspec, :cursor => @reader.cursor_before_mark) + @reader.mark unless !cell.rowspan || cell.rowspan == 1 activate_rowspan(cell.rowspan, (cell.colspan || 1)) end @column_visits += (cell.colspan || 1) @current_row << cell @@ -618,8 +676,7 @@ # Internal: Advance to the next line (which may come after the parser begins processing # the next line if the last cell had wrapped content). def advance @linenum += 1 end - end end