diff --git a/conservancy_beancount/reports/core.py b/conservancy_beancount/reports/core.py index c1e1a31bda0adfa37469cde75a22a77d203dd503..4003a85dc6135b66239b6098d394f0958988986d 100644 --- a/conservancy_beancount/reports/core.py +++ b/conservancy_beancount/reports/core.py @@ -18,6 +18,7 @@ import abc import collections import copy import datetime +import enum import itertools import operator import re @@ -518,6 +519,14 @@ class BaseSpreadsheet(Generic[RT, ST], metaclass=abc.ABCMeta): self.end_spreadsheet() +class Border(enum.IntFlag): + TOP = 1 + RIGHT = 2 + BOTTOM = 4 + LEFT = 8 + # in CSS order, clockwise from top + + class BaseODS(BaseSpreadsheet[RT, ST], metaclass=abc.ABCMeta): """Abstract base class to help write OpenDocument spreadsheets @@ -548,7 +557,7 @@ class BaseODS(BaseSpreadsheet[RT, ST], metaclass=abc.ABCMeta): self.locale = babel.core.Locale.default('LC_MONETARY') self.currency_fmt_key = 'accounting' self._name_counter = itertools.count(1) - self._currency_style_cache: MutableMapping[str, odf.style.Style] = {} + self._style_cache: MutableMapping[str, odf.style.Style] = {} self.document = odf.opendocument.OpenDocumentSpreadsheet() self.init_settings() self.init_styles() @@ -666,6 +675,32 @@ class BaseODS(BaseSpreadsheet[RT, ST], metaclass=abc.ABCMeta): ### Styles + def border_style(self, + edges: int, + width: str='1px', + style: str='solid', + color: str='#000000', + ) -> odf.style.Style: + flags = [edge for edge in Border if edges & edge] + if not flags: + raise ValueError(f"no valid edges in {edges!r}") + border_attr = f'{width} {style} {color}' + key = f'{",".join(f.name for f in flags)} {border_attr}' + try: + retval = self._style_cache[key] + except KeyError: + props = odf.style.TableCellProperties() + for flag in flags: + props.setAttribute(f'border{flag.name.lower()}', border_attr) + retval = odf.style.Style( + name=f'Border{next(self._name_counter)}', + family='table-cell', + ) + retval.addElement(props) + self.document.styles.addElement(retval) + self._style_cache[key] = retval + return retval + def column_style(self, width: Union[float, str], **attrs: Any) -> odf.style.Style: if not isinstance(width, str) or (width and not width[-1].isalpha()): width = f'{width}in' @@ -785,7 +820,7 @@ class BaseODS(BaseSpreadsheet[RT, ST], metaclass=abc.ABCMeta): cache_parts.append(f'{key}={value}') cache_key = '\0'.join(cache_parts) try: - style = self._currency_style_cache[cache_key] + style = self._style_cache[cache_key] except KeyError: pos_style = self._build_currency_style( root, locale, code, 0, positive_properties, volatile=True, @@ -803,7 +838,7 @@ class BaseODS(BaseSpreadsheet[RT, ST], metaclass=abc.ABCMeta): family='table-cell', datastylename=curr_style, ) - self._currency_style_cache[cache_key] = style + self._style_cache[cache_key] = style return style def _merge_style_iter_names( diff --git a/tests/test_reports_spreadsheet.py b/tests/test_reports_spreadsheet.py index 34dee426838c45c9d6f570e14995780349e9188b..68709396e35e219fb0d5cdd801c26a19db6784fd 100644 --- a/tests/test_reports_spreadsheet.py +++ b/tests/test_reports_spreadsheet.py @@ -313,6 +313,46 @@ def test_ods_writer_style(ods_writer, attr_name, child_type, checked_attr): child = get_child(actual, child_type) assert child.getAttribute(checked_attr) +@pytest.mark.parametrize('edges,width,style,color', [ + (core.Border.TOP, + '5px', 'solid', '#ff0000'), + (core.Border.RIGHT | core.Border.LEFT, + '2pt', 'dashed', '#00ff00'), + (core.Border.BOTTOM | core.Border.RIGHT | core.Border.LEFT, + '1em', 'dotted', '#0000ff'), + (core.Border.TOP | core.Border.BOTTOM | core.Border.RIGHT | core.Border.LEFT, + '1cm', 'thick', '#aaaaaa'), +]) +def test_ods_writer_border_style(ods_writer, edges, width, style, color): + actual = ods_writer.border_style(edges, width, style, color) + props, = actual.childNodes + attr_s = f'{width} {style} {color}' + for edge_exp, edge_name in enumerate(['top', 'right', 'bottom', 'left']): + expected = attr_s if edges & (2 ** edge_exp) else None + assert props.getAttribute(f'border{edge_name}') == expected + +def test_ods_writer_border_style_caches(ods_writer): + expected = ods_writer.border_style(core.Border.TOP) + width, style, color = expected.childNodes[0].getAttribute('bordertop').split() + actual = ods_writer.border_style(core.Border.TOP, width, style, color) + assert actual is expected + +@pytest.mark.parametrize('argname,val1,val2', [ + ('edges', core.Border.TOP, core.Border.LEFT), + ('edges', core.Border.TOP, core.Border.TOP | core.Border.BOTTOM), + ('style', 'solid', 'dashed'), + ('width', '1px', '1em'), + ('width', '1px', '2px'), + ('color', '#0000fe', '#0000ff'), +]) +def test_ods_writer_border_no_caching(ods_writer, argname, val1, val2): + kwargs = {'edges': core.Border.TOP} + kwargs[argname] = val1 + style1 = ods_writer.border_style(**kwargs) + kwargs[argname] = val2 + style2 = ods_writer.border_style(**kwargs) + assert style1 is not style2 + def test_ods_writer_merge_styles(ods_writer): style = ods_writer.merge_styles(ods_writer.style_bold, ods_writer.style_dividerline) actual = get_child(