diff --git a/conservancy_beancount/reports/accrual.py b/conservancy_beancount/reports/accrual.py index 3d8535b4f835ab605831360f7f41a19c673e4378..2a4dec933ed1bf6056a5bd798c0834e47c4dd56e 100644 --- a/conservancy_beancount/reports/accrual.py +++ b/conservancy_beancount/reports/accrual.py @@ -309,8 +309,13 @@ class AgingODS(core.BaseODS[AccrualPostings, Optional[data.Account]]): for accrual_type in AccrualAccount: self.use_sheet(accrual_type.name.title()) for index in range(self.COL_COUNT): - stylename = self.style_col1_25 if index else '' - self.sheet.addElement(odf.table.TableColumn(stylename=stylename)) + if index == 0: + style: Union[str, odf.style.Style] = '' + elif index < 6: + style = self.column_style(1.2) + else: + style = self.column_style(1.5) + self.sheet.addElement(odf.table.TableColumn(stylename=style)) self.add_row(*( self.string_cell(name, stylename=self.style_bold) for name in self.COLUMNS diff --git a/conservancy_beancount/reports/core.py b/conservancy_beancount/reports/core.py index 8e05c23ec4efc3ebc797506e03c4d4c1d8cea863..a55d4479fa6865afc88255ac64e4d7659bf4f9f3 100644 --- a/conservancy_beancount/reports/core.py +++ b/conservancy_beancount/reports/core.py @@ -463,6 +463,21 @@ class BaseODS(BaseSpreadsheet[RT, ST], metaclass=abc.ABCMeta): See also the BaseSpreadsheet base class for additional documentation about methods you must and can define, the definition of RT and ST, etc. """ + # Defined in the XSL spec, "Definitions of Units of Measure" + MEASUREMENT_UNITS = frozenset([ + 'cm', + 'em', + 'in', + 'mm', + 'pc', + 'pt', + 'px', + ]) + MEASUREMENT_RE = re.compile( + r'([-+]?(?:\d+\.?|\.\d+|\d+\.\d+))({})'.format('|'.join(MEASUREMENT_UNITS)), + re.ASCII, + ) + def __init__(self, rt_wrapper: Optional[rtutil.RT]=None) -> None: self.rt_wrapper = rt_wrapper self.locale = babel.core.Locale.default('LC_MONETARY') @@ -576,6 +591,29 @@ class BaseODS(BaseSpreadsheet[RT, ST], metaclass=abc.ABCMeta): ### Styles + 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' + match = self.MEASUREMENT_RE.fullmatch(width) + if match is None: + raise ValueError(f"invalid width {width!r}") + width_float = float(match.group(1)) + if width_float <= 0: + # Per the OpenDocument spec, column-width is a positiveLength. + raise ValueError(f"width {width!r} must be positive") + width = '{:.3g}{}'.format(width_float, match.group(2)) + retval = self.ensure_child( + self.document.automaticstyles, + odf.style.Style, + name=f'col_{width.replace(".", "_")}' + ) + retval.setAttribute('family', 'table-column') + if retval.firstChild is None: + retval.addElement(odf.style.TableColumnProperties( + columnwidth=width, **attrs + )) + return retval + def _build_currency_style( self, root: odf.element.Element, @@ -875,20 +913,6 @@ class BaseODS(BaseSpreadsheet[RT, ST], metaclass=abc.ABCMeta): aligned_style.addElement(odf.style.ParagraphProperties(textalign=textalign)) setattr(self, f'style_{textalign}text', aligned_style) - self.style_col1: odf.style.Style - self.style_col1_25: odf.style.Style - self.style_col1_5: odf.style.Style - self.style_col1_75: odf.style.Style - self.style_col2: odf.style.Style - for width in ['1', '1.25', '1.5', '1.75', '2']: - width_name = width.replace('.', '_') - column_style = self.replace_child( - self.document.automaticstyles, odf.style.Style, name=f'col_{width_name}', - ) - column_style.setAttribute('family', 'table-column') - column_style.addElement(odf.style.TableColumnProperties(columnwidth=f'{width}in')) - setattr(self, f'style_col{width_name}', column_style) - ### Rows and cells def add_row(self, *cells: odf.table.TableCell, **attrs: Any) -> odf.table.TableRow: diff --git a/conservancy_beancount/reports/ledger.py b/conservancy_beancount/reports/ledger.py index 2c2460972ed2b87a1f4ed4e3bdbc7341a5170066..b69c18020ac4118fa8fc85291304d4ea9c3eef1e 100644 --- a/conservancy_beancount/reports/ledger.py +++ b/conservancy_beancount/reports/ledger.py @@ -62,6 +62,7 @@ from typing import ( Sequence, TextIO, Tuple, + Union, ) from pathlib import Path @@ -101,11 +102,6 @@ class LedgerODS(core.BaseODS[data.Posting, data.Account]): ('Assets', ['rt-id', 'receipt', 'approval', 'bank-statement']), ('Liabilities', ['rt-id', 'receipt', 'approval', 'bank-statement']), ]) - COLUMN_STYLES: Mapping[str, str] = { - 'Date': '', - 'Description': 'col_1_75', - data.Metadata.human_name('paypal-id'): 'col_1_5', - } # Excel 2003 was limited to 65,536 rows per worksheet. # While we can probably count on all our users supporting more modern # formats (Excel 2007 supports over 1 million rows per worksheet), @@ -130,6 +126,19 @@ class LedgerODS(core.BaseODS[data.Posting, data.Account]): self.required_sheet_names = sheet_names self.sheet_size = sheet_size + def init_styles(self) -> None: + super().init_styles() + self.amount_column = self.column_style(1.2) + self.default_column = self.column_style(1.5) + self.column_styles: Mapping[str, Union[str, odf.style.Style]] = { + 'Date': '', + 'Description': self.column_style(2), + 'Original Amount': self.amount_column, + 'Booked Amount': self.amount_column, + data.Metadata.human_name('project'): self.amount_column, + data.Metadata.human_name('rt-id'): self.amount_column, + } + @classmethod def _group_tally( cls, @@ -249,7 +258,7 @@ class LedgerODS(core.BaseODS[data.Posting, data.Account]): ] for col_name in self.sheet_columns: self.sheet.addElement(odf.table.TableColumn( - stylename=self.COLUMN_STYLES.get(col_name, 'col_1_25'), + stylename=self.column_styles.get(col_name, self.default_column), )) self.add_row(*( self.string_cell(col_name, stylename=self.style_bold) @@ -341,13 +350,8 @@ class LedgerODS(core.BaseODS[data.Posting, data.Account]): if balance_accounts[0] not in self.required_sheet_names: balance_accounts[0] = 'Equity:Funds' self.use_sheet("Balance") - column_style = self.replace_child( - self.document.automaticstyles, odf.style.Style, name='col_3', - ) - column_style.setAttribute('family', 'table-column') - column_style.addElement(odf.style.TableColumnProperties(columnwidth='3in')) - for _ in range(2): - self.sheet.addElement(odf.table.TableColumn(stylename=column_style)) + self.sheet.addElement(odf.table.TableColumn(stylename=self.column_style(3))) + self.sheet.addElement(odf.table.TableColumn(stylename=self.column_style(1.5))) self.add_row( self.string_cell("Account", stylename=self.style_bold), self.string_cell("Balance", stylename=self.style_bold), diff --git a/setup.py b/setup.py index 689a28a816599ce9be0d5508d07e3196e16096c6..594df7e545f1a2529bb5c805c90c9aed25553561 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup setup( name='conservancy_beancount', description="Plugin, library, and reports for reading Conservancy's books", - version='1.2.3', + version='1.2.4', author='Software Freedom Conservancy', author_email='info@sfconservancy.org', license='GNU AGPLv3+', diff --git a/tests/test_reports_spreadsheet.py b/tests/test_reports_spreadsheet.py index d27ee3c608939a79fb5e9fbbce448aae496fe5fe..b14ec45c522452b4873e2bbebcefc2390dd343e8 100644 --- a/tests/test_reports_spreadsheet.py +++ b/tests/test_reports_spreadsheet.py @@ -223,6 +223,44 @@ def test_ods_writer_use_sheet_discards_unused_sheets(ods_writer): assert len(sheets) == 1 assert sheets[0].getAttribute('name') == 'One' +@pytest.mark.parametrize('width,expect_name', [ + ('.750', 'col_0_75in'), + (2, 'col_2in'), + ('2.2in', 'col_2_2in'), + (3.5, 'col_3_5in'), + ('4cm', 'col_4cm'), +]) +def test_ods_column_style(ods_writer, width, expect_name): + style = ods_writer.column_style(width) + assert style.getAttribute('name') == expect_name + assert style.getAttribute('family') == 'table-column' + curr_style = get_child( + ods_writer.document.automaticstyles, + odf.style.Style, + name=expect_name, + ) + assert get_child( + curr_style, + odf.style.TableColumnProperties, + columnwidth=expect_name[4:].replace('_', '.'), + ) + +def test_ods_column_style_caches(ods_writer): + int_width = ods_writer.column_style('1in') + float_width = ods_writer.column_style('1.00in') + assert int_width is float_width + +@pytest.mark.parametrize('width', [ + '1mi', + '0in', + '-1cm', + 'in', + '.cm', +]) +def test_ods_column_style_invalid_width(ods_writer, width): + with pytest.raises(ValueError): + ods_writer.column_style(width) + @pytest.mark.parametrize('currency_code', [ 'USD', 'EUR', @@ -261,11 +299,6 @@ def test_ods_currency_style_cache_considers_properties(ods_writer): assert plain.getAttribute('datastylename') != bold.getAttribute('datastylename') @pytest.mark.parametrize('attr_name,child_type,checked_attr', [ - ('style_col1', odf.style.TableColumnProperties, 'columnwidth'), - ('style_col1_25', odf.style.TableColumnProperties, 'columnwidth'), - ('style_col1_5', odf.style.TableColumnProperties, 'columnwidth'), - ('style_col1_75', odf.style.TableColumnProperties, 'columnwidth'), - ('style_col2', odf.style.TableColumnProperties, 'columnwidth'), ('style_bold', odf.style.TextProperties, 'fontweight'), ('style_centertext', odf.style.ParagraphProperties, 'textalign'), ('style_dividerline', odf.style.TableCellProperties, 'borderbottom'), @@ -273,10 +306,7 @@ def test_ods_currency_style_cache_considers_properties(ods_writer): ('style_starttext', odf.style.ParagraphProperties, 'textalign'), ]) def test_ods_writer_style(ods_writer, attr_name, child_type, checked_attr): - if child_type is odf.style.TableColumnProperties: - root = ods_writer.document.automaticstyles - else: - root = ods_writer.document.styles + root = ods_writer.document.styles style = getattr(ods_writer, attr_name) actual = get_child(root, odf.style.Style, name=style.getAttribute('name')) assert actual is style