import functools
import collections
import importlib
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

# ---- :format functions ---------------------------------------------------
# A :format function takes (value, target) and returns the formatted cell
# text.  It is named <module>.<function> in the :format list (like an eval
# reference), so it can live in ANY module: the SKS ships none by default, and
# a document defines its own (e.g. a euro() in the document's .py, named
# "<module>.euro" in :format) because a specific currency style is a property
# of that document, not of the SKS.  A function should emit period-decimal
# numbers; apply_formats() applies the :decimal comma swap.

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)
        self.row_count = len(self.rows)
        if self.grid:
            self.vline = ["all"]
            self.hline = ["all"]
        elif not self.hline and self.header:
            # Default lines: under the header row and at the bottom.  A
            # writer's own :hline replaces them (":hline none" = no lines).
            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]
        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")
        # :calc runs after the span structures exist so calculate() can warn
        # when a target lands in a cell hidden by a colspan/rowspan merge;
        # :format runs after :calc so it formats the computed values.
        if self.calc:
            self.compute_coverage()
            self.calculate()
        if self.format:
            self.apply_formats()
        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)
        # In html, a 'fit' column mixed with sized columns must be clamped
        # to its widest entry (tex's \widthof semantics) or it absorbs the
        # window width: its cells get nowrap (class Wfit; see table.css).
        # In a full-width table (fractions/'*') they also need the classic
        # 1%-shrink width (class Wpct) to survive surplus distribution; in
        # a 'fill' table the table is content-sized (max-width) and a
        # percentage would blow it up to full width, so nowrap alone.
        # All-'fit' tables shrink to content anyway and keep wrapping.
        widths = extend(self.column_width, self.row_size)
        self.fill_columns = [i for i, w in enumerate(widths) if w == "fill"]
        if self.fill_columns:
            if any(w not in ("fit", "f", "fill") for w in widths):
                raise Exception(
                    ':column_width: "fill" cannot be combined with a '
                    'fraction or "*" -- fill computes the remaining width '
                    'itself')
            if len(self.fill_columns) > 4:
                raise Exception(
                    ':column_width: at most four "fill" columns are '
                    'supported')
        mixed = not all(w in ("fit", "f") for w in widths)
        self.fit_columns = {i for i, w in enumerate(widths)
                            if mixed and w in ("fit", "f")}
        self.fit_class = "Wfit" if self.fill_columns else "Wfit Wpct"
        # A table edge with no outer vertical line drops its outer cell
        # padding (html Fl/Fr classes, tex @{}) so the edge cells' text
        # aligns with the text margin; with an outer line the padding
        # stays -- text against a border looks worse than text inset
        # from a margin.
        self.flush_l = 0 not in self.s_vline.by_index
        self.flush_r = self.row_size not in self.s_vline.by_index
        self.justify_map = self.justify_overrides() if self.justify else {}
        self.make_cells(self.rows)

    # Calculated cell values (:calc).  A calculation is
    #     <target> = <op> <operand> ...          (prefix operator: + - * /)
    # and the SHAPE of the target selects the operation:
    #   * single cell   r(c)  -> FOLD: the operands collapse to one value.
    #   * row range     R(c)  -> horizontal MAP: run once per row in R.
    #   * column range  r(C)  -> vertical MAP: run once per column in C.
    # In a map the target's ranged axis iterates; an operand's aligned axis
    # iterates in lockstep and any other range in it folds.  A relative
    # operand omits the iterated axis ("(col)" in a row map; bare "rows" in a
    # column map); a constant or a single fixed cell broadcasts.  See
    # notes/calc_notation.md.  Calculations run in order, each reading values
    # as displayed (display-precision); :format styles the results.
    # Future operators to consider: min, max, mean.

    # A single-axis selector: an index or an inclusive range; negatives count
    # from the end (-1 = last).  A target/operand token is <rows>(<cols>),
    # <rows>, or (<cols>) -- the last two are the relative operand forms.
    selector_rgx = re.compile(r"^(-?\d+)(-(-?\d+)?)?$")
    operand_rgx = re.compile(
        r"^(?P<rows>-?\d+(?:-(?:-?\d+)?)?)?(?:\((?P<cols>[-\d,]+)\))?$")

    def calc_error(self, calc, message):
        raise Exception(f'In the :calc calculation "{calc}": {message}')

    def calc_warn(self, calc, message):
        # Non-fatal: the value is still computed and stored (a covered target
        # may be read as an operand by a later calc), it just is not rendered.
        print(f'Warning: in the :calc calculation "{calc}": {message}',
              file=sys.stderr)

    def selector_error(self, ctx, message):
        # ctx is the caller's prefix, e.g. 'In the :calc calculation "..."' or
        # 'In :format "..."', so the same selector parser serves both.
        raise Exception(f"{ctx}: {message}")

    def to_number(self, text):
        # A displayed cell value as a float, honoring :decimal and thousands
        # separators; None (no error raised) when it is not a number.
        s = text.strip()
        if self.decimal == "comma":
            s = s.translate(str.maketrans(",.", ".,"))
        s = s.replace(",", "")  # Remove thousands separators
        try:
            return float(s)
        except ValueError:
            return None

    def parse_number(self, text, ref, calc):
        v = self.to_number(text)
        if v is None:
            # Unescape KTESC markers so the message shows the character the
            # writer typed (e.g. "$5") rather than "KTESC0024KTESC5".
            self.calc_error(
                calc, "the cell {} contains \"{}\", which is not a number"
                .format(ref, klammer_base.unescape_ktesc(text.strip())))
        return v

    def format_number(self, value):
        # Calc results are stored as plain numbers; :format does any styling.
        s = str(int(value)) if value.is_integer() else str(value)
        if self.decimal == "comma":
            s = s.translate(str.maketrans(",.", ".,"))
        return s

    def calc_norm(self, i, count, spec, ctx):
        # Resolve a possibly-negative index; -1 is the last, like Python.
        j = i + count if i < 0 else i
        if not 0 <= j < count:
            self.selector_error(
                ctx, f'in "{spec}", index {i} is out of range '
                f"(0 through {count - 1})")
        return j

    def calc_selectors(self, spec, count, ctx):
        # "3", "0-2", "0--2", "-1", or a comma-separated list of those, to a
        # list of indices in written order (order matters for a left fold).
        result = []
        for part in spec.split(","):
            m = self.selector_rgx.match(part)
            if not m:
                self.selector_error(ctx, f'"{part}" is not a valid selector')
            start = self.calc_norm(int(m.group(1)), count, part, ctx)
            if m.group(2) is None:
                result.append(start)
                continue
            end = (self.calc_norm(int(m.group(3)), count, part, ctx)
                   if m.group(3) else count - 1)
            if start > end:
                self.selector_error(
                    ctx, f'in "{part}", the start {start} is after the end {end}')
            result += list(range(start, end + 1))
        return result

    def cell_number(self, r, c, calc):
        return self.parse_number(self.rows[r][c], f"{r}({c})", calc)

    def parse_operand(self, token, calc):
        # ('const', value) or ('cells', rows, cols) where each of rows/cols is
        # a list of indices, or None when that axis is not written (a relative
        # operand, resolved against the target's iterated axis by operand_cells).
        try:
            return ('const', float(token))
        except ValueError:
            pass
        m = self.operand_rgx.match(token)
        if not m or (m.group('rows') is None and m.group('cols') is None):
            self.calc_error(
                calc, f'"{token}" is not a number or a cell selection')
        ctx = f'In the :calc calculation "{calc}"'
        rows = (self.calc_selectors(m.group('rows'), self.row_count, ctx)
                if m.group('rows') is not None else None)
        cols = (self.calc_selectors(m.group('cols'), self.row_size, ctx)
                if m.group('cols') is not None else None)
        return ('cells', rows, cols)

    def operand_cells(self, token, calc, mode, index, trange):
        # The operand's numbers for the current target cell.  mode is 'scalar',
        # 'row' (horizontal map, rows iterate), or 'col' (vertical, cols
        # iterate); index is the current row/col; trange is the target's range.
        kind = self.parse_operand(token, calc)
        if kind[0] == 'const':
            return [kind[1]]
        _, rows, cols = kind
        if mode == 'scalar':
            if rows is None or cols is None:
                self.calc_error(
                    calc, f'"{token}" is a relative operand; it needs a ranged '
                    "target (a row or column range) to resolve against")
            return [self.cell_number(r, c, calc) for r in rows for c in cols]
        if mode == 'row':                   # rows iterate; any columns fold
            if cols is None:
                self.calc_error(
                    calc, f'"{token}" selects no column; a row-map operand '
                    "names a column, e.g. (0) or 0-(0)")
            if rows is None:                 # relative: this row
                use_rows = [index]
            elif len(rows) == 1:             # a fixed row broadcasts
                use_rows = rows
            elif rows == trange:             # explicit range in lockstep
                use_rows = [index]
            else:
                self.calc_error(
                    calc, f'the rows of "{token}" must match the target rows')
            return [self.cell_number(r, c, calc) for r in use_rows for c in cols]
        # mode == 'col': columns iterate; any rows fold
        if rows is None:
            self.calc_error(
                calc, f'"{token}" selects no row; a column-map operand names '
                "rows, e.g. 0--2 or 0--2(0-)")
        if cols is None:                     # relative: this column
            use_cols = [index]
        elif len(cols) == 1:                 # a fixed column broadcasts
            use_cols = cols
        elif cols == trange:                 # explicit range in lockstep
            use_cols = [index]
        else:
            self.calc_error(
                calc, f'the columns of "{token}" must match the target columns')
        return [self.cell_number(r, c, calc) for r in rows for c in use_cols]

    def apply_operator(self, op, values, calc):
        if len(values) == 1:  # Lisp-style unary: - negates, / reciprocates
            v = values[0]     # + and * of one operand are the operand itself
            if op == "-":
                return -v
            if op == "/":     # compute 1/v only for "/", so "+ <zero cell>"
                return 1 / v  # does not raise a spurious ZeroDivisionError
            return v
        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 calc_fold(self, op, operands, calc, mode, index, trange):
        values = []
        for token in operands:
            values += self.operand_cells(token, calc, mode, index, trange)
        try:
            return self.apply_operator(op, values, calc)
        except ZeroDivisionError:
            self.calc_error(calc, "division by zero")

    def calc_assign(self, r, c, value, calc, target_text):
        if (r, c) in self.covered:
            self.calc_warn(
                calc, f"the target {target_text} is a cell hidden by a colspan "
                "or rowspan merge; its computed value will not be shown")
        self.rows[r][c] = self.format_number(value)

    def calculate(self):
        for calc in [c.strip() for c in self.calc.split(";") if c.strip()]:
            self.run_calc(calc)

    def run_calc(self, calc):
        target, eq, expression = calc.partition("=")
        target = target.strip()
        m = self.operand_rgx.match(target)
        if not eq or not m or m.group('rows') is None or m.group('cols') is None:
            self.calc_error(
                calc, "the target must be a cell r(c) or a ranged cell such as "
                "0-(2) or -1(0-), followed by \"=\"")
        ctx = f'In the :calc calculation "{calc}"'
        trows = self.calc_selectors(m.group('rows'), self.row_count, ctx)
        tcols = self.calc_selectors(m.group('cols'), self.row_size, ctx)
        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")
        op, operands = tokens[0], tokens[1:]
        row_range, col_range = len(trows) > 1, len(tcols) > 1
        if row_range and col_range:
            self.calc_error(
                calc, "the target may range over rows or columns, but not both")
        if not row_range and not col_range:            # single cell: a fold
            v = self.calc_fold(op, operands, calc, 'scalar', None, None)
            self.calc_assign(trows[0], tcols[0], v, calc, target)
        elif row_range:                                # horizontal map
            for r in trows:
                v = self.calc_fold(op, operands, calc, 'row', r, trows)
                self.calc_assign(r, tcols[0], v, calc, target)
        else:                                          # vertical map
            for c in tcols:
                v = self.calc_fold(op, operands, calc, 'col', c, tcols)
                self.calc_assign(trows[0], c, v, calc, target)

    # ---- :format ----------------------------------------------------------
    #
    # ";"-separated  <cells> <function>  pairs (same list style as :calc).
    # <cells> is an indexed_range; <function> is a "<module>.<function>"
    # reference (like an @eval reference) to a Python function taking
    # (value, target) and returning the formatted cell text.  Each selected
    # cell's value is parsed as a number (honoring :decimal); if numeric the
    # function is called and its result -- with the :decimal comma swap
    # applied -- replaces the cell (e.g. a writer's myformats.euro function
    # turns 1234.56 into "1,234.56 €").  A
    # non-numeric cell is left as-is with a warning.  Runs AFTER :calc.  The
    # result is inserted verbatim (this pass is after the cell-processing pass),
    # so a function may emit target markup directly.

    def format_warn(self, message):
        print(f"Warning: in :format: {message}", file=sys.stderr)

    def format_function(self, spec, ctx):
        # Resolve "<module>.<function>" to a callable, like an @eval reference.
        if "." not in spec:
            self.selector_error(
                ctx, f'the format function "{spec}" must be written '
                "<module>.<function>, e.g. table.euro")
        mod_name, func_name = spec.rsplit(".", 1)
        try:
            return getattr(importlib.import_module(mod_name), func_name)
        except (ImportError, AttributeError):
            self.selector_error(
                ctx, f'the format function "{spec}" was not found')

    def apply_formats(self):
        for stmt in [s.strip() for s in self.format.split(";") if s.strip()]:
            ctx = f'In :format "{stmt}"'
            parts = stmt.split()
            if len(parts) != 2:
                self.selector_error(
                    ctx, "each entry is <cells> <function>, e.g. 0-(5) myformats.euro")
            rangespec, spec = parts
            func = self.format_function(spec, ctx)
            m = self.operand_rgx.match(rangespec)
            if not m or m.group('rows') is None or m.group('cols') is None:
                self.selector_error(
                    ctx, f'"{rangespec}" is not a cell range like 0-(5) '
                    "or 1--2(0-3)")
            rows = self.calc_selectors(m.group('rows'), self.row_count, ctx)
            cols = self.calc_selectors(m.group('cols'), self.row_size, ctx)
            for r in rows:
                for c in cols:
                    v = self.to_number(self.rows[r][c])
                    if v is None:
                        self.format_warn(
                            f'cell {r}({c}) contains "'
                            f'{klammer_base.unescape_ktesc(self.rows[r][c].strip())}'
                            '", which is not a number; left unformatted')
                        continue
                    result = func(v, self.K_target)
                    if self.decimal == "comma":
                        result = result.translate(str.maketrans(",.", ".,"))
                    self.rows[r][c] = result

    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 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)

    # ---- :hpos -------------------------------------------------------------
    #
    # ";"-separated  <cells> <position>  pairs (the same list style as :calc
    # and :format).  <cells> is an indexed_range; <position> is l, c, or r
    # and overrides the column's :cell_hpos for the selected cells.  A
    # colspan anchor's override positions the whole merged cell; in the tex
    # target an ordinary overridden cell is wrapped in \multicolumn{1}.

    def justify_overrides(self):
        result = {}
        for stmt in [s.strip() for s in self.justify.split(";") if s.strip()]:
            ctx = f'In :justify "{stmt}"'
            parts = stmt.split()
            if len(parts) != 2 or parts[1] not in ("l", "c", "r"):
                self.selector_error(
                    ctx, "each entry is <cells> <position>, the position one "
                    "of l, c, or r -- e.g. -3--1(3) r")
            rangespec, pos = parts
            m = self.operand_rgx.match(rangespec)
            if not m or m.group('rows') is None or m.group('cols') is None:
                self.selector_error(
                    ctx, f'"{rangespec}" is not a cell range like 0-(5) '
                    "or 1--2(0-3)")
            rows = self.calc_selectors(m.group('rows'), self.row_count, ctx)
            cols = self.calc_selectors(m.group('cols'), self.row_size, ctx)
            for r in rows:
                for c in cols:
                    result[(r, c)] = pos
        return result

    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)
                hpos = self.justify_map.get((row_i, cell_i), self.cell_hpos[cell_i])
                row_cells.append(
                    table_cell.Cell(
                        cell,
                        font, hpos,
                        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),
                        hpos_forced=(row_i, cell_i) in self.justify_map,
                        fit_class=(self.fit_class
                                   if cell_i in self.fit_columns else ""),
                        flush_left=(self.flush_l and cell_i == 0),
                        flush_right=(self.flush_r and
                                     cell_i + max(cspan, 1) == self.row_size)))
            cells.append(row_cells)
        self.cells = cells
        self.column_width_text()
                
    # HTML

    def html_colgroup(self):
        # The CSS counterpart of tex_hpos()'s width resolution: 'fit' is the
        # widest entry of the column and no larger (tex's \widthof — the
        # cells' Wfit class clamps it there), a
        # fraction is of the text column, and '*' shares the width the sized
        # columns leave over.  All-'fit' (the default) needs no markup at
        # all.  With no 'fit' column the fixed layout makes the fractions
        # exact (long content wraps, as LaTeX's p{} columns do); a 'fit'
        # column forces the auto layout, where the fraction widths are
        # honored approximately.
        widths = extend(self.column_width, self.row_size)
        if all(w in ("fit", "f") for w in widths):
            return "", "", None
        fractions = sum(float(w) for w in widths if w not in ("fit", "f", "*"))
        if any(w in ("fit", "f", "*") for w in widths):
            table_width = "100%"
        else:
            table_width = f"{min(fractions, 1) * 100:g}%"
        cols = ""
        for w in widths:
            if w in ("fit", "f"):
                # Clamped to the widest entry by the cells' Wfit class
                # (width 1% + nowrap; see table.css) -- a px width on the
                # <col> does NOT survive surplus distribution when no '*'
                # column exists (seen in both Firefox and Chrome).
                cols += E("col").str()
            elif w == "*":
                cols += E("col").str()
            else:
                share = float(w) if table_width == "100%" else float(w) / fractions
                cols += E("col").attr("style", f"width: {share * 100:g}%").str()
        layout = "" if any(w in ("fit", "f") for w in widths) else "table-layout: fixed; "
        # No blank line before </colgroup>: @document's insert_missing_ids
        # would wrap it as a stray <p> inside the table.
        return E("colgroup").body(cols.strip(), newline=False).str(), layout, table_width

    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()
        if self.fill_columns:
            # 'fill': the table sizes itself -- the browser's auto layout
            # computes min(available, widest entries) natively, so the fill
            # column grows only until nothing needs a line break.  Several
            # fill columns share in proportion to their content (the auto
            # algorithm), matching the tex \ratio division.  The max-width
            # cap goes on the caption wrapper when there is one (the table's
            # own percentage would be circular in a shrink-to-fit wrapper).
            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, hpos=self.hpos,
                    side=self.caption_side,
                    font_size=self.caption_font_size, max_width="100%",
                    offset=self.offset)
            else:
                result.attr("style", "max-width: 100%")
                result = html_util.hpos_container(result, self.hpos, self.offset).str()
            return result
        colgroup, layout, table_width = self.html_colgroup()
        result = E("table").body(colgroup + result)
        if self.number or self.caption:
            # The caption wrapper carries the width (a percentage on the
            # shrink-to-fit wrapper itself would be circular) and the table
            # fills it -- which also makes the caption track the table.
            if table_width:
                result.attr("style", f"{layout}width: 100%")
            result = html_util.add_caption(
                result, "Table", self.number, self.caption, self.caption_font,
                hpos=self.hpos, side=self.caption_side,
                font_size=self.caption_font_size, width=table_width,
                offset=self.offset)
        else:
            # An uncaptioned table still gets the position container, so
            # html and tex agree on where the table sits.
            if table_width:
                result.attr("style", f"{layout}width: {table_width}")
            result = html_util.hpos_container(result, self.hpos, self.offset).str()
        return result

    # LaTeX

    # The length registers holding computed 'fill' column widths, declared
    # in table.sty; one per fill column, in column order.
    fill_registers = ["\\klfilla", "\\klfillb", "\\klfillc", "\\klfilld"]

    def tex_fill_widths(self):
        # Set each 'fill' column's register to min(its share of the
        # remaining width, its widest entry) -- the same rule the html auto
        # layout applies.  The shares divide the remaining width in
        # proportion to the columns' widest entries (calc's \ratio): either
        # the space covers them all and every column caps at its widest
        # entry, or no column caps and all the space is used -- no stranded
        # whitespace, and no iterative redistribution.
        if not self.fill_columns:
            return ""
        widths = extend(self.column_width, self.row_size)
        fit = [f"\\widthof{{{self.column_widths[i]}}}"
               for i, w in enumerate(widths) if w in ("fit", "f")]
        remaining = "\\tablewidth" + "".join(f" - {e}" for e in fit)
        widest = {i: f"\\widthof{{{self.column_widths[i]}}}"
                  for i in self.fill_columns}
        total = " + ".join(widest[i] for i in self.fill_columns)
        result = ""
        for k, i in enumerate(self.fill_columns):
            reg = Table.fill_registers[k]
            if len(self.fill_columns) == 1:
                result += (f"\\setlength{{{reg}}}"
                           f"{{\\minof{{{remaining}}}{{{widest[i]}}}}}\n")
            else:
                result += (f"\\setlength{{{reg}}}{{({remaining})"
                           f"*\\ratio{{{widest[i]}}}{{{total}}}}}\n")
                result += (f"\\setlength{{{reg}}}"
                           f"{{\\minof{{{reg}}}{{{widest[i]}}}}}\n")
        return result

    def tex_hpos(self):
        # One column specification per column: the width comes from
        # :column_width ('fit', 'fill' via its precomputed register, 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 = []
        fill_ordinal = 0
        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 == "fill":
                widths.append(Table.fill_registers[fill_ordinal])
                fill_ordinal += 1
            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] = "|"
        # A lineless table edge drops its outer \tabcolsep (matched by the
        # \tablewidth arithmetic in tex()).
        if self.flush_l:
            parts[0] = "@{}"
        if self.flush_r:
            parts[-1] = "@{}"
        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_position(self):
        # Position a PAGE-BREAKING table with longtable's own glue (it
        # cannot be boxed).  A boxed table (allow_break false) is positioned
        # by its :hpos wrapper instead; its glue is left neutral (\fill on
        # both sides collapses in the exactly-fitting box), because a fixed
        # length would overflow the box.  A length is the left margin.
        # :offset insets the table from the margin :hpos names -- the same
        # rule the boxed path gets from latex_util.caption_wrapper, here
        # expressed as the fixed side of the glue pair.
        inset = latex_util.offset_length(self.offset)
        if not self.allow_break:
            left, right = "\\fill", "\\fill"
        elif self.hpos == "center":
            left, right = "\\fill", "\\fill"
        elif self.hpos == "left":
            left, right = inset, "\\fill"
        elif self.hpos == "right":
            left, right = "\\fill", inset
        elif self.hpos == "none":
            # No positioning container: the table starts at the text margin
            # like ordinary text, which is what "none" does in html (the
            # hpos_none class is inline-flex).  A longtable cannot flow
            # inline, so flush left is as close as the target gets.  The
            # offset names no margin here and is ignored, as when centered.
            left, right = "0pt", "\\fill"
        else:
            length, _, _ = kutil.parse_length("tex", self.hpos, 1)
            left, right = length, "\\fill"
        return (f"\\setlength{{\\LTleft}}{{{left}}}\n"
                f"\\setlength{{\\LTright}}{{{right}}}\n")

    def tex_width_check(self, name):
        # Emit a marker into the xelatex log when the measured table is
        # wider than the text column (2pt tolerance for exactly-full-width
        # tables).  tex_to_pdf() in document.cpp scans the log for the
        # marker and prints the console warning with the :column_width
        # primer -- the widths are only known at LaTeX run time, and prose
        # kept out of TeX avoids the log's 79-column line wrapping.
        return ("\\ifdim\\tableboxwidth>\\dimexpr\\textwidth+2pt\\relax\n"
                f"\\message{{^^JKT-WIDE-TABLE {name} overfull by "
                "\\the\\dimexpr\\tableboxwidth-\\textwidth\\relax^^J}\n"
                "\\fi\n")

    def tex(self):
        name = f"Reference-Table-{Table.id}"
        Table.id += 1
        # The measuring \savebox must stay OUTSIDE the \tableboxwidth
        # minipage below: it computes the width the minipage consumes.
        measure = self.get_width() + self.tex_width_check(name)
        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:
            # Box the table at its measured width so the caption tracks it
            # and the box can be positioned as one piece; the LT glue then
            # has no room and positioning falls to the :hpos wrapper.  (The
            # page-breaking table cannot be boxed -- there the LT glue
            # positions and make_caption's \multicolumn tracks.)
            result = latex_util.minipage(
                result, "\\tableboxwidth", vertical="t", center=False)
            if self.number or self.caption:
                result = latex_util.add_caption(
                    result, "Table", self.number, self.caption, "\\tableboxwidth",
                    hpos=self.hpos, side=self.caption_side,
                    font_symbol=self.caption_font,
                    font_size=self.caption_font_size, offset=self.offset)
            else:
                result = latex_util.caption_wrapper(result, self.hpos, offset=self.offset)
        result = measure + result

        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"
                  + self.tex_position() +
                  # 2 \tabcolsep per column, minus the ones @{} removes at
                  # flush (lineless) edges.
                  f"\\setlength{{\\tablewidth}}{{\\textwidth - "
                  f"{2 * self.row_size - self.flush_l - self.flush_r}\\tabcolsep}}\n"
                  # Fill widths need \tablewidth and must precede the
                  # measuring \savebox, whose column spec reads them.
                  + self.tex_fill_widths()
                  + result)
        result = re.sub(r"\newline", r"\\\\", result)
        # A boxed table is box material: it must sit in vertical mode or it
        # is typeset beside any text it follows.  (A page-breaking longtable
        # breaks the paragraph itself, but \par on both sides is a no-op
        # there, so the rule stays unconditional.)
        return latex_util.block(result)
    
# This should signal that it has not be implemented by not being defined.
#    def txt(self):
#        return "Table in .txt format not implemented"

