"""Fit annual tables after their values and footnote markers have been filled.""" from functools import lru_cache from math import ceil from pathlib import Path import re from PIL import ImageFont from docx.oxml import OxmlElement from docx.oxml.ns import qn from .table import set_table_font_size @lru_cache(maxsize=2) def _font(bold=False): # The same bundled metrics are available locally and in Spaces. DejaVu Sans # is slightly wider than Arial, leaving a margin for Word font substitution. from matplotlib import get_data_path name = 'DejaVuSans-Bold.ttf' if bold else 'DejaVuSans.ttf' return ImageFont.truetype(str(Path(get_data_path()) / 'fonts' / 'ttf' / name), 1000) @lru_cache(maxsize=8192) def _width(text, bold=False): return _font(bold).getlength(text) / 1000 def _lines(text, width, size, bold=False): """Estimate wrapping using glyph widths, preserving explicit line breaks.""" count = 0 for line in text.split('\n'): used = 0 count += 1 for word in re.findall(r'\S+', line): length = _width(word, bold) * size gap = _width(' ', bold) * size if used else 0 if used and used + gap + length > width: count += 1 used = 0 gap = 0 if length > width: extra = max(0, ceil(length / width) - 1) count += extra used = length - extra * width else: used += gap + length return count def _margin(cell, side, default): margins = cell._tc.get_or_add_tcPr().find(qn('w:tcMar')) node = margins.find(qn('w:' + side)) if margins is not None else None return int(node.get(qn('w:w'), default)) / 20 if node is not None else default / 20 def fit_table_to_page(doc, table, notes=()): """Select the largest half-point size <= 8 that fits the populated table. Currency, numbers and year labels must fit on one line. Other text can wrap. Height includes cell padding, the caption allowance and 7 pt footnotes. This is a conservative estimate: Word remains responsible for final pagination. The years always remain columns of the same table, even for longer tables. """ section = doc.sections[-1] page_width = (section.page_width - section.left_margin - section.right_margin) / 12700 page_height = (section.page_height - section.top_margin - section.bottom_margin) / 12700 note_height = sum(_lines(str(note), page_width, 7) * 8 + 4 for note in notes) available_height = max(1, page_height - 30 - note_height) rows = [] for row in table.rows: row_props = row._tr.get_or_add_trPr() if row_props.find(qn('w:cantSplit')) is None: row_props.append(OxmlElement('w:cantSplit')) cells = [] for cell in row.cells: props = cell._tc.get_or_add_tcPr() for node in list(props.findall(qn('w:noWrap'))): props.remove(node) width = max(1, cell.width / 12700 - _margin(cell, 'left', 80) - _margin(cell, 'right', 80) - 2) paragraphs = [] for paragraph in cell.paragraphs: marker_count = len(paragraph._p.findall('.//' + qn('w:footnoteReference'))) text = paragraph.text + '1' * marker_count bold = any(run.bold for run in paragraph.runs) paragraphs.append((text, bold, paragraph)) cells.append((width, _margin(cell, 'top', 65) + _margin(cell, 'bottom', 65), paragraphs)) rows.append(cells) def fits(size): height = 0 for cells in rows: row_height = 0 for width, padding, paragraphs in cells: cell_height = padding for text, bold, paragraph in paragraphs: # Do not split an amount, a date, a coefficient or IPTU year # into fragments to make the width calculation succeed. single_line = bool(re.fullmatch(r'(?:R\$\s*)?[−+\-]?\d[\d.,%/\s]*|IPTU\s+\d{4}', text.strip())) if single_line and _width(text, bold) * size > width: return False cell_height += _lines(text, width, size, bold) * size * 1.15 cell_height += (paragraph.paragraph_format.space_before or 0) / 12700 cell_height += (paragraph.paragraph_format.space_after or 0) / 12700 row_height = max(row_height, cell_height) height += row_height return height <= available_height # w:sz stores integer half-points: smaller steps would be rounded away. size = 8 while size > 0.5 and not fits(size): size -= 0.5 set_table_font_size(table, size) keep_together = fits(size) for row_index, row in enumerate(table.rows): for cell in row.cells: for paragraph in cell.paragraphs: paragraph.paragraph_format.line_spacing = 1.05 paragraph.paragraph_format.keep_with_next = keep_together and row_index < len(table.rows) - 1 paragraph.paragraph_format.keep_together = True return size