import functools import collections import re import sys import traceback import pprint import kutil import klammer_base import html_util from html_util import E import latex_util from indexed_range import Indexed_ranges, hline_names, vline_names import table_cell import font def extend(lst, count, fill=None): if isinstance(lst, str): lst = lst.split() if fill is None: fill = lst[-1] if lst else "" return lst + ([fill] * (count - len(lst))) def parse_hpos(target, pos): if '"' in pos: pos_list = [] parts = pos.split('"') i = 0 while i < len(parts): pos_list += parts[i].split() i += 1 if i == len(parts): break #pos_list.append(f"{{{parts[i]}}}") pos_list.append(kutil.parse_length(target, f'"{parts[i]}"')[0]) i += 1 else: # pos_list = pos.split() pos_list = pos #print("pos_list:", pos_list) return pos_list class Table(klammer_base.Klammer_base): id = 0 def __init__(self, K): super().__init__(K) if self.grid: self.vline = ["all"] self.hline = ["all"] self.row_count = len(self.rows) if self.header: self.hline += ["1", str(self.row_count)] self.row_size = max([len(e) for e in self.rows]) # Rows with fewer cells than the widest row are padded with empty # cells (last-value duplication is for argument lists, not content). self.rows = [row + [""] * (self.row_size - len(row)) for row in self.rows] if self.calc: self.calculate() self.s_vline = Indexed_ranges(self.row_size + 1, self.row_count - 1, self.vline, vline_names(self.row_size + 1), ":vline") self.s_hline = Indexed_ranges(self.row_count + 1, self.row_size - 1, self.hline, hline_names(self.row_count + 1), ":hline") self.s_rowspan = Indexed_ranges(self.row_size, self.row_count - 1, self.rowspan, argument=":rowspan") self.s_colspan = Indexed_ranges(self.row_count, self.row_size - 1, self.colspan, argument=":colspan") self.cell_hpos = extend(parse_hpos(self.K_target, self.cell_hpos), self.row_size) #self.cell_hpos = self.cell_hpos.split(";") self.font = extend(self.font, self.row_size) self.make_cells(self.rows) # Calculated cell values (:calc). Calculations run in the order given; # each reads cell values as displayed (display-precision semantics), so # a printed total always equals the sum of the printed lines. Only # calculation targets are formatted (:calc_format); other cells keep # their authored text. Future operators to consider: min, max, mean, # and a per-calculation format override. calc_target_rgx = re.compile(r"(\d+)\((\d+)\)$") calc_cell_rgx = re.compile(r"\d+(-\d*)?\(") def calc_error(self, calc, message): raise Exception(f'In the :calc calculation "{calc}": {message}') def parse_number(self, text, ref, calc): s = text.strip() if self.decimal == "comma": s = s.translate(str.maketrans(",.", ".,")) s = s.replace(",", "") # Remove thousands separators try: return float(s) except ValueError: self.calc_error( calc, f'the cell {ref} contains "{text.strip()}", ' "which is not a number") def format_number(self, value, calc): if self.calc_format: try: s = format(value, self.calc_format) except ValueError: self.calc_error( calc, f'"{self.calc_format}" is not a valid ' "format specification") elif value.is_integer(): s = str(int(value)) else: s = str(value) if self.decimal == "comma": s = s.translate(str.maketrans(",.", ".,")) return s def operand_values(self, token, calc): # A token with subsets is a cell selection; a bare number is a # constant (always period-decimal, independent of :decimal). if not self.calc_cell_rgx.match(token): return [float(token)] selection = Indexed_ranges(self.row_count, self.row_size - 1, [token], argument=":calc") values = [] for row_i in selection.by_index: for _, col_i in selection.by_index[row_i].items(): values.append(self.parse_number( self.rows[row_i][col_i], f"{row_i}({col_i})", calc)) return values def apply_operator(self, op, values, calc): if len(values) == 1: # Lisp-style unary - and / return {"+": values[0], "*": values[0], "-": -values[0], "/": 1 / values[0]}[op] result = values[0] for v in values[1:]: # Fold from the left if op == "+": result += v elif op == "-": result -= v elif op == "*": result *= v else: result /= v return result def calculate(self): for calc in [c.strip() for c in self.calc.split(";") if c.strip()]: target, eq, expression = calc.partition("=") match = self.calc_target_rgx.match(target.strip()) if not eq or not match: self.calc_error(calc, "the target must be a single cell " "written (), followed by \"=\"") row_i, col_i = int(match.group(1)), int(match.group(2)) if row_i >= self.row_count or col_i >= self.row_size: self.calc_error( calc, f"the target {target.strip()} is outside the " f"table (rows 0-{self.row_count - 1}, " f"columns 0-{self.row_size - 1})") tokens = expression.split() if not tokens or tokens[0] not in "+-*/" or len(tokens) < 2: self.calc_error(calc, "the expression must be an operator " "(+ - * /) followed by at least one operand") values = [] for token in tokens[1:]: values += self.operand_values(token, calc) try: result = self.apply_operator(tokens[0], values, calc) except ZeroDivisionError: self.calc_error(calc, "division by zero") self.rows[row_i][col_i] = self.format_number(result, calc) def span_count(self, spans, index, cross_i): # The count of cells merged by a span anchored at (index, cross_i): # for colspan, index is the row and cross_i the column; for rowspan, # index is the column and cross_i the row. Non-anchor cells get 0. result = 0 entry = spans[index] if entry: for start, end in entry.ranges: if start == cross_i: result = end - start + 1 return result def compute_coverage(self): # Cells hidden by a span (every spanned cell except the anchor). self.colspan_covered = set() self.rowspan_covered = set() for row_i in self.s_colspan.by_index: for start, end in self.s_colspan.by_index[row_i].ranges: for col_i in range(start + 1, end + 1): self.colspan_covered.add((row_i, col_i)) for col_i in self.s_rowspan.by_index: for start, end in self.s_rowspan.by_index[col_i].ranges: for row_i in range(start + 1, end + 1): self.rowspan_covered.add((row_i, col_i)) self.covered = self.colspan_covered | self.rowspan_covered def remove_redundant_borders(self): remove_right = [] for row_i in range(self.row_count): for cell_i in range(self.row_size): a = self.cells[row_i][cell_i] b = self.cells[row_i][cell_i+1] if a.border.right and b.border.left: a.border.right = False a.border.right_all = False def column_width_text(self): # For each column, the text used to measure a 'fit' width in the # tex target. The longest cell's font is applied, so a bold or # italic cell is measured in the font it will be set in. self.column_widths = [] for col_i in range(len(self.cells[0])): longest = "" longest_font = "r" for row_i in range(len(self.cells)): cell = self.cells[row_i][col_i] # A colspan anchor's text spans several columns and must # not set the width of its own column. if cell is not None and cell.colspan <= 1: lines = [e.strip() for e in cell.text.split("\\newline")] lines = sorted(lines, key=len) longest_in_line = lines[-1] if len(longest_in_line) > len(longest): longest = longest_in_line longest_font = cell.font if longest_font != "r": longest = font.tex_fontify(longest, longest_font, 1.0) self.column_widths.append(longest) def make_cells(self, rows): self.compute_coverage() result = [] cells = [] for row_i, row in enumerate(rows): row_cells = [] for cell_i, cell in enumerate(row): rspan = self.span_count(self.s_rowspan, cell_i, row_i) cspan = self.span_count(self.s_colspan, row_i, cell_i) font = self.font[cell_i] if row_i == 0 and self.header: font = self.header_font # A span anchor's right and bottom borders come from the # boundary at the END of the merged region. right_i = cell_i + max(cspan, 1) bottom_i = row_i + max(rspan, 1) row_cells.append( table_cell.Cell( cell, font, self.cell_hpos[cell_i], self.s_hline.has(row_i, cell_i), self.s_vline.has(right_i, row_i), self.s_hline.has(bottom_i, cell_i), self.s_vline.has(cell_i, row_i), self.s_vline.by_index.get(cell_i), self.s_vline.by_index.get(right_i), rspan, cspan, first_column=(cell_i == 0))) cells.append(row_cells) self.cells = cells self.column_width_text() # HTML def html(self): result = "" for row_i, row in enumerate(self.cells): row_html = "" for cell_i, cell in enumerate(row): if (row_i, cell_i) in self.covered: continue row_html += cell.html().strip() + "\n" result += E("tr").body(row_html).str() result = E("table").body(result) if self.number or self.caption: result = html_util.add_caption( result, "Table", self.number, self.caption, self.caption_font, side=self.caption_side, font_size=self.caption_font_size) else: result = result.str() return result # LaTeX def tex_hpos(self): # One column specification per column: the width comes from # :column_width ('fit', a fraction of \tablewidth, or '*' for the # remaining width), the justification from :cell_hpos. def par_format(s, justification): command = {"l" : "raggedright", "c" : "centering", "r" : "raggedleft"}[justification] return f">{{\\{command}}}p{{{s}}}" widths = [] for i, w in enumerate(extend(self.column_width, self.row_size)): if w in ("fit", "f"): widths.append(f"\\widthof{{{self.column_widths[i]}}}") elif w == "*": widths.append(None) else: widths.append(f"{w}\\tablewidth") fill_count = widths.count(None) if fill_count > 0: fixed = [e for e in widths if e is not None] if fixed: expr = f"(\\tablewidth - {' - '.join(fixed)}) / {fill_count}" else: expr = f"{1 / fill_count}\\tablewidth" widths = [e if e is not None else expr for e in widths] return [par_format(w, j) for w, j in zip(widths, self.cell_hpos)] def tex_column_spec(self): parts = [""] * (self.row_size * 2 + 1) for i in self.s_vline.by_index: parts[i * 2] = "|" for i, hpos in enumerate(self.tex_hpos()): parts[i * 2 + 1] = hpos # print("tex_column_spec:", "".join(parts)) return "".join(parts) def tex_hline(self, index): # Contiguous cell borders coalesce into single \cline runs; a # full-width line becomes \hline. bottom = index == self.row_count if bottom: index -= 1 flags = [(cell.border.bottom if bottom else cell.border.top) for cell in self.cells[index]] if not bottom: # No line through the interior of a merged (rowspan) cell. flags = [flag and (index, col_i) not in self.rowspan_covered for col_i, flag in enumerate(flags)] if flags and all(flags): return "\\hline\n" hline = "" start = None for i, flag in enumerate(flags + [False]): if flag and start is None: start = i elif not flag and start is not None: hline += f"\\cline{{{start + 1}-{i}}} " start = None return hline.strip() + "\n" def tex_rows(self): result = "" for row_i, row in enumerate(self.cells): result += self.tex_hline(row_i) parts = [] for cell_i, cell in enumerate(row): if (row_i, cell_i) in self.colspan_covered: continue # Absorbed by the \multicolumn anchor if (row_i, cell_i) in self.rowspan_covered: parts.append("") # Occupied by the \multirow anchor else: parts.append(cell.tex()) result += " & ".join(parts) + " \\tabularnewline\n" result += self.tex_hline(self.row_count) return result def get_width(self): box = "\\savebox{\\tablebox}{%\n" box += "\\begin{tabular}{" box += self.tex_column_spec() box += "}\n" box += self.tex_rows() box += "\\end{tabular}}\n" result = box + "\\setlength{\\tableboxwidth}{\\wd\\tablebox}\n" # result += "\\the\\tableboxwidth\n\n" # print(result) return result def make_caption(self): column_count = str(len(self.rows[0])) caption = latex_util.make_caption_text( self.number, "Table", self.caption, self.caption_font, self.caption_font_size) result = "\\multicolumn{" + column_count + "}{c}{\\parbox{\\tableboxwidth}{" result += "\\vspace*{8pt}\\centering\\small\\em " result += caption result += "}} \\\\ \\endlastfoot\n" return result def tex(self): result = self.get_width() result += f"\\renewcommand*{{\\arraystretch}}{{{self.leading}}}\n" if self.allow_break: result += "\\vspace*{12pt}\n" result += "\\begin{longtable}{" result += self.tex_column_spec() result += "}\n" if self.allow_break: result += self.make_caption() result += self.tex_rows() result += "\\end{longtable}\n" if not self.allow_break: if self.number or self.caption: result = latex_util.add_caption( result, "Table", self.number, self.caption, "\\tablewidth", side=self.caption_side, font_symbol=self.caption_font, font_size=self.caption_font_size) else: result = latex_util.caption_wrapper(result, "center") name = f"Reference-Table-{Table.id}" Table.id += 1 result = f"\\hypertarget{{{name}}}{{}}\\label{{Label-{name}}}\n{result}" # The wrapper (add_caption/caption_wrapper) owns all vertical space # around the table; longtable's own glue is zeroed. result = (f"\\setlength{{\\tabcolsep}}{{{self.colsep}}}\n" "\\setlength{\\LTpre}{0pt}\n" "\\setlength{\\LTpost}{0pt}\n" f"\\setlength{{\\tablewidth}}{{\\textwidth - {2 * self.row_size}\\tabcolsep}}\n" + result) result = re.sub(r"\newline", r"\\\\", result) return result def txt(self): return "Table in .txt format not implemented"