"Fossies" - the Fresh Open Source Software Archive

Member "asciidoctor-2.0.10/lib/asciidoctor/table.rb" (1 Jun 2019, 23767 Bytes) of package /linux/www/asciidoctor-2.0.10.tar.gz:


As a special service "Fossies" has tried to format the requested source page into HTML format using (guessed) Ruby source code syntax highlighting (style: standard) with prefixed line numbers and code folding option. Alternatively you can here view or download the uninterpreted source code file.

    1 # frozen_string_literal: true
    2 module Asciidoctor
    3 # Public: Methods and constants for managing AsciiDoc table content in a document.
    4 # It supports all three of AsciiDoc's table formats: psv, dsv and csv.
    5 class Table < AbstractBlock
    6   # precision of column widths
    7   DEFAULT_PRECISION = 4
    8 
    9   # Public: A data object that encapsulates the collection of rows (head, foot, body) for a table
   10   class Rows
   11     attr_accessor :head, :foot, :body
   12 
   13     def initialize head = [], foot = [], body = []
   14       @head = head
   15       @foot = foot
   16       @body = body
   17     end
   18 
   19     alias [] send
   20 
   21     # Public: Retrieve the rows grouped by section as a nested Array.
   22     #
   23     # Creates a 2-dimensional array of two element entries. The first element
   24     # is the section name as a symbol. The second element is the Array of rows
   25     # in that section. The entries are in document order (head, foot, body).
   26     #
   27     # Returns a 2-dimentional Array of rows grouped by section.
   28     def by_section
   29       [[:head, @head], [:body, @body], [:foot, @foot]]
   30     end
   31 
   32     # Public: Retrieve the rows as a Hash.
   33     #
   34     # The keys are the names of the section groups and the values are the Array of rows in that section.
   35     # The keys are in document order (head, foot, body).
   36     #
   37     # Returns a Hash of rows grouped by section.
   38     def to_h
   39       { head: @head, body: @body, foot: @foot }
   40     end
   41   end
   42 
   43   # Public: Get/Set the columns for this table
   44   attr_accessor :columns
   45 
   46   # Public: Get/Set the Rows struct for this table (encapsulates head, foot
   47   # and body rows)
   48   attr_accessor :rows
   49 
   50   # Public: Boolean specifies whether this table has a header row
   51   attr_accessor :has_header_option
   52 
   53   # Public: Get the caption for this table
   54   attr_reader :caption
   55 
   56   def initialize parent, attributes
   57     super parent, :table
   58     @rows = Rows.new
   59     @columns = []
   60 
   61     @has_header_option = attributes['header-option'] ? true : false
   62 
   63     # smells like we need a utility method here
   64     # to resolve an integer width from potential bogus input
   65     if (pcwidth = attributes['width'])
   66       if (pcwidth_intval = pcwidth.to_i) > 100 || pcwidth_intval < 1
   67         pcwidth_intval = 100 unless pcwidth_intval == 0 && (pcwidth == '0' || pcwidth == '0%')
   68       end
   69     else
   70       pcwidth_intval = 100
   71     end
   72     @attributes['tablepcwidth'] = pcwidth_intval
   73 
   74     if @document.attributes['pagewidth']
   75       @attributes['tableabswidth'] = (abswidth_val = (((pcwidth_intval / 100.0) * @document.attributes['pagewidth'].to_f).truncate DEFAULT_PRECISION)) == abswidth_val.to_i ? abswidth_val.to_i : abswidth_val
   76     end
   77 
   78     @attributes['orientation'] = 'landscape' if attributes['rotate-option']
   79   end
   80 
   81   # Internal: Returns whether the current row being processed is
   82   # the header row
   83   def header_row?
   84     @has_header_option && @rows.body.empty?
   85   end
   86 
   87   # Internal: Creates the Column objects from the column spec
   88   #
   89   # returns nothing
   90   def create_columns colspecs
   91     cols = []
   92     autowidth_cols = nil
   93     width_base = 0
   94     colspecs.each do |colspec|
   95       colwidth = colspec['width']
   96       cols << (Column.new self, cols.size, colspec)
   97       if colwidth < 0
   98         (autowidth_cols ||= []) << cols[-1]
   99       else
  100         width_base += colwidth
  101       end
  102     end
  103     if (num_cols = (@columns = cols).size) > 0
  104       @attributes['colcount'] = num_cols
  105       width_base = nil unless width_base > 0 || autowidth_cols
  106       assign_column_widths width_base, autowidth_cols
  107     end
  108     nil
  109   end
  110 
  111   # Internal: Assign column widths to columns
  112   #
  113   # This method rounds the percentage width values to 4 decimal places and
  114   # donates the balance to the final column.
  115   #
  116   # This method assumes there's at least one column in the columns array.
  117   #
  118   # width_base - the total of the relative column values used for calculating percentage widths (default: nil)
  119   #
  120   # returns nothing
  121   def assign_column_widths width_base = nil, autowidth_cols = nil
  122     precision = DEFAULT_PRECISION
  123     total_width = col_pcwidth = 0
  124 
  125     if width_base
  126       if autowidth_cols
  127         if width_base > 100
  128           autowidth = 0
  129           logger.warn %(total column width must not exceed 100% when using autowidth columns; got #{width_base}%)
  130         else
  131           autowidth = ((100.0 - width_base) / autowidth_cols.size).truncate precision
  132           autowidth = autowidth.to_i if autowidth.to_i == autowidth
  133           width_base = 100
  134         end
  135         autowidth_attrs = { 'width' => autowidth, 'autowidth-option' => '' }
  136         autowidth_cols.each {|col| col.update_attributes autowidth_attrs }
  137       end
  138       @columns.each {|col| total_width += (col_pcwidth = col.assign_width nil, width_base, precision) }
  139     else
  140       col_pcwidth = (100.0 / @columns.size).truncate precision
  141       col_pcwidth = col_pcwidth.to_i if col_pcwidth.to_i == col_pcwidth
  142       @columns.each {|col| total_width += col.assign_width col_pcwidth, nil, precision }
  143     end
  144 
  145     # donate balance, if any, to final column (using half up rounding)
  146     @columns[-1].assign_width(((100 - total_width + col_pcwidth).round precision), nil, precision) unless total_width == 100
  147 
  148     nil
  149   end
  150 
  151   # Internal: Partition the rows into header, footer and body as determined
  152   # by the options on the table
  153   #
  154   # returns nothing
  155   def partition_header_footer(attrs)
  156     # set rowcount before splitting up body rows
  157     @attributes['rowcount'] = @rows.body.size
  158 
  159     num_body_rows = @rows.body.size
  160     if num_body_rows > 0 && @has_header_option
  161       head = @rows.body.shift
  162       num_body_rows -= 1
  163       # styles aren't applied to header row
  164       head.each {|c| c.style = nil }
  165       # QUESTION why does AsciiDoc use an array for head? is it
  166       # possible to have more than one based on the syntax?
  167       @rows.head = [head]
  168     end
  169 
  170     if num_body_rows > 0 && attrs['footer-option']
  171       @rows.foot = [@rows.body.pop]
  172     end
  173 
  174     nil
  175   end
  176 end
  177 
  178 # Public: Methods to manage the columns of an AsciiDoc table. In particular, it
  179 # keeps track of the column specs
  180 class Table::Column < AbstractNode
  181   # Public: Get/Set the style Symbol for this column.
  182   attr_accessor :style
  183 
  184   def initialize table, index, attributes = {}
  185     super table, :table_column
  186     @style = attributes['style']
  187     attributes['colnumber'] = index + 1
  188     attributes['width'] ||= 1
  189     attributes['halign'] ||= 'left'
  190     attributes['valign'] ||= 'top'
  191     update_attributes(attributes)
  192   end
  193 
  194   # Public: An alias to the parent block (which is always a Table)
  195   alias table parent
  196 
  197   # Internal: Calculate and assign the widths (percentage and absolute) for this column
  198   #
  199   # This method assigns the colpcwidth and colabswidth attributes.
  200   #
  201   # returns the resolved colpcwidth value
  202   def assign_width col_pcwidth, width_base, precision
  203     if width_base
  204       col_pcwidth = (@attributes['width'].to_f * 100.0 / width_base).truncate precision
  205       col_pcwidth = col_pcwidth.to_i if col_pcwidth.to_i == col_pcwidth
  206     end
  207     if parent.attributes['tableabswidth']
  208       @attributes['colabswidth'] = (col_abswidth = ((col_pcwidth / 100.0) * parent.attributes['tableabswidth']).truncate precision) == col_abswidth.to_i ? col_abswidth.to_i : col_abswidth
  209     end
  210     @attributes['colpcwidth'] = col_pcwidth
  211   end
  212 
  213   def block?
  214     false
  215   end
  216 
  217   def inline?
  218     false
  219   end
  220 end
  221 
  222 # Public: Methods for managing the a cell in an AsciiDoc table.
  223 class Table::Cell < AbstractBlock
  224   DOUBLE_LF = LF * 2
  225 
  226   # Public: An Integer of the number of columns this cell will span (default: nil)
  227   attr_accessor :colspan
  228 
  229   # Public: An Integer of the number of rows this cell will span (default: nil)
  230   attr_accessor :rowspan
  231 
  232   # Public: An alias to the parent block (which is always a Column)
  233   alias column parent
  234 
  235   # Internal: Returns the nested Document in an AsciiDoc table cell (only set when style is :asciidoc)
  236   attr_reader :inner_document
  237 
  238   def initialize column, cell_text, attributes = {}, opts = {}
  239     super column, :table_cell
  240     @source_location = opts[:cursor].dup if @document.sourcemap
  241     if column
  242       cell_style = column.attributes['style'] unless (in_header_row = column.table.header_row?)
  243       # REVIEW feels hacky to inherit all attributes from column
  244       update_attributes column.attributes
  245     end
  246     # NOTE if attributes is defined, we know this is a psv cell; implies text needs to be stripped
  247     if attributes
  248       if attributes.empty?
  249         @colspan = @rowspan = nil
  250       else
  251         @colspan, @rowspan = (attributes.delete 'colspan'), (attributes.delete 'rowspan')
  252         # TODO delete style attribute from @attributes if set
  253         cell_style = attributes['style'] || cell_style unless in_header_row
  254         update_attributes attributes
  255       end
  256       if cell_style == :asciidoc
  257         asciidoc = true
  258         inner_document_cursor = opts[:cursor]
  259         if (cell_text = cell_text.rstrip).start_with? LF
  260           lines_advanced = 1
  261           lines_advanced += 1 while (cell_text = cell_text.slice 1, cell_text.length).start_with? LF
  262           # NOTE this only works if we remain in the same file
  263           inner_document_cursor.advance lines_advanced
  264         else
  265           cell_text = cell_text.lstrip
  266         end
  267       elsif cell_style == :literal
  268         literal = true
  269         cell_text = cell_text.rstrip
  270         # QUESTION should we use same logic as :asciidoc cell? strip leading space if text doesn't start with newline?
  271         cell_text = cell_text.slice 1, cell_text.length while cell_text.start_with? LF
  272       else
  273         normal_psv = true
  274         # NOTE AsciidoctorJ uses nil cell_text to create an empty cell
  275         cell_text = cell_text ? cell_text.strip : ''
  276       end
  277     else
  278       @colspan = @rowspan = nil
  279       if cell_style == :asciidoc
  280         asciidoc = true
  281         inner_document_cursor = opts[:cursor]
  282       end
  283     end
  284     # NOTE only true for non-header rows
  285     if asciidoc
  286       # FIXME hide doctitle from nested document; temporary workaround to fix
  287       # nested document seeing doctitle and assuming it has its own document title
  288       parent_doctitle = @document.attributes.delete('doctitle')
  289       # NOTE we need to process the first line of content as it may not have been processed
  290       # the included content cannot expect to match conditional terminators in the remaining
  291       # lines of table cell content, it must be self-contained logic
  292       # QUESTION should we reset cell_text to nil?
  293       # QUESTION is is faster to check for :: before splitting?
  294       inner_document_lines = cell_text.split LF, -1
  295       if (unprocessed_line1 = inner_document_lines[0]).include? '::'
  296         preprocessed_lines = (PreprocessorReader.new @document, [unprocessed_line1]).readlines
  297         unless unprocessed_line1 == preprocessed_lines[0] && preprocessed_lines.size < 2
  298           inner_document_lines.shift
  299           inner_document_lines.unshift(*preprocessed_lines) unless preprocessed_lines.empty?
  300         end
  301       end unless inner_document_lines.empty?
  302       @inner_document = Document.new inner_document_lines, standalone: false, parent: @document, cursor: inner_document_cursor
  303       @document.attributes['doctitle'] = parent_doctitle unless parent_doctitle.nil?
  304       @subs = nil
  305     elsif literal
  306       @content_model = :verbatim
  307       @subs = BASIC_SUBS
  308     else
  309       if normal_psv && (cell_text.start_with? '[[') && LeadingInlineAnchorRx =~ cell_text
  310         Parser.catalog_inline_anchor $1, $2, self, opts[:cursor], @document
  311       end
  312       @content_model = :simple
  313       @subs = NORMAL_SUBS
  314     end
  315     @text = cell_text
  316     @style = cell_style
  317   end
  318 
  319   # Public: Get the String text of this cell with substitutions applied.
  320   #
  321   # Used for cells in the head row as well as text-only (non-AsciiDoc) cells in
  322   # the foot row and body.
  323   #
  324   # This method shouldn't be used for cells that have the AsciiDoc style.
  325   #
  326   # Returns the converted String text for this Cell
  327   def text
  328     apply_subs @text, @subs
  329   end
  330 
  331   # Public: Set the String text.
  332   #
  333   # This method shouldn't be used for cells that have the AsciiDoc style.
  334   #
  335   # Returns the new String text assigned to this Cell
  336   def text= val
  337     @text = val
  338   end
  339 
  340   # Public: Handles the body data (tbody, tfoot), applying styles and partitioning into paragraphs
  341   #
  342   # This method should not be used for cells in the head row or that have the literal or verse style.
  343   #
  344   # Returns the converted String for this Cell
  345   def content
  346     if (cell_style = @style) == :asciidoc
  347       @inner_document.convert
  348     elsif @text.include? DOUBLE_LF
  349       (text.split BlankLineRx).map do |para|
  350         cell_style && cell_style != :header ? (Inline.new parent, :quoted, para, type: cell_style).convert : para
  351       end
  352     elsif (subbed_text = text).empty?
  353       []
  354     elsif cell_style && cell_style != :header
  355       [(Inline.new parent, :quoted, subbed_text, type: cell_style).convert]
  356     else
  357       [subbed_text]
  358     end
  359   end
  360 
  361   def lines
  362     @text.split LF
  363   end
  364 
  365   def source
  366     @text
  367   end
  368 
  369   # Public: Get the source file where this block started
  370   def file
  371     @source_location && @source_location.file
  372   end
  373 
  374   # Public: Get the source line number where this block started
  375   def lineno
  376     @source_location && @source_location.lineno
  377   end
  378 
  379   def to_s
  380     "#{super.to_s} - [text: #@text, colspan: #{@colspan || 1}, rowspan: #{@rowspan || 1}, attributes: #@attributes]"
  381   end
  382 end
  383 
  384 # Public: Methods for managing the parsing of an AsciiDoc table. Instances of this
  385 # class are primarily responsible for tracking the buffer of a cell as the parser
  386 # moves through the lines of the table using tail recursion. When a cell boundary
  387 # is located, the previous cell is closed, an instance of Table::Cell is
  388 # instantiated, the row is closed if the cell satisifies the column count and,
  389 # finally, a new buffer is allocated to track the next cell.
  390 class Table::ParserContext
  391   include Logging
  392 
  393   # Public: An Array of String keys that represent the table formats in AsciiDoc
  394   #--
  395   # QUESTION should we recognize !sv as a valid format value?
  396   FORMATS = ['psv', 'csv', 'dsv', 'tsv'].to_set
  397 
  398   # Public: A Hash mapping the AsciiDoc table formats to default delimiters
  399   DELIMITERS = {
  400     'psv' => ['|', /\|/],
  401     'csv' => [',', /,/],
  402     'dsv' => [':', /:/],
  403     'tsv' => [?\t, /\t/],
  404     '!sv' => ['!', /!/],
  405   }
  406 
  407   # Public: The Table currently being parsed
  408   attr_accessor :table
  409 
  410   # Public: The AsciiDoc table format (psv, dsv, or csv)
  411   attr_accessor :format
  412 
  413   # Public: Get the expected column count for a row
  414   #
  415   # colcount is the number of columns to pull into a row
  416   # A value of -1 means we use the number of columns found
  417   # in the first line as the colcount
  418   attr_reader :colcount
  419 
  420   # Public: The String buffer of the currently open cell
  421   attr_accessor :buffer
  422 
  423   # Public: The cell delimiter for this table.
  424   attr_reader :delimiter
  425 
  426   # Public: The cell delimiter compiled Regexp for this table.
  427   attr_reader :delimiter_re
  428 
  429   def initialize reader, table, attributes = {}
  430     @start_cursor_data = (@reader = reader).mark
  431     @table = table
  432 
  433     if attributes.key? 'format'
  434       if FORMATS.include?(xsv = attributes['format'])
  435         if xsv == 'tsv'
  436           # NOTE tsv is just an alias for csv with a tab separator
  437           @format = 'csv'
  438         elsif (@format = xsv) == 'psv' && table.document.nested?
  439           xsv = '!sv'
  440         end
  441       else
  442         logger.error message_with_context %(illegal table format: #{xsv}), source_location: reader.cursor_at_prev_line
  443         @format, xsv = 'psv', (table.document.nested? ? '!sv' : 'psv')
  444       end
  445     else
  446       @format, xsv = 'psv', (table.document.nested? ? '!sv' : 'psv')
  447     end
  448 
  449     if attributes.key? 'separator'
  450       if (sep = attributes['separator']).nil_or_empty?
  451         @delimiter, @delimiter_rx = DELIMITERS[xsv]
  452       # QUESTION should we support any other escape codes or multiple tabs?
  453       elsif sep == '\t'
  454         @delimiter, @delimiter_rx = DELIMITERS['tsv']
  455       else
  456         @delimiter, @delimiter_rx = sep, /#{::Regexp.escape sep}/
  457       end
  458     else
  459       @delimiter, @delimiter_rx = DELIMITERS[xsv]
  460     end
  461 
  462     @colcount = table.columns.empty? ? -1 : table.columns.size
  463     @buffer = ''
  464     @cellspecs = []
  465     @cell_open = false
  466     @active_rowspans = [0]
  467     @column_visits = 0
  468     @current_row = []
  469     @linenum = -1
  470   end
  471 
  472   # Public: Checks whether the line provided starts with the cell delimiter
  473   # used by this table.
  474   #
  475   # returns true if the line starts with the delimiter, false otherwise
  476   def starts_with_delimiter?(line)
  477     line.start_with? @delimiter
  478   end
  479 
  480   # Public: Checks whether the line provided contains the cell delimiter
  481   # used by this table.
  482   #
  483   # returns Regexp MatchData if the line contains the delimiter, false otherwise
  484   def match_delimiter(line)
  485     @delimiter_rx.match(line)
  486   end
  487 
  488   # Public: Skip past the matched delimiter because it's inside quoted text.
  489   #
  490   # Returns nothing
  491   def skip_past_delimiter(pre)
  492     @buffer = %(#{@buffer}#{pre}#{@delimiter})
  493     nil
  494   end
  495 
  496   # Public: Skip past the matched delimiter because it's escaped.
  497   #
  498   # Returns nothing
  499   def skip_past_escaped_delimiter(pre)
  500     @buffer = %(#{@buffer}#{pre.chop}#{@delimiter})
  501     nil
  502   end
  503 
  504   # Public: Determines whether the buffer has unclosed quotes. Used for CSV data.
  505   #
  506   # returns true if the buffer has unclosed quotes, false if it doesn't or it
  507   # isn't quoted data
  508   def buffer_has_unclosed_quotes? append = nil
  509     if (record = append ? (@buffer + append).strip : @buffer.strip) == '"'
  510       true
  511     elsif record.start_with? '"'
  512       if ((trailing_quote = record.end_with? '"') && (record.end_with? '""')) || (record.start_with? '""')
  513         ((record = record.gsub '""', '').start_with? '"') && !(record.end_with? '"')
  514       else
  515         !trailing_quote
  516       end
  517     else
  518       false
  519     end
  520   end
  521 
  522   # Public: Takes a cell spec from the stack. Cell specs precede the delimiter, so a
  523   # stack is used to carry over the spec from the previous cell to the current cell
  524   # when the cell is being closed.
  525   #
  526   # returns The cell spec Hash captured from parsing the previous cell
  527   def take_cellspec
  528     @cellspecs.shift
  529   end
  530 
  531   # Public: Puts a cell spec onto the stack. Cell specs precede the delimiter, so a
  532   # stack is used to carry over the spec to the next cell.
  533   #
  534   # returns nothing
  535   def push_cellspec(cellspec = {})
  536     # this shouldn't be nil, but we check anyway
  537     @cellspecs << (cellspec || {})
  538     nil
  539   end
  540 
  541   # Public: Marks that the cell should be kept open. Used when the end of the line is
  542   # reached and the cell may contain additional text.
  543   #
  544   # returns nothing
  545   def keep_cell_open
  546     @cell_open = true
  547     nil
  548   end
  549 
  550   # Public: Marks the cell as closed so that the parser knows to instantiate a new cell
  551   # instance and add it to the current row.
  552   #
  553   # returns nothing
  554   def mark_cell_closed
  555     @cell_open = false
  556     nil
  557   end
  558 
  559   # Public: Checks whether the current cell is still open
  560   #
  561   # returns true if the cell is marked as open, false otherwise
  562   def cell_open?
  563     @cell_open
  564   end
  565 
  566   # Public: Checks whether the current cell has been marked as closed
  567   #
  568   # returns true if the cell is marked as closed, false otherwise
  569   def cell_closed?
  570     !@cell_open
  571   end
  572 
  573   # Public: If the current cell is open, close it. In additional, push the
  574   # cell spec captured from the end of this cell onto the stack for use
  575   # by the next cell.
  576   #
  577   # returns nothing
  578   def close_open_cell(next_cellspec = {})
  579     push_cellspec next_cellspec
  580     close_cell(true) if cell_open?
  581     advance
  582     nil
  583   end
  584 
  585   # Public: Close the current cell, instantiate a new Table::Cell, add it to
  586   # the current row and, if the number of expected columns for the current
  587   # row has been met, close the row and begin a new one.
  588   #
  589   # returns nothing
  590   def close_cell(eol = false)
  591     if @format == 'psv'
  592       cell_text = @buffer
  593       @buffer = ''
  594       if (cellspec = take_cellspec)
  595         repeat = cellspec.delete('repeatcol') || 1
  596       else
  597         logger.error message_with_context 'table missing leading separator; recovering automatically', source_location: Reader::Cursor.new(*@start_cursor_data)
  598         cellspec = {}
  599         repeat = 1
  600       end
  601     else
  602       cell_text = @buffer.strip
  603       @buffer = ''
  604       cellspec = nil
  605       repeat = 1
  606       if @format == 'csv' && !cell_text.empty? && cell_text.include?('"')
  607         # this may not be perfect logic, but it hits the 99%
  608         if cell_text.start_with?('"') && cell_text.end_with?('"')
  609           # unquote
  610           if (cell_text = cell_text.slice(1, cell_text.length - 2))
  611             # trim whitespace and collapse escaped quotes
  612             cell_text = cell_text.strip.squeeze('"')
  613           else
  614             logger.error message_with_context 'unclosed quote in CSV data; setting cell to empty', source_location: @reader.cursor_at_prev_line
  615             cell_text = ''
  616           end
  617         else
  618           # collapse escaped quotes
  619           cell_text = cell_text.squeeze('"')
  620         end
  621       end
  622     end
  623 
  624     1.upto(repeat) do |i|
  625       # TODO make column resolving an operation
  626       if @colcount == -1
  627         @table.columns << (column = Table::Column.new(@table, @table.columns.size + i - 1))
  628         if cellspec && (cellspec.key? 'colspan') && (extra_cols = cellspec['colspan'].to_i - 1) > 0
  629           offset = @table.columns.size
  630           extra_cols.times do |j|
  631             @table.columns << Table::Column.new(@table, offset + j)
  632           end
  633         end
  634       else
  635         # QUESTION is this right for cells that span columns?
  636         unless (column = @table.columns[@current_row.size])
  637           logger.error message_with_context 'dropping cell because it exceeds specified number of columns', source_location: @reader.cursor_before_mark
  638           return
  639         end
  640       end
  641 
  642       cell = Table::Cell.new(column, cell_text, cellspec, cursor: @reader.cursor_before_mark)
  643       @reader.mark
  644       unless !cell.rowspan || cell.rowspan == 1
  645         activate_rowspan(cell.rowspan, (cell.colspan || 1))
  646       end
  647       @column_visits += (cell.colspan || 1)
  648       @current_row << cell
  649       # don't close the row if we're on the first line and the column count has not been set explicitly
  650       # TODO perhaps the colcount/linenum logic should be in end_of_row? (or a should_end_row? method)
  651       close_row if end_of_row? && (@colcount != -1 || @linenum > 0 || (eol && i == repeat))
  652     end
  653     @cell_open = false
  654     nil
  655   end
  656 
  657   private
  658 
  659   # Internal: Close the row by adding it to the Table and resetting the row
  660   # Array and counter variables.
  661   #
  662   # returns nothing
  663   def close_row
  664     @table.rows.body << @current_row
  665     # don't have to account for active rowspans here
  666     # since we know this is first row
  667     @colcount = @column_visits if @colcount == -1
  668     @column_visits = 0
  669     @current_row = []
  670     @active_rowspans.shift
  671     @active_rowspans[0] ||= 0
  672     nil
  673   end
  674 
  675   # Internal: Activate a rowspan. The rowspan Array is consulted when
  676   # determining the effective number of cells in the current row.
  677   #
  678   # returns nothing
  679   def activate_rowspan(rowspan, colspan)
  680     1.upto(rowspan - 1) {|i| @active_rowspans[i] = (@active_rowspans[i] || 0) + colspan }
  681     nil
  682   end
  683 
  684   # Internal: Check whether we've met the number of effective columns for the current row.
  685   def end_of_row?
  686     @colcount == -1 || effective_column_visits == @colcount
  687   end
  688 
  689   # Internal: Calculate the effective column visits, which consists of the number of
  690   # cells plus any active rowspans.
  691   def effective_column_visits
  692     @column_visits + @active_rowspans[0]
  693   end
  694 
  695   # Internal: Advance to the next line (which may come after the parser begins processing
  696   # the next line if the last cell had wrapped content).
  697   def advance
  698     @linenum += 1
  699   end
  700 end
  701 end