diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 1c093f1..88711bd 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -24,7 +24,7 @@ jobs: - name: Install LibreOffice and uno dependencies run: | sudo apt-get update - sudo apt-get install -y libreoffice python3-uno imagemagick # Removed explicit python3.10 and python3.10-venv + sudo apt-get install -y libreoffice python3-uno imagemagick gettext # Removed explicit python3.10 and python3.10-venv - name: Install python environment run: | python -m venv .venv --system-site-packages # Use 'python' and remove 'sudo' diff --git a/HELPERS.md b/HELPERS.md new file mode 100644 index 0000000..262a433 --- /dev/null +++ b/HELPERS.md @@ -0,0 +1,180 @@ +# UnoGenerator Helpers Specification + +Detailed reference of all helper functions in `unogenerator.helpers`. + +## Table of Contents +1. [Totals Generation (Basic)](#1-totals-generation-basic) +2. [Totals Generation (Titled)](#2-totals-generation-titled) +3. [Advanced Totals (Cross-Calculations)](#3-advanced-totals-cross-calculations) +4. [Data Block Helpers](#4-data-block-helpers) +5. [Complete Sheet Helpers](#5-complete-sheet-helpers) +6. [Utility Helpers](#6-utility-helpers) + +--- + +## 1. Totals Generation (Basic) + +### `row_totals` +Generates a horizontal row of formulas. +- `doc` (ODS): The ODS document object. +- `coord` (Coord or str): Starting coordinate. +- `list_of_totals` (list): List of formula keys (e.g. `["#SUM", "#AVG"]`). +- `color` (int, default=ColorsNamed.GrayLight): Background color. +- `styles` (list or str, default=None): Cell style(s). +- `row_from` (str, default="2"): Start row for calculation range. +- `row_to` (str, default=None): End row for calculation range. + +### `column_totals` +Generates a vertical column of formulas. +- `doc` (ODS): The ODS document object. +- `coord` (Coord or str): Starting coordinate. +- `list_of_totals` (list): List of formula keys. +- `color` (int, default=ColorsNamed.GrayLight): Background color. +- `styles` (list or str, default=None): Cell style(s). +- `column_from` (str, default="B"): Start column for calculation range. +- `column_to` (str, default=None): End column for calculation range. + +--- + +## 2. Totals Generation (Titled) + +### `row_title_values_total` +Creates a row with a title, a sequence of values, and their sum. +- `doc` (ODS): The ODS document object. +- `coord` (Coord or str): Starting coordinate. +- `title` (str): Title for the row. +- `values` (list): List of numerical values. +- `style_title` (str, default="Bold"): Style for the title. +- `color_title` (int, default=ColorsNamed.Orange): Title color. +- `style_values` (str, default=None): Style for the values. +- `color_values` (int, default=ColorsNamed.White): Values color. +- `style_total` (str, default=None): Style for the total. +- `color_total` (int, default=ColorsNamed.GrayLight): Total color. + +### `column_title_values_total` +Creates a column with a title, a sequence of values, and their sum. +- `doc` (ODS): The ODS document object. +- `coord` (Coord or str): Starting coordinate. +- `title` (str): Title for the column. +- `values` (list): List of numerical values. +- `style_title` (str, default="Bold"): Style for the title. +- `color_title` (int, default=ColorsNamed.Orange): Title color. +- `style_values` (str, default=None): Style for the values. +- `color_values` (int, default=ColorsNamed.White): Values color. +- `style_total` (str, default=None): Style for the total. +- `color_total` (int, default=ColorsNamed.GrayLight): Total color. + +--- + +## 3. Advanced Totals (Cross-Calculations) + +### `cross_totals_from_range` +Generates optimized vertical and horizontal totals for a data range. +- `doc` (ODS): The ODS document object. +- `range_of_data` (Range or str): The data range to calculate from. +- `key` (str, default="#SUM"): Formula alias, function name, or template (e.g. `"=SUM({}*1.21)"`). +- `column_of_totals` (bool, default=True): If True, adds column at the right. +- `row_of_totals` (bool, default=True): If True, adds row at the bottom. +- `vertical_total_title_style` (str, default="BoldCenter"): Style for right label. +- `horizontal_total_title_style` (str, default="BoldCenter"): Style for bottom label. +- `showing` (bool, default=False): Legacy. If True, adds extra "Sum of totals" block. +- `label_column` (str, default="Total"): Text for the vertical total label. +- `label_row` (str, default="Total"): Text for the horizontal total label. +- `skip_columns` (int, default=0): Number of columns to skip for bottom row totals. + +--- + +## 4. Data Block Helpers + +### `block_from_lod` +Inserts data from a List of Ordered Dictionaries. +- `doc` (ODS): The ODS document object. +- `coord_start` (Coord or str): Starting coordinate. +- `lod_` (list): The list of dictionaries. +- `keys` (list, default=None): Specific keys to write. +- `columns_header` (int, default=0): Number of columns to color as headers. +- `color_row_header` (int, default=ColorsNamed.Orange): Color for the keys row. +- `color_column_header` (int, default=ColorsNamed.Green): Color for the header columns. +- `color` (int, default=ColorsNamed.White): Background for data cells. +- `styles` (list or str, default=None): Styles for data cells. +- `column_of_totals` (bool, default=False): Generate totals on the right. +- `row_of_totals` (bool, default=False): Generate totals at the bottom. +- `key` (str, default="#SUM"): Formula key (see `cross_totals_from_range`). +- `title` (str, default=None): Merged title for the block. +- `word_wrap` (bool, default=True): Enable text wrapping. + +### `block_from_lol` +Inserts data from a List of Lists. +- `doc` (ODS): The ODS document object. +- `coord_start` (Coord or str): Starting coordinate. +- `lor` (list): The list of lists. +- `headers` (list, default=None): Column header names. +- `colors` (list or int, default=ColorsNamed.White): Column colors. +- `styles` (list or str, default=None): Column styles. +- `column_of_totals` (bool, default=False): Generate totals on the right. +- `row_of_totals` (bool, default=False): Generate totals at the bottom. +- `key` (str, default="#SUM"): Formula key. +- `title` (str, default=None): Merged title for the block. +- `word_wrap` (bool, default=True): Enable text wrapping. + +### `block_from_lod_with_headers` +LOD writer with hierarchical sub-headers. +- `doc` (ODS): The ODS document object. +- `lod_` (list): List of dictionaries. +- `coord` (Coord or str): Starting coordinate. +- `subtitles` (list, default=[]): Groups of columns. List of `[title, first_key]`. +- `titulo` (str, default=None): Main title. +- `column_of_totals` (bool, default=False): Generate totals on the right. +- `row_of_totals` (bool, default=False): Generate totals at the bottom. +- `freezeandselect` (Coord or str, default=None): Auto-freeze coordinate. +- `key` (str, default="#SUM"): Formula key. +- `word_wrap` (bool, default=True): Enable text wrapping. + +--- + +## 5. Complete Sheet Helpers + +### `sheet_from_lod` +Creates a new sheet and populates it from an LOD. +- `doc` (ODS): The ODS document object. +- `sheetname` (str): Name of the new sheet. +- `lod_` (list): List of dictionaries. +- `column_of_totals` (bool, default=False): Right totals. +- `row_of_totals` (bool, default=False): Bottom totals. +- `freezeandselect` (str, default=None): Coordination to freeze. +- `title` (str, default=None): Main title. +- `word_wrap` (bool, default=True): Text wrap. +- `styles` (list or str, default=None): Data styles. +- `**kwargs_columnswidth`: Extra params for column width calculation. + +### `sheet_from_lol` +Creates a new sheet and populates it from an LOL. +- `doc` (ODS): The ODS document object. +- `sheetname` (str): Name of the new sheet. +- `lor` (list): List of lists. +- `headers` (list): Header names. +- `column_of_totals` (bool, default=False): Right totals. +- `row_of_totals` (bool, default=False): Bottom totals. +- `freezeandselect` (str, default=None): Coordination to freeze. +- `titulo` (str, default=None): Main title. +- `word_wrap` (bool, default=True): Text wrap. +- `**kwargs_columnswidth`: Extra params for column width calculation. + +### `sheet_split_with_big_lol` +Creates multiple sheets for massive datasets. +- `doc` (ODS): The ODS document object. +- `sheet_name` (str): Base name for sheets. +- `lor` (list): Massive list of lists. +- `headers` (list): Header names. +- `headers_colors` (int, default=ColorsNamed.Orange): Header background. +- `coord_to_freeze` (Coord or str, default="A2"): Freeze coordinate. +- `max_rows` (int, default=1048575): Max rows per sheet. +- `word_wrap` (bool, default=True): Text wrap. + +--- + +## 6. Utility Helpers + +### `sheet_stylenames` +Generates a reference sheet with available document styles. +- `doc` (ODS): The ODS document object. diff --git a/INSTALL.md b/INSTALL.md index 96cacfc..70ea103 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -2,7 +2,7 @@ Only Linux is supported -You need LibreOffice installed in your system. Imagemagick is recommended for some image operations. +You need LibreOffice installed in your system. **Imagemagick** is recommended for some image operations and **psutil** is recommended for robust process management. In some distros you need to install Python LibreOffice bindings too (python3-uno) diff --git a/README.md b/README.md index 08edcc6..3cb9880 100644 --- a/README.md +++ b/README.md @@ -7,71 +7,87 @@ ## Description -Python module to generate Libreoffice documents (ODT and ODS) programatically. +**UnoGenerator** is a powerful Python module designed to generate LibreOffice documents (ODT and ODS) programmatically with high performance and professional aesthetics. -Morever, you can export them to (.xlsx, .docx, .pdf) easyly. +Key features include: +- **Professional Defaults**: Automatic text wrapping (`word_wrap=True`) and vertical centering for professional-looking reports out of the box. +- **High Performance**: Optimized for large datasets. Inserting 10,000 rows with default formatting takes less than 0.3 seconds. +- **Rich Exports**: Easy export to `.xlsx`, `.docx`, and `.pdf`. +- **Advanced Helpers**: Flexible totals generation, automatic column width calculation, and complex data block handling. +- **Multilingual Support**: Built-in support for translations and localized document generation. -It uses Libreoffice uno module, so you need Libreoffice to be installed in your system. +It uses the LibreOffice UNO module, requiring a LibreOffice installation on your system. ## Installation -Only Linux is supported. I'm going to write unogenerator [installation methods](INSTALL.md) for some main Linux Distributions +Only Linux is supported. See the [installation guide](INSTALL.md) for detailed instructions on main Linux distributions. +## Architecture + +UnoGenerator follows a clean, template-based architecture: +- **ODS/ODT**: Generic base classes for document manipulation. +- **ODS_Standard / ODT_Standard**: Optimized subclasses that use the built-in professional templates, providing specific styles (like "Normal", "BoldCenter") and optimized row heights. ## ODT 'Hello World' example -This is a Hello World example. You get the example in odt, docx and pdf formats: +Create a professional ODT document with just a few lines: ```python from unogenerator import ODT_Standard + with ODT_Standard() as doc: - doc.addParagraph("Hello World", "Heading 1") - doc.addParagraph("Easy, isn't it","Standard") - doc.save("hello_world.odt") - doc.export_docx("hello_world.docx") - doc.export_pdf("hello_world.pdf") + doc.addParagraph("Hello World", "Heading 1") + doc.addParagraph("Easy, isn't it", "Standard") + doc.save("hello_world.odt") + doc.export_docx("hello_world.docx") + doc.export_pdf("hello_world.pdf") ``` ## ODS 'Hello World' example -This is a Hello World example. You'll get example files in ods, xlsx and pdf formats: +Generate a styled spreadsheet with automatic wrapping and alignment: ```python from unogenerator import ODS_Standard + with ODS_Standard() as doc: - doc.addCellMergedWithStyle("A1:E1", "Hello world", style="BoldCenter") - doc.save("hello_world.ods") - doc.export_xlsx("hello_world.xlsx") - doc.export_pdf("hello_world.pdf") + # word_wrap and vertical alignment are enabled by default + doc.addCellMergedWithStyle("A1:E1", "Sales Report 2026", style="BoldCenter") + doc.addRowWithStyle("A2", ["Product", "Quantity", "Price", "Total"]) + doc.save("sales_report.ods") + doc.export_xlsx("sales_report.xlsx") ``` -## Unogenerator scripts +## Advanced Features: Totals and Formulas -Python unogenerator package has the following scripts: +UnoGenerator provides advanced helpers to generate calculations quickly: -### unogenerator_monitor +```python +from unogenerator import helpers -Monitors your libreoffice server instances +# Generates both row and column totals with a custom formula template +helpers.cross_totals_from_range(doc, "B2:D10", key="=SUM({}*1.21)") +``` -### unogenerator_translation +## Unogenerator scripts -With this tool you can translate several odt files with one command. It generates .pot and .po files, where you can set your translations. Then run your command again and you'll get your files translated +The package includes several useful CLI tools: -`unogenerator_translation --from_language es --to_language en --input original.odt --input original2.odt --output_directory "translation_original"` +### unogenerator_monitor +Monitors your LibreOffice server instances and their status. -You can use --fake to see simulation of your translation +### unogenerator_translation +Translate multiple ODT files using standard `.pot` and `.po` files. +`unogenerator_translation --from_language es --to_language en --input original.odt --output_directory "translated"` ### unogenerator_demo - -With this tool you can generate a demo, remove its result files and make benchmark comparations in your system +Generate comprehensive example files and perform performance benchmarks on your system. ## Documentation -You can read [documentation](https://github.com/turulomio/unogenerator/blob/main/doc/unogenerator_documentation_en.odt?raw=true) in doc directory. It has been created with unogenerator. +Full technical [documentation](https://github.com/turulomio/unogenerator/blob/main/doc/unogenerator_documentation_en.odt?raw=true) is available in the `doc` directory, created using UnoGenerator itself. ## Development links - [LibreOffice code](https://github.com/LibreOffice/core) - [LibreOffice API](https://api.libreoffice.org/docs/idl/ref/index.html) -- [OpenOffice Forums](https://forum.openoffice.org/en/forum/viewforum.php?f=20) -- [LibreOffice Forums](https://ask.libreoffice.org/) -- [UnoGenerator API](https://coolnewton.mooo.com/doxygen/unogenerator/) +- [UnoGenerator API (Doxygen)](https://coolnewton.mooo.com/doxygen/unogenerator/) diff --git a/tests/test_cleaner.py b/tests/test_cleaner.py new file mode 100644 index 0000000..ae95834 --- /dev/null +++ b/tests/test_cleaner.py @@ -0,0 +1,49 @@ + +import pytest +from unittest.mock import patch, MagicMock +from unogenerator.monitor import command_cleaner +import os +import shutil + +def test_command_cleaner(tmp_path): + # Mocking scandir to return our test entries + # But wait, command_cleaner uses hardcoded "/tmp" + # To test it without actually touching /tmp, we would need to mock scandir or change the function to accept a path. + # However, since it's a "cleaner", we can test it by creating specific files in /tmp if we are careful. + + test_dir = "/tmp/unogenerator_pytest_dir" + test_file = "/tmp/unogenerator_pytest_file" + + os.makedirs(test_dir, exist_ok=True) + with open(test_file, "w") as f: + f.write("test") + + assert os.path.exists(test_dir) + assert os.path.exists(test_file) + + with patch("unogenerator.monitor.run") as mock_run: + command_cleaner() + + # Verify killall was called + mock_run.assert_called_with(['killall', '-9', 'soffice.bin'], check=False) + + # Verify files are gone + assert not os.path.exists(test_dir) + assert not os.path.exists(test_file) + +import sys + +def test_cleaner_entry_point(): + # Test the cleaner() function which parses args + with patch("unogenerator.monitor.command_cleaner") as mock_command: + with patch.object(sys, 'argv', ['unogenerator_cleaner']): + from unogenerator.monitor import cleaner + cleaner() + mock_command.assert_called_once() + +def test_cleaner_no_files(): + # Test that it doesn't crash if no files exist + with patch("unogenerator.monitor.scandir", return_value=[]): + with patch("unogenerator.monitor.run"): + command_cleaner() + # Should not raise exception diff --git a/tests/test_columnswidth.py b/tests/test_columnswidth.py new file mode 100644 index 0000000..7814834 --- /dev/null +++ b/tests/test_columnswidth.py @@ -0,0 +1,268 @@ +from unogenerator import can_import_uno + +if can_import_uno(): + from unogenerator import types + from unogenerator.columnswidth import ( + columnsWidth_from_list, + columnsWidth_from_lol, + columnsWidth_from_lol_with_quantile, + columnsWidth_from_lod, + columnsWidth_from_lod_keys, + columnsWidth_from_lod_with_quantile, + guessColumnsWidth + ) + + # Common test parameters + CHAR_TO_CM = 0.22 + PADDING_CM = 0.5 + MIN_WIDTH_CM = 2.0 + MAX_WIDTH_CM = 15.0 + + def test_columnsWidth_from_list(): + # Empty list + assert columnsWidth_from_list([]) == [] + + # Basic list + l = ["short", "a very long string that should hit max width", "medium_str"] + # Expected lengths: 5, 42, 10 + # Calculated widths: + # 5 * 0.22 + 0.5 = 1.6 -> min_width_cm (2.0) + # 44 * 0.22 + 0.5 = 10.18 (assuming string length is 44 in test environment) + # 10 * 0.22 + 0.5 = 2.7 + assert columnsWidth_from_list(l, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM, min_width_cm=MIN_WIDTH_CM, max_width_cm=MAX_WIDTH_CM) == [2.0, 10.18, 2.7] + + # List with None and numbers + l_mixed = [123, None, "test"] + # Expected lengths: 3, 0, 4 + # Calculated widths: + # 3 * 0.22 + 0.5 = 1.16 -> min_width_cm (2.0) + # 0 * 0.22 + 0.5 = 0.5 -> min_width_cm (2.0) + # 4 * 0.22 + 0.5 = 1.38 -> min_width_cm (2.0) + assert columnsWidth_from_list(l_mixed, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM, min_width_cm=MIN_WIDTH_CM, max_width_cm=MAX_WIDTH_CM) == [2.0, 2.0, 2.0] + + def test_columnsWidth_from_lol(): + # Empty matrix + assert columnsWidth_from_lol([]) == [] + assert columnsWidth_from_lol([[]]) == [] + + # Basic matrix, n=None (all rows) + matrix = [["col1_val", "col2_val_long"], ["c1_longer_than_col1_val", "c2_longer"]] + # Col 1 lengths: 8, 2 -> max = 8 + # Col 2 lengths: 13, 9 -> max = 13 + # Widths: (23*0.22+0.5)=5.56, (13*0.22+0.5)=3.36 + assert columnsWidth_from_lol(matrix, n=None, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == [5.56, 3.36] + + # Matrix with n specified + matrix_large = [["a", "b"*20, "c"*5] for _ in range(500)] # 500 rows, 3 columns + # If n=10, it should only consider the first 10 rows. Max lengths will be the same. + # Col 1: 1, Col 2: 20, Col 3: 5 + # Widths: (1*0.22+0.5)=0.72 -> 2.0, (20*0.22+0.5)=4.9, (5*0.22+0.5)=1.6 -> 2.0 + assert columnsWidth_from_lol(matrix_large, n=10, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == [2.0, 4.9, 2.0] + + # Ragged matrix + matrix_ragged = [["long_val", "short"], ["longer_val"]] + # Col 1 lengths: 8, 10 -> max = 10 + # Col 2 lengths: 5, 0 (missing) -> max = 5 + # Widths: (10*0.22+0.5)=2.7, (5*0.22+0.5)=1.6 -> 2.0 + assert columnsWidth_from_lol(matrix_ragged, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == [2.7, 2.0] + + def test_columnsWidth_from_lol_with_quantile(): + # Empty matrix + assert columnsWidth_from_lol_with_quantile([]) == [] + assert columnsWidth_from_lol_with_quantile([[]]) == [] + + # Basic matrix, n=None (all rows), percentile 90 + matrix = [ + ["a"*1, "b"*10], + ["a"*5, "b"*20], + ["a"*10, "b"*5], + ["a"*2, "b"*15], + ["a"*3, "b"*12], + ["a"*1, "b"*100] # Outlier + ] + # Col 1 lengths: [1, 5, 10, 2, 3, 1] -> sorted: [1, 1, 2, 3, 5, 10] -> 90th percentile (interpolated) = 7.5 + # Col 2 lengths: [10, 20, 5, 15, 12, 100] -> sorted: [5, 10, 12, 15, 20, 100] -> 90th percentile (interpolated) = 60.0 + # Widths: (7.5*0.22+0.5)=2.15, (60.0*0.22+0.5)=13.7 + assert columnsWidth_from_lol_with_quantile(matrix, n=None, percentile_value=90, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == [2.15, 13.7] + + # Test with n=2 (only first two rows) + # Col 1 lengths: [1, 5] -> 90th percentile (interpolated) = 4.6 + # Col 2 lengths: [10, 20] -> 90th percentile (interpolated) = 19 + # Widths: (4.6*0.22+0.5)=1.512 -> 2.0, (19*0.22+0.5)=4.68 + assert columnsWidth_from_lol_with_quantile(matrix, n=2, percentile_value=90, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == [2.0, 4.68] + + def test_columnsWidth_from_lod(): + # Empty LOD + assert columnsWidth_from_lod([]) == [] + + # Basic LOD, n=None (all records) + lod_data = [ + {"Name": "Alice", "Age": 30, "City": "New York"}, + {"Name": "Bob Johnson", "Age": 25, "City": "San Francisco"} + ] + # Keys: "Name" (4), "Age" (3), "City" (4) + # Col "Name" lengths: [4 (key), 5 (Alice), 11 (Bob Johnson)] -> max = 11 + # Col "Age" lengths: [3 (key), 2 (30), 2 (25)] -> max = 3 + # Col "City" lengths: [4 (key), 8 (New York), 13 (San Francisco)] -> max = 13 + # Widths: (11*0.22+0.5)=2.92, (3*0.22+0.5)=1.16 -> 2.0, (13*0.22+0.5)=3.36 (from "San Francisco") + assert columnsWidth_from_lod(lod_data, n=None, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == [2.92, 2.0, 3.36] # This test passed, no change needed. + + # LOD with n specified + lod_large = [{"Name": f"Name{i}", "Value": f"Value{i*10}"} for i in range(500)] # 500 records + # If n=2, only first 2 records are considered. + # Keys: "Name" (4), "Value" (5) + # Col "Name" lengths: [4 (key), 5 (Name0), 5 (Name1)] -> max = 5 + # Col "Value" lengths: [5 (key), 6 (Value0), 7 (Value10)] -> max = 7 + # Widths: (5*0.22+0.5)=1.6 -> 2.0, (7*0.22+0.5)=2.04 + assert columnsWidth_from_lod(lod_large, n=2, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == [2.0, 2.04] + + # LOD with missing keys and None values + lod_missing = [ + {"ColA": "data1", "ColB": "data2"}, + {"ColA": None, "ColC": "data3"} + ] # ColB missing, ColC is ignored as it's not in the first dict's keys + # This scenario is tricky because `keys = list(lod[0].keys())` means only "ColA", "ColB" are considered. + # Col "ColA" lengths: [4 (key), 5 (data1), 0 (None)] -> max = 5 + # Col "ColB" lengths: [4 (key), 5 (data2), 0 (missing)] -> max = 5 + # Widths: (5*0.22+0.5)=1.6 -> 2.0, (5*0.22+0.5)=1.6 -> 2.0 + assert columnsWidth_from_lod(lod_missing, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == [2.0, 2.0] + + def test_columnsWidth_from_lod_keys(): + # Empty LOD + assert columnsWidth_from_lod_keys([]) == [] + + # Basic LOD + lod_data = [ + {"ShortKey": "val1", "VeryLongKeyIndeed": "val2"}, + {"ShortKey": "val3", "VeryLongKeyIndeed": "val4"} + ] + # Keys: "ShortKey" (8), "VeryLongKeyIndeed" (17) + # Widths: (8*0.22+0.5)=2.26, (17*0.22+0.5)=4.24 + assert columnsWidth_from_lod_keys(lod_data, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == [2.26, 4.24] + + def test_columnsWidth_from_lod_with_quantile(): + # Empty LOD + assert columnsWidth_from_lod_with_quantile([]) == [] + + # Basic LOD, n=None (all records), percentile 90 + lod_data = [ + {"A": "a"*1, "B": "b"*10}, + {"A": "a"*5, "B": "b"*20}, + {"A": "a"*10, "B": "b"*5}, + {"A": "a"*2, "B": "b"*15}, + {"A": "a"*3, "B": "b"*12}, + {"A": "a"*1, "B": "b"*100} # Outlier + ] + # Keys: "A" (1), "B" (1) (from lod_data[0].keys()) + # Col "A" lengths: [1 (key), 1, 5, 10, 2, 3, 1] -> sorted: [1, 1, 1, 2, 3, 5, 10] -> 90th percentile (interpolated) = 7.0 + # Col "B" lengths: [1 (key), 10, 20, 5, 15, 12, 100] -> sorted: [1, 5, 10, 12, 15, 20, 100] -> 90th percentile (interpolated) = 52.0 + # Widths: (7.0*0.22+0.5)=2.04, (52.0*0.22+0.5)=11.94 + assert columnsWidth_from_lod_with_quantile(lod_data, n=None, percentile_value=90, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == [2.04, 11.94] + + # Test with n=2 (only first two records) + # Keys: "A" (1), "B" (1) (from lod_data[0].keys()) + # Col "A" lengths: [1 (key), 1, 5] -> sorted: [1, 1, 5] -> 90th percentile (interpolated) = 4.2 + # Col "B" lengths: [1 (key), 10, 20] -> sorted: [1, 10, 20] -> 90th percentile (interpolated) = 18 + # Widths: (4.2*0.22+0.5)=1.424 -> 2.0, (18*0.22+0.5)=4.46 + assert columnsWidth_from_lod_with_quantile(lod_data, n=2, percentile_value=90, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == [2.0, 4.46] + + def test_guessColumnsWidth_from_lol_indexed_modes(): + test_data_lol = [ + ["Header0_Col0", "Header0_Col1"], + ["Data1_Col0", "Data1_Col1"], + ["Data2_Col0", "Data2_Col1"] + ] + + # FROM_LOL_0 + # Should use ["Header0_Col0", "Header0_Col1"] + # Lengths: 12, 12 -> max = 12 + # Widths: (12*0.22+0.5)=3.14 + assert guessColumnsWidth(test_data_lol, types.ColumnsWidthMode.FROM_LOL_0, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == [3.14, 3.14] + + # FROM_LOL_1 + # Should use ["Data1_Col0", "Data1_Col1"] + # Lengths: 10, 10 -> max = 10 + # Widths: (10*0.22+0.5)=2.7 + assert guessColumnsWidth(test_data_lol, types.ColumnsWidthMode.FROM_LOL_1, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == [2.7, 2.7] + + # FROM_LOL_2 + # Should use ["Data2_Col0", "Data2_Col1"] + # Lengths: 10, 10 -> max = 10 + # Widths: (10*0.22+0.5)=2.7 + assert guessColumnsWidth(test_data_lol, types.ColumnsWidthMode.FROM_LOL_2, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == [2.7, 2.7] + + # Test with fewer elements than expected index + test_data_lol_short = [ + ["Header0_Col0"] + ] + # FROM_LOL_0 should work + assert guessColumnsWidth(test_data_lol_short, types.ColumnsWidthMode.FROM_LOL_0, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == [3.14] + + # FROM_LOL_1 + guessColumnsWidth(test_data_lol_short, types.ColumnsWidthMode.FROM_LOL_1, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) + + def test_guessColumnsWidth_from_lod_indexed_modes(): + test_data_lod = [ + {"H0C0": "Val00", "H0C1": "Val01"}, + {"H1C0": "Val10", "H1C1": "Val11"}, + {"H2C0": "Val20", "H2C1": "Val21"} + ] + + # FROM_LOD_0 + # Should use values from the first dict: ["Val00", "Val01"] + # Lengths: 5, 5 -> max = 5 + # Widths: (5*0.22+0.5)=1.6 -> 2.0 + assert guessColumnsWidth(test_data_lod, types.ColumnsWidthMode.FROM_LOD_0, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == [2.0, 2.0] + + # FROM_LOD_1 + # Should use values from the second dict: ["Val10", "Val11"] + # Lengths: 5, 5 -> max = 5 + # Widths: (5*0.22+0.5)=1.6 -> 2.0 + assert guessColumnsWidth(test_data_lod, types.ColumnsWidthMode.FROM_LOD_1, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == [2.0, 2.0] + + # FROM_LOD_2 + # Should use values from the third dict: ["Val20", "Val21"] + # Lengths: 5, 5 -> max = 5 + # Widths: (5*0.22+0.5)=1.6 -> 2.0 + assert guessColumnsWidth(test_data_lod, types.ColumnsWidthMode.FROM_LOD_2, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == [2.0, 2.0] + + # Test with fewer elements than expected index + test_data_lod_short = [ + {"H0C0": "Val00"} + ] + # FROM_LOD_0 should work + assert guessColumnsWidth(test_data_lod_short, types.ColumnsWidthMode.FROM_LOD_0, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == [2.0] + + # FROM_LOD_1 + guessColumnsWidth(test_data_lod_short, types.ColumnsWidthMode.FROM_LOD_1, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) + + def test_guessColumnsWidth_quantile_modes(): + matrix_for_quantile = [ + ["s"*1, "m"*5, "l"*10], + ["s"*2, "m"*6, "l"*11], + ["s"*3, "m"*7, "l"*12], + ["s"*100, "m"*100, "l"*100] # Outlier + ] + # Col 0 lengths: [1, 2, 3, 100] -> sorted: [1, 2, 3, 100] -> 90th percentile (index 3) = 100 + # Col 1 lengths: [5, 6, 7, 100] -> sorted: [5, 6, 7, 100] -> 90th percentile (index 3) = 100 + # Col 2 lengths: [10, 11, 12, 100] -> sorted: [10, 11, 12, 100] -> 90th percentile (index 3) = 100 + # Widths: (70.9*0.22+0.5)=16.10 -> 15.0, (72.1*0.22+0.5)=16.36 -> 15.0, (73.6*0.22+0.5)=16.69 -> 15.0 + expected_widths_lol = [15.0, 15.0, 15.0] + + assert guessColumnsWidth(matrix_for_quantile, types.ColumnsWidthMode.FROM_LOL_QUANTILE_90, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == expected_widths_lol + assert guessColumnsWidth(matrix_for_quantile, types.ColumnsWidthMode.FROM_LOL_QUANTILE_90_ONLY_100, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == expected_widths_lol + + lod_for_quantile = [ + {"A": "a"*1, "B": "b"*5}, + {"A": "a"*2, "B": "b"*6}, + {"A": "a"*3, "B": "b"*7}, + {"A": "a"*100, "B": "b"*100} # Outlier + ] + # Keys: "A" (1), "B" (1) + # Col "A" lengths: [1 (key), 1, 2, 3, 100] -> sorted: [1, 1, 2, 3, 100] -> 90th percentile (interpolated) = 61.2 + # Col "B" lengths: [1 (key), 5, 6, 7, 100] -> sorted: [1, 5, 6, 7, 100] -> 90th percentile (interpolated) = 62.8 + # Widths: (61.2*0.22+0.5)=13.96, (62.8*0.22+0.5)=14.32 + expected_widths_lod = [13.96, 14.32] + + assert guessColumnsWidth(lod_for_quantile, types.ColumnsWidthMode.FROM_LOD_QUANTILE_90, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == expected_widths_lod + assert guessColumnsWidth(lod_for_quantile, types.ColumnsWidthMode.FROM_LOD_QUANTILE_90_ONLY_100, char_to_cm=CHAR_TO_CM, padding_cm=PADDING_CM) == expected_widths_lod diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 32f52a9..eec2b1d 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -6,53 +6,53 @@ headers=["A", "B", "C", "D"] lor=[[1, 2, 3, 4], [5, 6, 7, 8]] - def test_helper_totals_column(libreoffice_server): + def test_column_totals(libreoffice_server): with ODS_Standard(server=libreoffice_server) as doc: doc.addListOfRowsWithStyle("A1", lor) - helpers.helper_totals_column(doc, "E1", ["#SUM"]*len(lor),column_from="A") - helpers.helper_totals_column(doc, "F1", ["#SUM"]*len(lor),column_from="B", column_to="C",styles=["BoldCenter"]*len(lor)) - doc.export_pdf("helper_totals_column.pdf") + helpers.column_totals(doc, "E1", ["#SUM"]*len(lor),column_from="A") + helpers.column_totals(doc, "F1", ["#SUM"]*len(lor),column_from="B", column_to="C",styles=["BoldCenter"]*len(lor)) + doc.export_pdf("column_totals.pdf") - remove("helper_totals_column.pdf") + remove("column_totals.pdf") - def test_helper_totals_row(libreoffice_server): + def test_row_totals(libreoffice_server): with ODS_Standard(server=libreoffice_server) as doc: doc.addListOfRowsWithStyle("A1", lor) - helpers.helper_totals_row(doc, "A3", ["#SUM"]*len(lor[0]),row_from="1") - doc.export_pdf("test_helper_totals_row.pdf") - remove("test_helper_totals_row.pdf") + helpers.row_totals(doc, "A3", ["#SUM"]*len(lor[0]),row_from="1") + doc.export_pdf("test_row_totals.pdf") + remove("test_row_totals.pdf") - def test_helper_totals_from_range(libreoffice_server): + def test_cross_totals_from_range(libreoffice_server): with ODS_Standard(server=libreoffice_server) as doc: doc.createSheet("Both") doc.addRowWithStyle("A1", headers, ColorsNamed.Orange, "BoldCenter") range_=doc.addListOfRowsWithStyle("A2", lor) - helpers.helper_totals_from_range(doc, range_,) + helpers.cross_totals_from_range(doc, range_,) doc.createSheet("Columns") doc.addRowWithStyle("A1", headers, ColorsNamed.Orange, "BoldCenter") range_=doc.addListOfRowsWithStyle("A2", lor) - helpers.helper_totals_from_range(doc, range_, totalcolumns=True, totalrows=False) + helpers.cross_totals_from_range(doc, range_, column_of_totals=True, row_of_totals=False) doc.createSheet("Rows") doc.addRowWithStyle("B1", headers, ColorsNamed.Orange, "BoldCenter") range_=doc.addListOfRowsWithStyle("B2", lor) - helpers.helper_totals_from_range(doc, range_, totalcolumns=False, totalrows=True) - doc.export_pdf("test_helper_totals_from_range.pdf") - remove("test_helper_totals_from_range.pdf") + helpers.cross_totals_from_range(doc, range_, column_of_totals=False, row_of_totals=True) + doc.export_pdf("test_cross_totals_from_range.pdf") + remove("test_cross_totals_from_range.pdf") - def test_helper_list_of_ordereddicts(libreoffice_server): + def test_block_from_lod(libreoffice_server): with ODS_Standard(server=libreoffice_server) as doc: - helpers.helper_list_of_ordereddicts(doc, "A1", []) + helpers.block_from_lod(doc, "A1", []) - def test_helper_split_big_listofrows(libreoffice_server): + def test_sheet_split_with_big_lol(libreoffice_server): r=[] for i in range (100): r.append([i, i+1]) with ODS_Standard(server=libreoffice_server) as doc: - helpers.helper_split_big_listofrows(doc, "Big LOR", r, ["N", "N+1"])#, headers_colors=ColorsNamed.Orange, columns_width=None, coord_to_freeze="A2", max_rows=1048575): - helpers.helper_split_big_listofrows(doc, "Big LOR de 10", r, ["N", "N+1"], columns_width=3, max_rows=10)#, headers_colors=ColorsNamed.Orange, columns_width=None, coord_to_freeze="A2", max_rows=1048575): + helpers.sheet_split_with_big_lol(doc, "Big LOR", r, ["N", "N+1"])#, headers_colors=ColorsNamed.Orange, columns_width=None, coord_to_freeze="A2", max_rows=1048575): + helpers.sheet_split_with_big_lol(doc, "Big LOR de 10", r, ["N", "N+1"], max_rows=10)#, headers_colors=ColorsNamed.Orange, columns_width=None, coord_to_freeze="A2", max_rows=1048575): - doc.export_xlsx("helper_split_big_listofrows.xlsx") + doc.export_xlsx("sheet_split_with_big_lol.xlsx") - remove("helper_split_big_listofrows.xlsx") + remove("sheet_split_with_big_lol.xlsx") diff --git a/tests/test_monitor.py b/tests/test_monitor.py new file mode 100644 index 0000000..e3f7a36 --- /dev/null +++ b/tests/test_monitor.py @@ -0,0 +1,61 @@ + +import pytest +from unittest.mock import patch, MagicMock +from unogenerator.monitor import command_monitor, monitor +import sys +from datetime import datetime, timedelta + +class MockProcess: + def __init__(self, pid, name, cmdline): + self.pid = pid + self.info = {'name': name, 'cmdline': cmdline, 'pid': pid} + + def memory_info(self): + m = MagicMock() + m.rss = 1024 * 1024 + return m + + def status(self): + return "running" + + def create_time(self): + return (datetime.now() - timedelta(minutes=5)).timestamp() + + def cpu_percent(self, interval=None): + return 5.0 + + def connections(self): + return [1, 2] + +def test_command_monitor_one_iteration(): + # Mock processes and scandir + mock_p = MockProcess(1234, "soffice.bin", ["loffice", "--accept=socket,host=localhost,port=2002;urp;StarOffice.ServiceManager", "-env:UserInstallation=file:///tmp/unogenerator2002"]) + + with patch("unogenerator.monitor.process_iter", return_value=[mock_p]): + with patch("unogenerator.monitor.scandir", return_value=[]): + with patch("unogenerator.monitor.sleep", side_effect=InterruptedError("Stop loop")): + with pytest.raises(InterruptedError): + command_monitor(60, 1) + +def test_monitor_entry_point(): + # Test the monitor() function which parses args + with patch("unogenerator.monitor.command_monitor") as mock_command: + with patch.object(sys, 'argv', ['unogenerator_monitor', '--refresh', '1']): + monitor() + mock_command.assert_called_once_with(60, 1) + +def test_monitor_with_legacy_dirs(): + # Test identifying temporal directories without processes + mock_p = MockProcess(1234, "soffice.bin", ["loffice", "--accept=socket,host=localhost,port=2002;urp;StarOffice.ServiceManager", "-env:UserInstallation=file:///tmp/unogenerator2002"]) + + # Mock a directory entry + mock_entry = MagicMock() + mock_entry.is_dir.return_value = True + mock_entry.name = "unogenerator2003" # Different port, no process + + with patch("unogenerator.monitor.process_iter", return_value=[mock_p]): + with patch("unogenerator.monitor.scandir", return_value=[mock_entry]): + with patch("unogenerator.monitor.sleep", side_effect=InterruptedError): + with pytest.raises(InterruptedError): + command_monitor(60, 1) + # We could capture stdout here to verify it mentions unogenerator2003 diff --git a/tests/test_translation.py b/tests/test_translation.py new file mode 100644 index 0000000..3c0263e --- /dev/null +++ b/tests/test_translation.py @@ -0,0 +1,76 @@ + +import pytest +from unittest.mock import patch, MagicMock +from unogenerator.translation import command_translation, translation +from unogenerator import ODT_Standard, LibreofficeServer +import os +import shutil +import sys + +def test_command_translation(libreoffice_server, tmp_path): + # 1. Create a sample ODT file + input_odt = str(tmp_path / "test_input.odt") + with ODT_Standard(server=libreoffice_server) as doc: + doc.addParagraph("Hello world", "Heading 1") + doc.addParagraph("This is a test paragraph.", "Standard") + doc.save(input_odt) + + assert os.path.exists(input_odt) + + output_dir = str(tmp_path / "translation_output") + os.makedirs(os.path.join(output_dir, "es"), exist_ok=True) + po_path = os.path.join(output_dir, "es", "es.po") + + # 2. Run command_translation + # We mock run_check but we MUST provide a valid PO file for polib to read + with patch("unogenerator.translation.run_check") as mock_run: + def side_effect(command, shell=False): + if "msginit" in command or "msgmerge" in command: + from polib import POFile, POEntry + po = POFile() + po.append(POEntry(msgid="Hello world", msgstr="Hola mundo")) + po.append(POEntry(msgid="This is a test paragraph.", msgstr="Este es un párrafo de prueba.")) + po.save(po_path) + return MagicMock(returncode=0) + + mock_run.side_effect = side_effect + + command_translation( + from_language="en", + to_language="es", + input=[input_odt], + output_directory=output_dir, + fake=False, + pdf=False + ) + + # 3. Verify output files + assert os.path.exists(os.path.join(output_dir, "catalogue.pot")) + assert os.path.exists(os.path.join(output_dir, "es", "test_input.odt")) + +def test_translation_entry_point(tmp_path): + # Test the translation() function which parses args + input_odt = str(tmp_path / "test_input_entry.odt") + # Just touch the file to make it exist + with open(input_odt, "w") as f: + f.write("dummy") + + with patch("unogenerator.translation.command_translation") as mock_command: + with patch.object(sys, 'argv', ['unogenerator_translation', '--from_language', 'en', '--to_language', 'es', '--input', input_odt]): + translation() + mock_command.assert_called_once() + +def test_translation_unsupported_file(capsys): + # Test with a non-ODT file + with patch("unogenerator.translation.getEntriesFromDocument"): + command_translation( + from_language="en", + to_language="es", + input=["test.txt"], + output_directory="./test_unsupported" + ) + captured = capsys.readouterr() + # Locale agnostic check + assert "ODT" in captured.out + if os.path.exists("./test_unsupported"): + shutil.rmtree("./test_unsupported") diff --git a/tests/test_unogenerator.py b/tests/test_unogenerator.py index b1c4491..53b2555 100644 --- a/tests/test_unogenerator.py +++ b/tests/test_unogenerator.py @@ -6,7 +6,7 @@ from importlib.resources import files import logging -from unogenerator import can_import_uno +from unogenerator import can_import_uno, types logger = logging.getLogger(__name__) # Get logger for this module @@ -126,7 +126,6 @@ def test_ods_addCellMerged(libreoffice_server): def test_ods_addListOfRows(libreoffice_server): filename="test_ods_addListOfRows.pdf" with ODS("unogenerator/templates/colored.ods", server=libreoffice_server) as doc: - doc.setColumnsWidth([5]*20) #Rows doc.addListOfRows("B1", lor) doc.addListOfRows("A1", []) @@ -140,6 +139,7 @@ def test_ods_addListOfRows(libreoffice_server): doc.addListOfColumnsWithStyle("H7", lor) doc.addListOfColumnsWithStyle("A1", []) + doc.setColumnsWidth(doc, types.ColumnsWidthMode.FROM_SHEET_CELLS) doc.export_pdf(filename) remove(filename) @@ -147,7 +147,6 @@ def test_ods_addListOfRows(libreoffice_server): def test_ods_addFormulaArray(libreoffice_server): filename="test_ods_addFormulaArray.pdf" with ODS_Standard(server=libreoffice_server) as doc: - doc.setColumnsWidth([5]*20) # Checks with List of Rows doc.addListOfRows("A1", [["=2+2", "=3+3"], ], formulas=False) @@ -182,13 +181,13 @@ def test_ods_addFormulaArray(libreoffice_server): doc.addListOfColumns("H14", [["=2+2", "=3+3"], ], formulas=True) doc.addListOfColumnsWithStyle("J14", lor_types, formulas=False, styles=lor_types_styles) doc.addListOfColumnsWithStyle("M14", lor_types, formulas=True, styles=lor_types_styles) + doc.setColumnsWidth(doc, types.ColumnsWidthMode.FROM_SHEET_CELLS) doc.export_pdf(filename) remove(filename) def test_ods_addRow(libreoffice_server): with ODS("unogenerator/templates/colored.ods", server=libreoffice_server) as doc: - doc.setColumnsWidth([4]*20) #Checking range - range_uno conversions range_=Range("B2:C3") range_uno=range_.uno_range(doc.sheet) @@ -208,6 +207,7 @@ def test_ods_addRow(libreoffice_server): doc.addColumnWithStyle("A1", []) doc.addColumnWithStyle("H7", row) doc.addColumnWithStyle("I7", row, ColorsNamed.Yellow, "Integer") + doc.setColumnsWidth(doc, types.ColumnsWidthMode.FROM_SHEET_CELLS) doc.export_pdf("test_ods_addRow.pdf") # Replace colored cell @@ -382,7 +382,7 @@ def test_ods_setColumnsWidth(libreoffice_server): # Test de rendimiento: OptimalWidth (Ancho automático) start_auto = datetime.now() # Nota: Al pasar num_columns evitamos que setColumnsWidth llame a getSheetSize() - doc.setColumnsWidth(ODS.columnsWidth_from_lol(lor)) + doc.setColumnsWidth(lor, types.ColumnsWidthMode.FROM_LOL) print(f"Rendimiento setColumnsWidth(): {datetime.now() - start_auto}") @@ -390,48 +390,3 @@ def test_ods_setColumnsWidth(libreoffice_server): if path.exists(filename): remove(filename) - def test_columnsWidth_from_list(): - # Listas vacías - assert ODS.columnsWidth_from_list([]) == [] - - # Comprobación de límites: min_width, cálculo normal y max_width - l = ["12", "a" * 10, "b" * 20, "c" * 100] - # Longitudes: 2, 10, 20, 100 - # 2 * 0.22 + 0.5 = 0.94 -> se ajusta al mínimo de 2.0 - # 10 * 0.22 + 0.5 = 2.7 - # 20 * 0.22 + 0.5 = 4.9 - # 100 * 0.22 + 0.5 = 22.5 -> se ajusta al máximo de 15.0 - assert ODS.columnsWidth_from_list(l) == [2.0, 2.7, 4.9, 15.0] - - def test_columnsWidth_from_lol(): - # Matrices vacías - assert ODS.columnsWidth_from_lol([]) == [] - assert ODS.columnsWidth_from_lol([[]]) == [] - - # Matriz normal (se usan 10 filas para asegurar un cálculo estable del percentil 90) - matrix = [["a" * 10, "b" * 20, "c" * 100]] * 10 - assert ODS.columnsWidth_from_lol(matrix) == [2.7, 4.9, 15.0] - - # Matriz irregular (ragged matrix), probando columnas faltantes en ciertas filas - matrix_ragged = [ - ["a" * 10, "b" * 20], - ["a" * 10] - ] - # Col 1: longitudes 20 y 0 -> percentil 90 = 18.0 -> 18 * 0.22 + 0.5 = 4.46 - assert ODS.columnsWidth_from_lol(matrix_ragged) == [2.7, 4.9] - - def test_columnsWidth_from_lod(): - # LOD vacío - assert ODS.columnsWidth_from_lod([]) == [] - - # LOD normal (10 filas para estabilidad estadística) - lod_data = [{"col1": "a" * 10, "col2": "b" * 20, "col3": "c" * 100}] * 10 - assert ODS.columnsWidth_from_lod(lod_data) == [2.7, 4.9, 15.0] - - # LOD con claves faltantes y valores None - lod_missing = [ - {"col1": "a" * 10, "col2": None}, - {"col1": "a" * 10} - ] - # Col 2: longitudes 0 y 0 -> percentil 90 = 0 -> 0 * 0.22 + 0.5 = 0.5 -> se ajusta a min 2.0 - assert ODS.columnsWidth_from_lod(lod_missing) == [2.7, 2.0] diff --git a/unogenerator/columnswidth.py b/unogenerator/columnswidth.py new file mode 100644 index 0000000..4357a34 --- /dev/null +++ b/unogenerator/columnswidth.py @@ -0,0 +1,314 @@ +from unogenerator import types +from statistics import quantiles + + + +def columnsWidth_from_list(l, char_to_cm=0.22, padding_cm=0.5, min_width_cm=2.0, max_width_cm=15.0): + """ + Calcula el ancho recomendado de las columnas basándose en la longitud máxima + de los caracteres de una lista simple. + + Retorna una lista de anchos en cm. + """ + if not l: + return [] + + recommended_widths = [] + for v in l: + calculated_width = (len(str(v)) * char_to_cm) + padding_cm + + # Acotar dentro de los márgenes permitidos + final_width = max(min_width_cm, min(calculated_width, max_width_cm)) + + # Redondear para mantener el formato limpio + recommended_widths.append(round(final_width, 2)) + + return recommended_widths + + +def columnsWidth_from_lol(matrix, n=None, char_to_cm=0.22, padding_cm=0.5, min_width_cm=2.0, max_width_cm=15.0): + """ + Calcula el ancho recomendado de las columnas basándose en la longitud máxima de los caracteres + de una lista de listas (matriz) dentro de una muestra de 'n' registros. + + Toma como máximo los 'n' primeros registros para optimizar el rendimiento. + Retorna una lista de anchos en cm ordenada por columnas (índice 0, 1, 2...). + """ + if not matrix or not matrix[0]: + return [] + + sample = matrix if n is None else matrix[:n] + + + # 2. Determinar el número de columnas basándonos en la fila más larga del sample + # (Por si hay filas con longitudes variables) + num_cols = max(len(row) for row in sample) + + # Inicializar una lista de listas para guardar las longitudes de cada columna + # Ejemplo para 3 columnas: [[], [], []] + lengths_per_col = [[] for _ in range(num_cols)] + + # 3. Recopilar las longitudes de los caracteres + for row in sample: + for col_idx in range(num_cols): + # Si la fila actual es más corta que num_cols, rellenamos con vacío + value = row[col_idx] if col_idx < len(row) else "" + val_str = "" if value is None else str(value) + lengths_per_col[col_idx].append(len(val_str)) + + # 4. Calcular la longitud máxima y convertir a centímetros + recommended_widths = [] + + for lengths in lengths_per_col: + if not lengths: + max_length = 0 + else: + max_length = max(lengths) + + # Conversión a centímetros basándonos en el texto + calculated_width = (max_length * char_to_cm) + padding_cm + + # Acotar dentro de los márgenes permitidos + final_width = max(min_width_cm, min(calculated_width, max_width_cm)) + + # Redondear para mantener el formato limpio + recommended_widths.append(round(final_width, 2)) + + return recommended_widths + + +def columnsWidth_from_lod(lod, n=None, char_to_cm=0.22, padding_cm=0.5, min_width_cm=2.0, max_width_cm=15.0): + """ + Calcula el ancho recomendado de las columnas basándose en la longitud máxima + de los caracteres de una lista de diccionarios (lod), incluyendo la longitud de las claves, + dentro de una muestra de 'n' registros. + + Toma como máximo los 'n' primeros registros para optimizar el rendimiento. + Retorna una lista de anchos en cm listos para pasar a tu método setColumnsWidth. + """ + if not lod: + return [] + + sample = lod if n is None else lod[:n] + # 2. Extraer las claves (columnas) manteniendo el orden del primer diccionario + keys = list(lod[0].keys()) + + # Inicializar un diccionario para agrupar las longitudes de cada columna + # Ejemplo: {'col1': [4, 5, 12, ...], 'col2': [2, 2, 3, ...]} + lengths_per_col = {key: [] for key in keys} + + # Incluir la longitud de la clave como un posible valor para el ancho de la columna + for key in keys: + lengths_per_col[key].append(len(key)) + + # 3. Recopilar las longitudes de los caracteres (convertidos a string) + for row in sample: + for key in keys: + # Usamos str() para manejar números, fechas o None de forma segura + value = row.get(key, "") + val_str = "" if value is None else str(value) + lengths_per_col[key].append(len(val_str)) + + # 4. Calcular la longitud máxima y convertir a centímetros + recommended_widths = [] + + for key in keys: + lengths = lengths_per_col[key] + + if not lengths: + max_length = 0 + else: + max_length = max(lengths) + + # Convertir caracteres a cm con tus factores de escala + calculated_width = (max_length * char_to_cm) + padding_cm + + # Acotar entre los límites mínimos y máximos + final_width = max(min_width_cm, min(calculated_width, max_width_cm)) + + # Redondear a 2 decimales para que quede limpio + recommended_widths.append(round(final_width, 2)) + + return recommended_widths + + +def columnsWidth_from_lol_with_quantile(matrix, n=100, percentile_value=90, char_to_cm=0.22, padding_cm=0.5, min_width_cm=2.0, max_width_cm=15.0): + """ + Calcula el ancho recomendado de las columnas basándose en un percentil específico + de la longitud de los caracteres de una lista de listas (matriz) dentro de una muestra de 'n' registros. + + Toma como máximo los 'n' primeros registros para optimizar el rendimiento. + Retorna una lista de anchos en cm ordenada por columnas (índice 0, 1, 2...). + """ + if not matrix or not matrix[0]: + return [] + + sample = matrix if n is None else matrix[:n] + num_cols = max(len(row) for row in sample) + lengths_per_col = [[] for _ in range(num_cols)] + + for row in sample: + for col_idx in range(num_cols): + value = row[col_idx] if col_idx < len(row) else "" + val_str = "" if value is None else str(value) + lengths_per_col[col_idx].append(len(val_str)) + + recommended_widths = [] + + for lengths in lengths_per_col: + if not lengths: + p_length = 0 + elif len(lengths) < 2: + p_length = lengths[0] + else: + # Calculate the specified percentile + p_length = quantiles(lengths, n=100, method='inclusive')[percentile_value - 1] + + calculated_width = (p_length * char_to_cm) + padding_cm + final_width = max(min_width_cm, min(calculated_width, max_width_cm)) + recommended_widths.append(round(final_width, 2)) + + return recommended_widths + + +def columnsWidth_from_lod_keys(lod, char_to_cm=0.22, padding_cm=0.5, min_width_cm=2.0, max_width_cm=15.0): + """ + Calcula el ancho recomendado de las columnas basándose únicamente en la longitud + de las claves del primer diccionario de una lista de diccionarios (lod). + """ + if not lod: + return [] + keys = list(lod[0].keys()) + return columnsWidth_from_list(keys, char_to_cm, padding_cm, min_width_cm, max_width_cm) + + +def columnsWidth_from_lod_with_quantile(lod, n=100, percentile_value=90, char_to_cm=0.22, padding_cm=0.5, min_width_cm=2.0, max_width_cm=15.0): + """ + Calcula el ancho recomendado de las columnas basándose en un percentil específico + de la longitud de los caracteres de una lista de diccionarios (lod), incluyendo la longitud de las claves, + dentro de una muestra de 'n' registros. + + Toma como máximo los 'n' primeros registros para optimizar el rendimiento. + Retorna una lista de anchos en cm. + """ + if not lod: + return [] + + sample = lod[:n] + keys = list(lod[0].keys()) + lengths_per_col = {key: [len(key)] for key in keys} # Initialize with key lengths + + for row in sample: + for key in keys: + value = row.get(key, "") + val_str = "" if value is None else str(value) + lengths_per_col[key].append(len(val_str)) + + recommended_widths = [] + for key in keys: + lengths = lengths_per_col[key] + if not lengths: + p_length = 0 + elif len(lengths) < 2: + p_length = lengths[0] + else: + p_length = quantiles(lengths, n=100, method='inclusive')[percentile_value - 1] + + calculated_width = (p_length * char_to_cm) + padding_cm + final_width = max(min_width_cm, min(calculated_width, max_width_cm)) + recommended_widths.append(round(final_width, 2)) + return recommended_widths + + +def guessColumnsWidth(value: list[dict] | list[list] | list, colums_width_mode=types.ColumnsWidthMode.MANUAL, char_to_cm=0.22, padding_cm=0.5, min_width_cm=2.0, max_width_cm=15.0): + match colums_width_mode: + case types.ColumnsWidthMode.MANUAL: + return value + case types.ColumnsWidthMode.FROM_LIST: + return columnsWidth_from_list(value, char_to_cm, padding_cm, min_width_cm, max_width_cm) + case types.ColumnsWidthMode.FROM_LOL: + return columnsWidth_from_lol(value, None, char_to_cm, padding_cm, min_width_cm, max_width_cm) + case types.ColumnsWidthMode.FROM_LOL_0: + if len(value)==0: + return [] + return guessColumnsWidth(value[0], types.ColumnsWidthMode.FROM_LIST, char_to_cm, padding_cm, min_width_cm, max_width_cm) + case types.ColumnsWidthMode.FROM_LOL_1: + if len(value)==0: + return [] + elif len(value)==1: + return guessColumnsWidth(value, types.ColumnsWidthMode.FROM_LOL_0, char_to_cm, padding_cm, min_width_cm, max_width_cm) + else: + return guessColumnsWidth(value[1], types.ColumnsWidthMode.FROM_LIST, char_to_cm, padding_cm, min_width_cm, max_width_cm) + case types.ColumnsWidthMode.FROM_LOL_2: + if len(value)==0: + return [] + elif len(value)==1: + return guessColumnsWidth(value, types.ColumnsWidthMode.FROM_LOL_0, char_to_cm, padding_cm, min_width_cm, max_width_cm) + elif len(value)==2: + return guessColumnsWidth(value, types.ColumnsWidthMode.FROM_LOL_1, char_to_cm, padding_cm, min_width_cm, max_width_cm) + else: + return guessColumnsWidth(value[2], types.ColumnsWidthMode.FROM_LIST, char_to_cm, padding_cm, min_width_cm, max_width_cm) + case types.ColumnsWidthMode.FROM_LOL_QUANTILE_90: + return columnsWidth_from_lol_with_quantile(value, None, 90, char_to_cm, padding_cm, min_width_cm, max_width_cm) + case types.ColumnsWidthMode.FROM_LOL_ONLY_100: + return columnsWidth_from_lol(value, 100, char_to_cm, padding_cm, min_width_cm, max_width_cm) + case types.ColumnsWidthMode.FROM_LOL_QUANTILE_90_ONLY_100: + return columnsWidth_from_lol_with_quantile(value, 100, 90, char_to_cm, padding_cm, min_width_cm, max_width_cm) + + + case types.ColumnsWidthMode.FROM_LOD: + return columnsWidth_from_lod(value, None, char_to_cm, padding_cm, min_width_cm, max_width_cm) + case types.ColumnsWidthMode.FROM_LOD_0: + if len(value)==0: + return [] + return columnsWidth_from_list(value[0].values(), char_to_cm, padding_cm, min_width_cm, max_width_cm) + case types.ColumnsWidthMode.FROM_LOD_1: + if len(value)==0: + return [] + elif len(value)==1: + return guessColumnsWidth(value, types.ColumnsWidthMode.FROM_LOD_0, char_to_cm, padding_cm, min_width_cm, max_width_cm) + else: + return columnsWidth_from_list(value[1].values(), char_to_cm, padding_cm, min_width_cm, max_width_cm) + case types.ColumnsWidthMode.FROM_LOD_2: + if len(value)==0: + return [] + elif len(value)==1: + return guessColumnsWidth(value, types.ColumnsWidthMode.FROM_LOD_0, char_to_cm, padding_cm, min_width_cm, max_width_cm) + elif len(value)==2: + return guessColumnsWidth(value, types.ColumnsWidthMode.FROM_LOD_1, char_to_cm, padding_cm, min_width_cm, max_width_cm) + else: + return columnsWidth_from_list(value[2].values(), char_to_cm, padding_cm, min_width_cm, max_width_cm) + case types.ColumnsWidthMode.FROM_LOD_KEYS: + return columnsWidth_from_lod_keys(value, char_to_cm, padding_cm, min_width_cm, max_width_cm) + case types.ColumnsWidthMode.FROM_LOD_ONLY_100: + return columnsWidth_from_lod(value, 100, char_to_cm, padding_cm, min_width_cm, max_width_cm) + case types.ColumnsWidthMode.FROM_LOD_QUANTILE_90: + return columnsWidth_from_lod_with_quantile(value, None, 90, char_to_cm, padding_cm, min_width_cm, max_width_cm) + case types.ColumnsWidthMode.FROM_LOD_QUANTILE_90_ONLY_100: + return columnsWidth_from_lod_with_quantile(value, 100, 90, char_to_cm, padding_cm, min_width_cm, max_width_cm) + + case types.ColumnsWidthMode.FROM_SHEET_CELLS: # 'value' is expected to be the ODS document object (doc) + doc = value + # Get all values from the current active sheet, including detailed string representation + sheet_data_detailed = doc.getValues(detailed=True) + + if not sheet_data_detailed: + return [] + + processed_strings_for_width_calc = [] + for row_data in sheet_data_detailed: + current_row_processed_strings = [] + for cell_data in row_data: + if cell_data.get('is_merged') is True: + current_row_processed_strings.append("X") # Placeholder string of length 1 + else: + current_row_processed_strings.append(cell_data.get('string')) + processed_strings_for_width_calc.append(current_row_processed_strings) + + # Now calculate widths using the list of lists of strings + r=columnsWidth_from_lol(processed_strings_for_width_calc, None, char_to_cm, padding_cm, min_width_cm, max_width_cm) + # with open("BORRAME", "a") as f: + # f.write(str(sheet_data_detailed)+"\n") + # f.write(str(r)+"\n") + # f.write(str(processed_strings_for_width_calc)+"\n") + return r diff --git a/unogenerator/commons.py b/unogenerator/commons.py index 02b4325..490358e 100644 --- a/unogenerator/commons.py +++ b/unogenerator/commons.py @@ -201,6 +201,13 @@ def __extract(self, strcoord): return (letter,number) + def copy(self): + """ + Returns a copy of the object + """ + return Coord(self.string()) + + ## Returns Coord string ## @return string For example "Z1" def string(self): @@ -227,14 +234,14 @@ def addColumn(self, num=1): ## Add a number of rows to the Coord and return a copy of the object ## @param num Integer Can be positive and negative. When num is negative, if Coord.number is less than 1, returns 1 def addRowCopy(self, num=1): - r=Coord(self.string()) + r=self.copy() r.addRow(num) return r ## Add a number of columns/letters to the Coord and return a copy of the objject ## @param num Integer. Can be positive and negative. When num is negative, if Coord.letter is less than A, returns A. def addColumnCopy(self, num=1): - r=Coord(self.string()) + r=self.copy() r.addColumn(num) return r @@ -469,25 +476,46 @@ def addDebugSystem(level): def generate_formula_total_string(key, coord_from, coord_to): + """ + Generates a spreadsheet formula string based on a key and a coordinate range. + + Args: + key (str): The formula key or template. Supported options: + 1. Predefined aliases: '#SUM', '#AVG', '#MEDIAN'. + 2. Standard function names: e.g., 'SUM', 'COUNT', 'PRODUCT', 'MAX'. + 3. Custom templates: e.g., '=SUM({}*1.21)', '={}/100'. The '{}' + placeholder will be replaced by the range string (e.g., 'A1:B10'). + coord_from (Coord): The start coordinate of the range. + coord_to (Coord): The end coordinate of the range. + + Returns: + str: The generated formula string (e.g., "=SUM(A1:B10)"). + """ + range_str = f"{coord_from.string()}:{coord_to.string()}" + if key == "#SUM": - s="=SUM({}:{})".format(coord_from.string(), coord_to.string()) + return f"=SUM({range_str})" elif key == "#AVG": - s="=AVERAGE({}:{})".format(coord_from.string(), coord_to.string()) + return f"=AVERAGE({range_str})" elif key == "#MEDIAN": - s="=MEDIAN({}:{})".format(coord_from.string(), coord_to.string()) + return f"=MEDIAN({range_str})" + elif "{}" in key: + return key.format(range_str) else: - s=key - return s + # Assume key is just the function name (e.g., "SUM", "COUNT") + # Ensure it doesn't already have an '=' + clean_key = key.lstrip("=") + return f"={clean_key}({range_str})" -def guess_object_style(o): +def guess_object_style(o, default_style="Default"): if o is None: - return "Normal" + return default_style elif o.__class__.__name__=="int": return "Integer" elif o.__class__.__name__=="timedelta": return "TimedeltaSeconds"#TimedeltaISO exits but you can't add or supr elif o.__class__.__name__=="str": - return "Normal" + return default_style elif o.__class__.__name__ in ["Currency", "Money" ]: return o.currency elif o.__class__.__name__=="Percentage": diff --git a/unogenerator/demo.py b/unogenerator/demo.py index 325304a..f58ddb9 100644 --- a/unogenerator/demo.py +++ b/unogenerator/demo.py @@ -1,8 +1,5 @@ -## @namespace unogenerator.demo -## @brief Generate ODF example files from uno import getComponentContext -from unogenerator.unogenerator import ODS getComponentContext() import argparse from collections import OrderedDict @@ -14,9 +11,7 @@ from os import system from pydicts.currency import Currency from pydicts.percentage import Percentage -from unogenerator import ODT_Standard, ODS_Standard, __version__, commons, ColorsNamed, Coord, LibreofficeServer -from unogenerator.helpers import helper_title_values_total_row,helper_title_values_total_column, helper_totals_row, helper_totals_from_range, helper_list_of_ordereddicts, helper_list_of_dicts, helper_list_of_ordereddicts_with_totals, helper_ods_sheet_stylenames, helper_split_big_listofrows - +from unogenerator import ODT_Standard, ODS_Standard, __version__, commons, ColorsNamed, Coord, LibreofficeServer, helpers, types, Range from tqdm import tqdm try: @@ -30,6 +25,49 @@ ## If arguments is None, launches with sys.argc parameters. Entry point is toomanyfiles:main logger = logging.getLogger(__name__) # Get logger for this module + + +lod_singers=[ + {"Singer": "Elvis", "Songs": 10000 , "Albums": 100, "Best song": "Always on my mind"}, + {"Singer": "Roy Orbison", "Songs": 100, "Albums": 20, "Best song": "Crying"}, +] + +lod_widths = [ + OrderedDict({"Column 1": "Short", "Column 2": "This is a much longer string to measure", "Column 3": 100}), + OrderedDict({"Column 1": "A medium string", "Column 2": "Short", "Column 3": 20000}), + OrderedDict({"Column 1": "A very very very long string that should affect quantile 90", "Column 2": "Medium", "Column 3": 3}), + ] + +lod_singers_rows=len(lod_singers) +lod_singers_columns=len(lod_singers[0].keys()) + +lol_numbers=[ + ["One Two Three", "Four", "Ten"], + ["One Two Three", "Four Two Three", "Ten"], + ["One Two Three", "Four", "Ten Two Three"], +] + +lol_numbers_headers=["A","B","C"] + +lol_numbers_rows=len(lol_numbers) +lol_numbers_columns=len(lol_numbers[0]) + + +lol_integers=[ + [1,2,3], + [4,5,6], + [7,8,9] +] + +lol_thousands=[] +for i in range(1000): + lol_thousands.append([i, _("String")+" "+ str(i), datetime.now()]) + +lol_thousands_rows=len(lol_thousands) +lol_thousands_columns=len(lol_thousands[0]) + + + ## You can call with main(['--pretend']). It's equivalento to os.system('program --pretend') ## @param arguments is an array with parser arguments. For example: ['--argument','9']. def demo(arguments=None): @@ -202,143 +240,19 @@ def demo_ods_standard(language, server): _("This file have been generated with UnoGenerator-{0}. You can see UnoGenerator main page in https://github.com/turulomio/unogenerator").format(__version__), ["unogenerator", "demo", "files"] ) - doc.createSheet("Styles") - doc.setSheetStyle("Portrait") - doc.setCellName("A1", "MYNAME") - - - headers=[_("Style name"), _("Date and time"), _("Date"), _("Integer"), _("Euros"), _("Dollars"), _("Percentage"), _("Number with 2 decimals"), _("Number with 6 decimals"), _("Time"), _("Boolean")] - doc.addRowWithStyle( "A1", headers, ColorsNamed.Orange, "BoldCenter") - - colors_list=([a for a in dir(ColorsNamed()) if not a.startswith('__')]) - for row, color_str in enumerate(colors_list): - color_key=getattr(ColorsNamed(), color_str) - doc.addCellWithStyle(Coord("A2").addRow(row), color_str, color_key, "Bold") - doc.addCellWithStyle(Coord("B2").addRow(row), datetime.now(), color_key, "Datetime") - doc.addCellWithStyle(Coord("C2").addRow(row), date.today(), color_key, "Date") - doc.addCellWithStyle(Coord("D2").addRow(row), pow(-1, row)*-10000000, color_key, "Integer") - doc.addCellWithStyle(Coord("E2").addRow(row), Currency(pow(-1, row)*12.56, "EUR"), color_key, "EUR") - doc.addCellWithStyle(Coord("F2").addRow(row), Currency(pow(-1, row)*12345.56, "USD"), color_key, "USD") - doc.addCellWithStyle(Coord("G2").addRow(row), Percentage(pow(-1, row)*1, 3), color_key, "Percentage") - doc.addCellWithStyle(Coord("H2").addRow(row), pow(-1, row)*123456789.121212, color_key, "Float6") - doc.addCellWithStyle(Coord("I2").addRow(row), pow(-1, row)*-12.121212, color_key, "Float2") - doc.addCellWithStyle(Coord("J2").addRow(row), (datetime.now()+timedelta(seconds=3600*12*row)).time(), color_key, "Time") - doc.addCellWithStyle(Coord("K2").addRow(row), bool(row%2), color_key, "Bool") - - doc.addCellWithStyle(Coord("E2").addRow(row+1),f"=sum(E2:{Coord('E2').addRow(row).string()})", ColorsNamed.GrayLight, "EUR" ) - doc.addCellMergedWithStyle("E15:K15", "Merge proof", ColorsNamed.Yellow, style="BoldCenter") - doc.setComment("B14", "This is nice comment") - - doc.setColumnsWidth(ODS.columnsWidth_from_list(headers)) - doc.freezeAndSelect("B2") - - ## List of rows - doc.createSheet("List of rows or columns") - - - doc.addCellMergedWithStyle("A1:C1","List of rows with helper_totals_row", ColorsNamed.Orange, "BoldCenter") - range_=doc.addListOfRowsWithStyle("A2", [[1,2,3],[4,5,6],[7,8,9]], ColorsNamed.White) - helper_totals_row(doc, range_.c_start.addRowCopy(range_.numRows()), ["#SUM"]*3, styles=None, row_from="2", row_to="4") - - doc.addCellMergedWithStyle("A8:C8","List of columns with helper_totals_row", ColorsNamed.Orange, "BoldCenter") - range_=doc.addListOfColumnsWithStyle("A9", [[1,2,3],[4,5,6],[7,8,9]], ColorsNamed.White) - helper_totals_row(doc, range_.c_start.addRowCopy(range_.numRows()), ["#SUM"]*3, styles=None, row_from="9", row_to="11") - - doc.addCellMergedWithStyle("A15:E15","List of rows with helper_totals_from_range in rows and columns", ColorsNamed.Orange, "BoldCenter") - range_=doc.addListOfRowsWithStyle("A16", [["A",12000,2,3, 6],["B",1020,5,6, 7],["C",20404,8,9, 8]], ColorsNamed.White) - helper_totals_from_range(doc, range_, totalcolumns=True, totalrows=True) - - - doc.addCellMergedWithStyle("A22:E22","List of rows with helper_totals_from_range in rows", ColorsNamed.Orange, "BoldCenter") - range_=doc.addListOfRowsWithStyle("A23", [["A",12000,2,3, 6],["B",1020,5,6, 7],["C",20404,8,9, 8]], ColorsNamed.White) - helper_totals_from_range(doc, range_.addColumnBefore(-1), totalcolumns=False, totalrows=True) #Removes one column to filter first alphanumerical column - - doc.addCellMergedWithStyle("A29:E29","List of rows with helper_totals_from_range in columns", ColorsNamed.Orange, "BoldCenter") - range_=doc.addListOfRowsWithStyle("A30", [["A",12000,2,3, 6],["B",1020,5,6, 7],["C",20404,8,9, 8]], ColorsNamed.White) - helper_totals_from_range(doc, range_.addColumnBefore(-1), totalcolumns=True, totalrows=False) - - doc.addCellMergedWithStyle("A35:E35","List of rows with helper_totals_from_range in rows showing", ColorsNamed.Orange, "BoldCenter") - range_=doc.addListOfRowsWithStyle("A36", [["A",12000,2,3, 6],["B",1020,5,6, 7],["C",20404,8,9, 8]], ColorsNamed.White) - helper_totals_from_range(doc, range_.addColumnBefore(-1), totalcolumns=False, totalrows=True, showing=True) - - doc.addCellMergedWithStyle("A42:E42","List of rows with helper_totals_from_range in columns showing", ColorsNamed.Orange, "BoldCenter") - range_=doc.addListOfRowsWithStyle("A43", [["A",12000,2,3, 6],["B",1020,5,6, 7],["C",20404,8,9, 8]], ColorsNamed.White) - helper_totals_from_range(doc, range_.addColumnBefore(-1), totalcolumns=True, totalrows=False, showing=True) - - doc.setColumnsWidth([3]*20) - - - ## HELPERS - doc.createSheet("Helpers") - doc.setSheetStyle("Portrait") - doc.addCellMergedWithStyle("A1:E1","Helper values with total (horizontal)", ColorsNamed.Orange, "BoldCenter") - helper_title_values_total_row(doc, "A2", "Suma 3", [1,2,3]) - - doc.addCellMergedWithStyle("A4:A9","Helper values with total (vertical)", ColorsNamed.Orange, "VerticalBoldCenter") - helper_title_values_total_column(doc, "B4", "Suma 3", [1,2,3, 4]) - - - doc.addCellMergedWithStyle("A23:B23","List of ordered dictionaries", ColorsNamed.Orange, "BoldCenter") - lod=[] - lod.append(OrderedDict({"Singer": "Elvis", "Song": "Fever" })) - lod.append(OrderedDict({"Singer": "Roy Orbison", "Song": "Blue angel" })) - helper_list_of_ordereddicts(doc, "A24", lod, columns_header=1) - - doc.addCellMergedWithStyle("A28:B28","List of dictionaries", ColorsNamed.Orange, "BoldCenter") - helper_list_of_dicts(doc, "A29", lod, keys=["Song", "Singer"]) - - doc.addCellMergedWithStyle("A33:D33","List of ordered dictionaries one method with totals", ColorsNamed.Orange, "BoldCenter") - lod=[] - lod.append(OrderedDict({"Singer": "Elvis", "Songs": 10000 , "Albums": 100})) - lod.append(OrderedDict({"Singer": "Roy Orbison", "Songs": 100, "Albums": 20 })) - helper_list_of_ordereddicts_with_totals(doc, "A34", lod, columns_header=1) - doc.setColumnsWidth([3]*20) - - ##Sort - doc.createSheet("Sort") - l=[7, 3, 2, 5, 6, 0, 9, 4, 10] - doc.addCellWithStyle("A1", "Unsorted", ColorsNamed.Orange, "BoldCenter") - doc.addCellWithStyle("B1", "Sorted ASC", ColorsNamed.Orange, "BoldCenter") - doc.addCellWithStyle("C1", "Sorted DESC", ColorsNamed.Orange, "BoldCenter") - doc.addColumnWithStyle("A2", l) - doc.addColumnWithStyle("B2", l) - doc.addColumnWithStyle("C2", l) - doc.sortRange("B2:B10", 0) - doc.sortRange("C2:C10", 0, False) - doc.setColumnsWidth([3]*20) - - ## Split big LOR - lor=[] - for i in range(1000): - lor.append([i, _("String")+" "+ str(i), datetime.now()]) - - helper_split_big_listofrows(doc, "Splits in 400 rows", lor, ["Integer", "String", "Datetime"], columns_width=[2, 5, 5], max_rows=400) - - - ## COLUMNS WIDTH LOD - doc.createSheet("ColumnsWidthsLOD") - helper_list_of_ordereddicts_with_totals(doc, "A1", lod, columns_header=1) - doc.setColumnsWidth(ODS.columnsWidth_from_lod(lod)) - - ## COLUMNS WIDTH LOL - doc.createSheet("ColumnsWidthsLOL") - lol_=[ - ["One Two Three", "Four", "Ten"], - ["One Two Three", "Four Two Three", "Ten"], - ["One Two Three", "Four", "Ten Two Three"], - ] - doc.addListOfRowsWithStyle("A1", lol_) - doc.setColumnsWidth(ODS.columnsWidth_from_lol(lol_)) - - - ## COLUMNS WIDTH LOL - doc.createSheet("ColumnsWidthsList") - doc.addListOfRowsWithStyle("A1", lol_) - doc.setColumnsWidth(ODS.columnsWidth_from_list(lol_[0])) - - ## Sheet with all styles names - helper_ods_sheet_stylenames(doc) + demo_ods_sheet_styles(doc) + demo_ods_sheet_sort(doc) + demo_ods_sheet_word_wrap(doc) + demo_ods_helpers_single(doc) + demo_ods_block_from_lod(doc) + demo_ods_block_from_lol(doc) + demo_ods_block_from_lod_with_headers(doc) + demo_ods_sheet_from_lod(doc) + demo_ods_sheet_from_lol(doc) + demo_ods_columns_width_modes(doc) + demo_ods_sheet_split_with_big_lol(doc) + helpers.sheet_stylenames(doc) doc.save(f"unogenerator_example_{language}.ods") doc.export_xlsx(f"unogenerator_example_{language}.xlsx") @@ -597,3 +511,224 @@ def demo_odt_standard(language, server): r= _("unogenerator_documentation_{0}.ods took {1} in {2}").format(language, datetime.now()-doc.start, doc.server.port) # This is an application-level message logger.info(r) return r + + +def demo_ods_sheet_styles(doc): + doc.createSheet("Styles") + + doc.setSheetStyle("Portrait") + doc.setCellName("A1", "MYNAME") + + + headers=[_("Style name"), _("Date and time"), _("Date"), _("Integer"), _("Euros"), _("Dollars"), _("Percentage"), _("Number with 2 decimals"), _("Number with 6 decimals"), _("Time"), _("Boolean")] + doc.addRowWithStyle( "A1", headers, ColorsNamed.Orange, "BoldCenter") + + colors_list=([a for a in dir(ColorsNamed()) if not a.startswith('__')]) + for row, color_str in enumerate(colors_list): + color_key=getattr(ColorsNamed(), color_str) + doc.addCellWithStyle(Coord("A2").addRow(row), color_str, color_key, "Bold") + doc.addCellWithStyle(Coord("B2").addRow(row), datetime.now(), color_key, "Datetime") + doc.addCellWithStyle(Coord("C2").addRow(row), date.today(), color_key, "Date") + doc.addCellWithStyle(Coord("D2").addRow(row), pow(-1, row)*-10000000, color_key, "Integer") + doc.addCellWithStyle(Coord("E2").addRow(row), Currency(pow(-1, row)*12.56, "EUR"), color_key, "EUR") + doc.addCellWithStyle(Coord("F2").addRow(row), Currency(pow(-1, row)*12345.56, "USD"), color_key, "USD") + doc.addCellWithStyle(Coord("G2").addRow(row), Percentage(pow(-1, row)*1, 3), color_key, "Percentage") + doc.addCellWithStyle(Coord("H2").addRow(row), pow(-1, row)*123456789.121212, color_key, "Float6") + doc.addCellWithStyle(Coord("I2").addRow(row), pow(-1, row)*-12.121212, color_key, "Float2") + doc.addCellWithStyle(Coord("J2").addRow(row), (datetime.now()+timedelta(seconds=3600*12*row)).time(), color_key, "Time") + doc.addCellWithStyle(Coord("K2").addRow(row), bool(row%2), color_key, "Bool") + + doc.addCellWithStyle(Coord("E2").addRow(row+1),f"=sum(E2:{Coord('E2').addRow(row).string()})", ColorsNamed.GrayLight, "EUR" ) + doc.addCellMergedWithStyle("E15:K15", "Merge proof", ColorsNamed.Yellow, style="BoldCenter") + doc.setComment("B14", "This is nice comment") + + doc.setColumnsWidth(doc, types.ColumnsWidthMode.FROM_SHEET_CELLS) + doc.freezeAndSelect("B2") + + + +def demo_ods_block_from_lod(doc): + ## List of rows + doc.createSheet("block_from_lod") + helpers.block_from_lod(doc, "A1", lod_singers) + doc.setColumnsWidth(doc, types.ColumnsWidthMode.FROM_SHEET_CELLS) + + doc.createSheet("block_from_lod title") + helpers.block_from_lod(doc, "A1", lod_singers, title="block_from_lod with title") + doc.setColumnsWidth(doc, types.ColumnsWidthMode.FROM_SHEET_CELLS) + + doc.createSheet("block_from_lod other headers") + helpers.block_from_lod(doc, "A1", lod_singers, columns_header=1, color_row_header=ColorsNamed.Red, title="block_from_lod (With other headers)") + doc.setColumnsWidth(doc, types.ColumnsWidthMode.FROM_SHEET_CELLS) + + doc.createSheet("block_from_lod empty") + helpers.block_from_lod(doc, "A1", [], columns_header=1, color_row_header=ColorsNamed.Red, title="block_from_lod (Empty)") + doc.setColumnsWidth(doc, types.ColumnsWidthMode.FROM_SHEET_CELLS) + + doc.createSheet("block_from_lod column of totals") + helpers.block_from_lod(doc, "A1", lod_singers, column_of_totals=True, title="block_from_lod (With total columns)", styles="Integer") + doc.setColumnsWidth(doc, types.ColumnsWidthMode.FROM_SHEET_CELLS) + + doc.createSheet("block_from_lod row of totals") + helpers.block_from_lod(doc, "A1", lod_singers, row_of_totals=True, title="block_from_lod (With total rows)", styles="Integer") + doc.setColumnsWidth(doc, types.ColumnsWidthMode.FROM_SHEET_CELLS) + + doc.createSheet("block_from_lod column of totals row of totals") + helpers.block_from_lod(doc, "A1", lod_singers, column_of_totals=True, row_of_totals=True, title="block_from_lod (With total columns and rows)", styles="Integer") + doc.setColumnsWidth(doc, types.ColumnsWidthMode.FROM_SHEET_CELLS) + + +def demo_ods_block_from_lod_with_headers(doc): + # block_from_lod_with_headers + doc.createSheet("block_from_lod_with_headers") + helpers.block_from_lod_with_headers(doc, lod_singers, "A1", [ + ["Singer header", "Singer"], + ["Song header", "Best song"] + ], titulo="block_from_lod_with_headers") + doc.setColumnsWidth(lod_singers, types.ColumnsWidthMode.FROM_LOD) + + + doc.createSheet("block_from_lod_with_headers column_of_totals") + helpers.block_from_lod_with_headers(doc, lod_singers, "A1", [ + ["Singer header", "Singer"], + ["Song header", "Best song"] + ], titulo="block_from_lod_with_headers (With total columns)", column_of_totals=True) + doc.setColumnsWidth(lod_singers, types.ColumnsWidthMode.FROM_LOD) + + doc.createSheet("block_from_lod_with_headers row_of_totals") + helpers.block_from_lod_with_headers(doc, lod_singers, "A1", [ + ["Singer header", "Singer"], + ["Song header", "Best song"] + ], titulo="block_from_lod_with_headers (With total rows)", row_of_totals=True) + doc.setColumnsWidth(lod_singers, types.ColumnsWidthMode.FROM_LOD) + + + doc.createSheet("block_from_lod_with_headers column_of_totals row_of_totals") + helpers.block_from_lod_with_headers(doc, lod_singers, "A1", [ + ["Singer header", "Singer"], + ["Song header", "Best song"] + ], titulo="block_from_lod_with_headers (With total columns and rows)", column_of_totals=True, row_of_totals=True) + doc.setColumnsWidth(lod_singers, types.ColumnsWidthMode.FROM_LOD) + + +def demo_ods_sheet_from_lol(doc): + ## Sheet from LOL and LOD + helpers.sheet_from_lol(doc, "sheet_from_lol", lol_numbers, lol_numbers_headers, column_of_totals=True, row_of_totals=True, titulo="LOL Sheet") + +def demo_ods_sheet_from_lod(doc): + helpers.sheet_from_lod(doc, "sheet_from_lod", lod_singers, column_of_totals=True, row_of_totals=True, title="LOD Sheet", styles="Integer") + +def demo_ods_sheet_sort(doc): + ##Sort + doc.createSheet("Sort") + l=[7, 3, 2, 5, 6, 0, 9, 4, 10] + doc.addCellWithStyle("A1", "Unsorted", ColorsNamed.Orange, "BoldCenter") + doc.addCellWithStyle("B1", "Sorted ASC", ColorsNamed.Orange, "BoldCenter") + doc.addCellWithStyle("C1", "Sorted DESC", ColorsNamed.Orange, "BoldCenter") + doc.addColumnWithStyle("A2", l) + doc.addColumnWithStyle("B2", l) + doc.addColumnWithStyle("C2", l) + doc.sortRange("B2:B10", 0) + doc.sortRange("C2:C10", 0, False) + doc.setColumnsWidth(doc, types.ColumnsWidthMode.FROM_SHEET_CELLS) + +def demo_ods_sheet_word_wrap(doc): + ## Word Wrap + doc.createSheet("Word Wrap") + long_text = "This is a very long text that should be wrapped if the parameter is set to True, otherwise it should stay in a single line with fixed row height." + doc.addCellWithStyle("A1", "Word Wrap False", ColorsNamed.Orange, "BoldCenter") + doc.addCellWithStyle("A2", long_text, word_wrap=False) + + doc.addCellWithStyle("A4", "Word Wrap True", ColorsNamed.Orange, "BoldCenter") + doc.addCellWithStyle("A5", long_text, word_wrap=True) + + + helpers.block_from_lod(doc, "A7", lod_singers, columns_header=1, word_wrap=False) + + helpers.block_from_lod(doc, "A12", lod_singers, columns_header=1, word_wrap=True) + + + + doc.setColumnsWidth([2, 2, 2, 2], types.ColumnsWidthMode.MANUAL) + +def demo_ods_sheet_split_with_big_lol(doc): + helpers.sheet_split_with_big_lol(doc, "Splits in 400 rows", lol_thousands, ["Integer", "String", "Datetime"], max_rows=400) + + + + +def demo_ods_block_from_lol(doc): + headers = ["Product", "Qty", "Price"] + data = [ + ["Item A", 10, 20.5], + ["Item B", 5, 15.0], + ["Item C", 2, 100.0], + ] + + + + + doc.createSheet("block_from_lol empty") + helpers.block_from_lol(doc, "A1", [], headers=headers, title="block_from_lol (With total columns)", styles="Float2") + doc.setColumnsWidth(doc, types.ColumnsWidthMode.FROM_SHEET_CELLS) + + doc.createSheet("block_from_lol column of totals") + helpers.block_from_lol(doc, "A1", data, headers=headers, column_of_totals=True, title="block_from_lol (With total columns)", styles="Float2") + doc.setColumnsWidth(doc, types.ColumnsWidthMode.FROM_SHEET_CELLS) + + doc.createSheet("block_from_lol row of totals") + helpers.block_from_lol(doc, "A1", data, headers=headers, row_of_totals=True, title="block_from_lol (With total rows)", styles="Float2") + doc.setColumnsWidth(doc, types.ColumnsWidthMode.FROM_SHEET_CELLS) + + doc.createSheet("block_from_lol both totals") + helpers.block_from_lol(doc, "A1", data, headers=headers, column_of_totals=True, row_of_totals=True, title="block_from_lol (With both totals)", styles="Float2") + doc.setColumnsWidth(doc, types.ColumnsWidthMode.FROM_SHEET_CELLS) + +def demo_ods_helpers_single(doc): + ## row_title_values_total + doc.createSheet("row_title_values_total") + helpers.row_title_values_total(doc, "A1", "My Row Sum", [10, 20, 30]) + doc.setColumnsWidth(doc, types.ColumnsWidthMode.FROM_SHEET_CELLS) + + ## column_title_values_total + doc.createSheet("column_title_values_total") + helpers.column_title_values_total(doc, "A1", "My Col Sum", [1, 2, 3, 4, 5]) + doc.setColumnsWidth(doc, types.ColumnsWidthMode.FROM_SHEET_CELLS) + + ## row_totals + doc.createSheet("row_totals") + doc.addRowWithStyle("A1", [100, 200, 300, 400]) + helpers.row_totals(doc, "A2", ["#SUM"] * 4, row_from="1") + doc.setColumnsWidth(doc, types.ColumnsWidthMode.FROM_SHEET_CELLS) + + ## column_totals + doc.createSheet("column_totals") + doc.addColumnWithStyle("A1", [10, 20, 30, 40]) + helpers.column_totals(doc, "B1", ["#SUM"] * 4, column_from="A") + doc.setColumnsWidth(doc, types.ColumnsWidthMode.FROM_SHEET_CELLS) + +def demo_ods_columns_width_modes(doc): + + # 1. MANUAL + helpers.sheet_from_lod(doc, "Width MANUAL", lod_widths, columns_width_mode=types.ColumnsWidthMode.MANUAL, value=[5, 10, 5]) + + # 2. FROM_LOD (Default behavior: max of all) + helpers.sheet_from_lod(doc, "Width FROM_LOD", lod_widths, columns_width_mode=types.ColumnsWidthMode.FROM_LOD) + + # 3. FROM_LOD_0 (Uses first dictionary) + helpers.sheet_from_lod(doc, "Width FROM_LOD_0", lod_widths, columns_width_mode=types.ColumnsWidthMode.FROM_LOD_0) + + # 4. FROM_LOD_QUANTILE_90 (Uses 90th percentile of lengths) + helpers.sheet_from_lod(doc, "Width FROM_LOD_Q90", lod_widths, columns_width_mode=types.ColumnsWidthMode.FROM_LOD_QUANTILE_90) + + # 5. FROM_SHEET_CELLS (Measures from generated cells) + helpers.sheet_from_lod(doc, "Width FROM_SHEET_CELLS", lod_widths, columns_width_mode=types.ColumnsWidthMode.FROM_SHEET_CELLS) + + + doc.createSheet("Width FROM_LOL") + doc.addListOfRowsWithStyle("A1", lol_numbers) + doc.setColumnsWidth(lol_numbers, types.ColumnsWidthMode.FROM_LOL) + + doc.createSheet("Width FROM_LIST") + doc.addListOfRowsWithStyle("A1", [lol_numbers[0]]) + doc.setColumnsWidth(lol_numbers[0], types.ColumnsWidthMode.FROM_LIST) \ No newline at end of file diff --git a/unogenerator/helpers.py b/unogenerator/helpers.py index 7cb0b82..cd69b9a 100644 --- a/unogenerator/helpers.py +++ b/unogenerator/helpers.py @@ -1,11 +1,7 @@ -## @param cood Coord from we are going to add totals -## @param list_of_totals List with strings or keys. Example: ["Total", "#SUM", "#AVG"]... -## @param styles List with string styles or None. If none tries to guest from top column object. List example: ["GrayLightPercentage", "GrayLightInteger"] -## @param string with the row where th3e total begins -## @param string with the rew where the formula ends. If None it's a coord.row -1 -from unogenerator.commons import ColorsNamed, Coord as C, Range as R, guess_object_style, generate_formula_total_string -from unogenerator import ODS -from pydicts import lod +from unogenerator.commons import ColorsNamed, Coord, Range, guess_object_style, generate_formula_total_string +from unogenerator import ODS, types +from pydicts import lod, lol +from collections import OrderedDict from gettext import translation from logging import debug import logging @@ -13,72 +9,87 @@ from importlib.resources import files logger = logging.getLogger(__name__) # Get logger for this module + try: t=translation('unogenerator', files("unogenerator") / 'locale') _=t.gettext except: _=str -def helper_totals_row(doc, coord, list_of_totals, color=ColorsNamed.GrayLight, styles=None, row_from="2", row_to=None): - coord=C.assertCoord(coord) +def row_totals(doc, coord, list_of_totals, color=ColorsNamed.GrayLight, styles=None, row_from="2", row_to=None): + """ + Generates a row of totals starting from the given coordinate using bulk insertion. + + Args: + doc (ODS): The ODS document object. + coord (Coord or str): Coordinate where the totals row will start. + list_of_totals (list): List of formulas or keys (e.g., ["Total", "#SUM", "#AVG"]). + color (int, optional): Background color for the cells. Defaults to ColorsNamed.GrayLight. + styles (list or str, optional): List of styles or a single style. If None, guesses from the adjacent cell. + row_from (str, optional): The row number where the formula range begins. Defaults to "2". + row_to (str, optional): The row number where the formula range ends. If None, defaults to the row above `coord`. + """ + coord=Coord.assertCoord(coord) + formulas = [] for letter, total in enumerate(list_of_totals): coord_total=coord.addColumnCopy(letter) - coord_total_from=C(coord_total.letter+row_from) + coord_total_from=Coord(coord_total.letter+str(row_from)) if row_to is None: coord_total_to=coord_total.addRowCopy(-1)# row above else: - coord_total_to=C(coord_total.letter+row_to) + coord_total_to=Coord(coord_total.letter+str(row_to)) + formulas.append(generate_formula_total_string(total, coord_total_from, coord_total_to)) - if styles is None: - style=guess_object_style(doc.getValue(coord_total_from)) - elif styles.__class__.__name__ != "list": - style=styles - else: - style=styles[letter] - - doc.addCellWithStyle(coord_total, generate_formula_total_string(total, coord_total_from, coord_total_to), color, style) + if styles is None: + # Guess style from the first data cell + first_coord_from = Coord(coord.letter + str(row_from)) + styles = guess_object_style(doc.getValue(first_coord_from), doc.default_cell_style) + + doc.addRowWithStyle(coord, formulas, colors=color, styles=styles) -def helper_totals_column(doc, coord, list_of_totals, color=ColorsNamed.GrayLight, styles=None, column_from="B", column_to=None): +def column_totals(doc, coord, list_of_totals, color=ColorsNamed.GrayLight, styles=None, column_from="B", column_to=None): """ - Genera una columna de totales desde la coordenada pasado como parámetro - @param doc Documento ODS - @param coord Coordenada de inicio - @param list_of_totals List of values #SUM #AVG #MEDIAN - @type list - @param color DESCRIPTION (defaults to ColorsNamed.GrayLight) - @type TYPE (optional) - @param styles DESCRIPTION (defaults to None) - @type TYPE (optional) - @param column_from DESCRIPTION (defaults to "B") - @type TYPE (optional) - @param column_to DESCRIPTION (defaults to None) - @type TYPE (optional) + Generates a column of totals starting from the given coordinate using bulk insertion. + + Args: + doc (ODS): The ODS document object. + coord (Coord or str): Starting coordinate for the totals column. + list_of_totals (list): List of formulas or keys (e.g., ["Total", "#SUM", "#AVG", "#MEDIAN"]). + color (int, optional): Background color for the cells. Defaults to ColorsNamed.GrayLight. + styles (list or str, optional): List of styles or a single style. If None, guesses from the adjacent cell. + column_from (str, optional): The column letter where the formula range begins. Defaults to "B". + column_to (str, optional): The column letter where the formula range ends. If None, defaults to one column before `coord`. """ - coord=C.assertCoord(coord) + coord=Coord.assertCoord(coord) + formulas = [] for number, total in enumerate(list_of_totals): coord_total=coord.addRowCopy(number) - coord_total_from=C(column_from + coord_total.number) + coord_total_from=Coord(str(column_from) + coord_total.number) if column_to is None: coord_total_to=coord_total.addColumnCopy(-1)# row above else: - coord_total_to=C(column_to + coord_total.number) + coord_total_to=Coord(str(column_to) + coord_total.number) + formulas.append(generate_formula_total_string(total, coord_total_from, coord_total_to)) - if styles is None: - style=guess_object_style(doc.getValue(coord_total_from)) - elif styles.__class__.__name__ != "list": - style=styles - else: - style=styles[number] + if styles is None: + # Guess style from the first data cell + first_coord_from = Coord(str(column_from) + coord.number) + styles = guess_object_style(doc.getValue(first_coord_from), doc.default_cell_style) - doc.addCellWithStyle(coord_total, generate_formula_total_string(total, coord_total_from, coord_total_to), color, style) - -def helper_title_values_total_row( doc, coord, title, values, + doc.addColumnWithStyle(coord, formulas, colors=color, styles=styles) + + +def row_title_values_total( doc, coord, title, values, style_title=None, color_title=ColorsNamed.Orange, style_values=None, color_values=ColorsNamed.White, style_total=None, color_total=ColorsNamed.GrayLight ): - coord=C.assertCoord(coord) + """ + Parameters: + - values: list: Only one row + """ + coord=Coord.assertCoord(coord) if style_title is None: style_title="Bold" @@ -92,19 +103,23 @@ def helper_title_values_total_row( doc, coord, title, values, doc.addCellWithStyle(coord,title,color_title,style_title) i=i+1 - doc.addRowWithStyle(coord.addColumnCopy(i),values,colors=color_values,styles=style_values) - doc.addCellWithStyle(coord.addColumnCopy(i+len(values)),f"=sum({coord.addColumnCopy(i).string()}:{coord.addColumnCopy(i+len(values)-1).string()}",color_total,style_total) + doc.addCellWithStyle(coord.addColumnCopy(i+len(values)),f"=sum({coord.addColumnCopy(i).string()}:{coord.addColumnCopy(i+len(values)-1).string()})",color_total,style_total) + -def helper_title_values_total_column(doc, coord, title, values, +def column_title_values_total( doc, coord, title, values, style_title=None, color_title=ColorsNamed.Orange, style_values=None, color_values=ColorsNamed.White, style_total=None, color_total=ColorsNamed.GrayLight ): - coord=C.assertCoord(coord) + """ + Parameters: + - values: list: Only one column + """ + coord=Coord.assertCoord(coord) if style_title is None: - style_title="BoldCenter" + style_title="Bold" if style_total is None and len(values)>0: style_total=guess_object_style(values[0]) @@ -116,128 +131,321 @@ def helper_title_values_total_column(doc, coord, title, values, i=i+1 doc.addColumnWithStyle(coord.addRowCopy(i),values,colors=color_values,styles=style_values) - doc.addCellWithStyle(coord.addRowCopy(i+len(values)),f"=sum({coord.addRowCopy(i).string()}:{coord.addRowCopy(i+len(values)-1).string()}",color_total,style_total) + doc.addCellWithStyle(coord.addRowCopy(i+len(values)),f"=sum({coord.addRowCopy(i).string()}:{coord.addRowCopy(i+len(values)-1).string()})",color_total,style_total) -## Genera totales verticales y horizontales directamente partiendo de un rango. Todos con sumas, añade un "Total" en la fila columna anterior -## @param s xlsx doc -## @param range_of_data. Range with data values -## @param keys Key of formula if List, it has all values -## @param showing When totalcolumns=True or totalrows=True only, shows a Total of Totals -def helper_totals_from_range ( - doc, - range_of_data, - key="#SUM", - totalcolumns=True, - totalrows=True, - vertical_total_title_style="BoldCenter", - horizontal_total_title_style="BoldCenter", - showing=False - ): - range=R.assertRange(range_of_data) - data_rows=range.numRows() - data_columns=range.numColumns() - coord_horizontal_title=range.c_start.addColumnCopy(-1).addRowCopy(data_rows) - coord_vertical_title=range.c_start.addRowCopy(-1).addColumnCopy(data_columns) - style_data=guess_object_style(doc.getValue(range.c_end)) +def cross_totals_from_range( + doc, + range_of_data, + key="#SUM", + column_of_totals=True, + row_of_totals=True, + vertical_total_title_style="BoldCenter", + horizontal_total_title_style="BoldCenter", + label_column="Total", + label_row="Total", + skip_columns=0 + ): + """ + Generates vertical and horizontal totals directly from a data range. + + Args: + doc (ODS): The ODS document object. + range_of_data (Range or str): The range containing the data values. + key (str, optional): Formula key or template. Supported options: + 1. Predefined aliases: '#SUM', '#AVG', '#MEDIAN'. + 2. Standard function names: e.g., 'SUM', 'COUNT', 'PRODUCT', 'MAX'. + 3. Custom templates: e.g., '=SUM({}*1.21)', '={}/100'. The '{}' + placeholder will be replaced by the range string (e.g., 'A1:B10'). + Defaults to "#SUM". + column_of_totals (bool, optional): Whether to generate a column of totals to the right. Defaults to True. + row_of_totals (bool, optional): Whether to generate a row of totals at the bottom. Defaults to True. + vertical_total_title_style (str, optional): Style for the vertical total title. Defaults to "BoldCenter". + horizontal_total_title_style (str, optional): Style for the horizontal total title. Defaults to "BoldCenter". + label_column (str, optional): Label for the column of totals. Defaults to "Total". Set to None to omit. + label_row (str, optional): Label for the row of totals. Defaults to "Total". Set to None to omit. + skip_columns (int, optional): Number of columns to skip from the left for row totals. Defaults to 0. + + Returns: + Range: The new range including the generated totals and labels. + """ + data_range = Range.assertRange(range_of_data) + data_rows = data_range.numRows() + data_columns = data_range.numColumns() - if totalcolumns==True and totalrows==True: - doc.addCellWithStyle(coord_horizontal_title, _("Total"), ColorsNamed.GrayLight, horizontal_total_title_style) - helper_totals_row(doc, coord_horizontal_title.addColumnCopy(1), [key]*data_columns,styles=style_data, row_from=range.c_start.number) - doc.addCellWithStyle(coord_vertical_title, _("Total"), ColorsNamed.GrayLight, vertical_total_title_style) - helper_totals_column(doc, coord_vertical_title.addRowCopy(1),[key]*(data_rows+1), styles=style_data, column_from=range.c_start.letter) - elif totalcolumns==True: - doc.addCellWithStyle(coord_vertical_title, _("Total"), ColorsNamed.GrayLight, vertical_total_title_style) - helper_totals_column(doc, coord_vertical_title.addRowCopy(1),[key]*(data_rows+0), styles=style_data, column_from=range.c_start.letter) - if showing is True: - coord_sum_totals=coord_vertical_title.addRowCopy(data_rows+1) - doc.addCellWithStyle(coord_sum_totals, generate_formula_total_string(key, range.c_start.addColumnCopy(data_columns+1), range.c_end.addColumnCopy(1)), ColorsNamed.GrayLight, style_data) - doc.addCellWithStyle(coord_sum_totals.addColumnCopy(-1), _("Sum of totals"), ColorsNamed.GrayDark, style_data) - elif totalrows==True: - doc.addCellWithStyle(coord_horizontal_title, _("Total"), ColorsNamed.GrayLight, horizontal_total_title_style) - helper_totals_row(doc, coord_horizontal_title.addColumnCopy(1),[key]*(data_columns+0), styles=style_data, row_from=range.c_start.number) #1 menos por la esquina - if showing is True: - coord_sum_totals=coord_horizontal_title.addColumnCopy(data_columns+1) - doc.addCellWithStyle(coord_sum_totals, generate_formula_total_string(key, range.c_start.addRowCopy(data_rows+1), range.c_end.addRowCopy(1)), ColorsNamed.GrayLight, style_data) - doc.addCellWithStyle(coord_sum_totals.addRowCopy(-1), _("Sum of totals"), ColorsNamed.GrayDark, style_data) - - return range_of_data - - - -## Write cells from a list of ordered dictionaries -## @param lod List of ordered dictionaries -## @param keys. If None write all keys, Else must be a list of keys -## @param columns_header. Integer with the number of columns to apply color_header -## @return Range. Returns the range of the data without headers. Useful to set totals. -def helper_list_of_ordereddicts(doc, coord_start, lod_, keys=None, columns_header=0, color_row_header=ColorsNamed.Orange, color_column_header=ColorsNamed.Green, color=ColorsNamed.White, styles=None): - coord_start=C.assertCoord(coord_start) + # Guessed style for data (to match totals formatting) + style_data = guess_object_style(doc.getValue(data_range.c_start), doc.default_cell_style) - if len(lod_)==0 and keys is None: - doc.addCellWithStyle(coord_start, _("No data to show"), ColorsNamed.Red, "BoldCenter") - return None + final_start_coord = data_range.c_start.copy() + final_end_coord = data_range.c_end.copy() - - #Header + # 1. Add row of totals (Bottom) + if row_of_totals: + # Start totals row after skip_columns + coord_row_totals = data_range.c_start.addRowCopy(data_rows).addColumnCopy(skip_columns) + num_totals = data_columns - skip_columns + if num_totals > 0: + row_totals(doc, coord_row_totals, [key] * num_totals, styles=style_data, row_from=data_range.c_start.number, row_to=data_range.c_end.number) + final_end_coord.addRow(1) + + # Label for row totals + if label_row: + if skip_columns > 0: + # Place label in the skipped columns area of the totals row + coord_label_start = data_range.c_start.addRowCopy(data_rows) + coord_label_end = coord_label_start.addColumnCopy(skip_columns - 1) + range_label = Range.from_coords(coord_label_start, coord_label_end) + doc.addCellMergedWithStyle(range_label, _(label_row), ColorsNamed.GrayLight, horizontal_total_title_style) + elif data_range.c_start.letterIndex() > 0: + # Place label to the left of the range + coord_label_row = data_range.c_start.addColumnCopy(-1).addRowCopy(data_rows) + doc.addCellWithStyle(coord_label_row, _(label_row), ColorsNamed.GrayLight, horizontal_total_title_style) + if final_start_coord.letterIndex() > coord_label_row.letterIndex(): + final_start_coord = coord_label_row.copy() + + # 2. Add column of totals (Right) + if column_of_totals: + coord_col_totals = data_range.c_start.addColumnCopy(data_columns) + # If we also added a row of totals, we include it in the column totals (cross total) + total_items = data_rows + (1 if row_of_totals else 0) + column_totals(doc, coord_col_totals, [key] * total_items, styles=style_data, column_from=data_range.c_start.addColumnCopy(skip_columns).letter, column_to=data_range.c_end.letter) + final_end_coord.addColumn(1) + + # Label for column totals + if label_column and data_range.c_start.numberIndex() > 0: + coord_label_column = data_range.c_start.addRowCopy(-1).addColumnCopy(data_columns) + doc.addCellWithStyle(coord_label_column, _(label_column), ColorsNamed.GrayLight, vertical_total_title_style) + if final_start_coord.numberIndex() > coord_label_column.numberIndex(): + final_start_coord = coord_label_column.copy() + + return Range.from_coords(final_start_coord, final_end_coord) + + +def block_from_lod(doc, coord_start, lod_, keys=None, columns_header=0, color_row_header=ColorsNamed.Orange, color_column_header=ColorsNamed.Green, color=ColorsNamed.White, styles=None, column_of_totals=False, row_of_totals=False, key="#SUM", title=None, word_wrap=True): + """ + Write cells from a list of ordered dictionaries. + + Args: + doc (ODS): The ODS document object. + coord_start (Coord or str): Starting coordinate. + lod_ (list): List of ordered dictionaries. + keys (list, optional): List of keys to write. If None, writes all keys. Defaults to None. + columns_header (int, optional): Number of columns to apply color_column_header. Defaults to 0. + color_row_header (int, optional): Color for row headers. Defaults to ColorsNamed.Orange. + color_column_header (int, optional): Color for column headers. Defaults to ColorsNamed.Green. + color (int, optional): Color for data cells. Defaults to ColorsNamed.White. + styles (list or str, optional): Styles for data cells. Defaults to None. + column_of_totals (bool, optional): Whether to generate a column of totals to the right. Defaults to False. + row_of_totals (bool, optional): Whether to generate a row of totals at the bottom. Defaults to False. + key (str, optional): Formula key or template. Supported options: + 1. Predefined aliases: '#SUM', '#AVG', '#MEDIAN'. + 2. Standard function names: e.g., 'SUM', 'COUNT', 'PRODUCT', 'MAX'. + 3. Custom templates: e.g., '=SUM({}*1.21)', '={}/100'. The '{}' + placeholder will be replaced by the range string (e.g., 'A1:B10'). + Defaults to "#SUM". + title (str, optional): Title for the block. Defaults to None. + word_wrap (bool, optional): Enable word wrap and optimal height. Defaults to True. + + Returns: + Range: The range of the data including headers and totals. + """ + coord_start=Coord.assertCoord(coord_start) + c=coord_start.copy() + + # 1. Headers if keys is None: keys=lod.lod_keys(lod_) + + # 2. Title + if title is not None: + if len(lod_)==0: + doc.addCellWithStyle(c, title, ColorsNamed.Red, "BoldCenter") + else: + add_of_totals=1 if column_of_totals else 0 + range_title=Range.from_coords(c.copy(), c.addColumnCopy(len(keys)-1+add_of_totals)) + doc.addCellMergedWithStyle(range_title, title, ColorsNamed.Red, "BoldCenter", word_wrap=word_wrap) + c.addRow(1) + + # 3. Handle empty lod + if len(lod_)==0: + doc.addCellWithStyle(c, _("No data to show"), ColorsNamed.White, "BoldCenter") + return Range.from_coords(coord_start, c) + + # 4. Write column headers + doc.addRowWithStyle(c, keys, color_row_header, "BoldCenter", word_wrap=word_wrap) - for column, key in enumerate(keys): - doc.addCellWithStyle(coord_start.addColumnCopy(column), key, color_row_header, "BoldCenter") - coord_data=coord_start.addRowCopy(1) - - - lor=lod.lod2lol(lod_, keys) - - #Generate list of colors + # 5. Generate colors per column colors=[] for i in range(len(keys)): - if i <= columns_header-1: + if i < columns_header: colors.append(color_column_header) else: colors.append(color) - #Generate list of rows - return doc.addListOfRowsWithStyle(coord_data, lor, colors, styles) - -## Write cells from a list of ordered dictionaries -## @param lod List of ordered dictionaries -## @param keys. If None write all keys, Else must be a list of keys -## @param columns_header. Integer with the number of columns to apply color_header -## @return Range of the data -def helper_list_of_ordereddicts_with_totals(doc, coord_start, lod, keys=None, columns_header=1, color_row_header=ColorsNamed.Orange, color_column_header=ColorsNamed.Green, color=ColorsNamed.White, styles=None, totalcolumns=True, totalrows=True, key="#SUM"): - coord_start=C.assertCoord(coord_start) - helper_list_of_ordereddicts(doc, coord_start, lod, keys, columns_header, color_row_header, color_column_header, color, styles) - range_lod=R.from_iterable_object(coord_start.addRow(1), lod)## Adds q to skip top headers - range_lod.c_start.addColumn(columns_header) ## Adds to skip columns headers - return helper_totals_from_range (doc, range_lod, key, totalcolumns, totalrows) + # 6. Write data rows + lol_data=lod.lod2lol(lod_, keys) + range_data = doc.addListOfRowsWithStyle(c.addRowCopy(1), lol_data, colors, styles, word_wrap=word_wrap) + + # 7. Generate totals + if (column_of_totals or row_of_totals) and range_data: + # Default to skipping the first column if columns_header is not specified, + # as it's typically a label/ID column in a LOD. + skip = columns_header if columns_header > 0 else 1 if len(keys) > 1 else 0 + final_range = cross_totals_from_range(doc, range_data, key, column_of_totals, row_of_totals, skip_columns=skip) + return Range.from_coords(coord_start, final_range.c_end) + + return Range.from_coords(coord_start, range_data.c_end if range_data else c) + + +def block_from_lol(doc, coord_start, lor, headers=None, colors=ColorsNamed.White, styles=None, column_of_totals=False, row_of_totals=False, key="#SUM", title=None, word_wrap=True): + """ + Writes cells from a list of lists (lor) with optional headers and totals. + + Args: + doc (ODS): The ODS document object. + coord_start (Coord or str): Starting coordinate. + lor (list): List of lists (data rows). + headers (list, optional): List of header strings. Defaults to None. + colors (list or int, optional): Column colors. Defaults to ColorsNamed.White. + styles (list or str, optional): Column styles. Defaults to None. + column_of_totals (bool, optional): Whether to generate a column of totals to the right. Defaults to False. + row_of_totals (bool, optional): Whether to generate a row of totals at the bottom. Defaults to False. + key (str, optional): Formula key or template. Defaults to "#SUM". + title (str, optional): Main title for the block. Defaults to None. + word_wrap (bool, optional): Enable word wrap and optimal height. Defaults to True. + + Returns: + Range: The range of the data including headers and totals. + """ + coord_start = Coord.assertCoord(coord_start) + c = coord_start.copy() + + # 1. Title + if title is not None: + if not lor and not headers: + doc.addCellWithStyle(c, title, ColorsNamed.Red, "BoldCenter") + else: + num_cols = len(headers) if headers else len(lor[0]) if lor else 1 + add_of_totals = 1 if column_of_totals else 0 + range_title = Range.from_coords(c.copy(), c.addColumnCopy(num_cols - 1 + add_of_totals)) + doc.addCellMergedWithStyle(range_title, title, ColorsNamed.Red, "BoldCenter", word_wrap=word_wrap) + c.addRow(1) + + # 2. Handle empty data + if not lor and not headers: + doc.addCellWithStyle(c, _("No data to show"), ColorsNamed.White, "BoldCenter") + return Range.from_coords(coord_start, c) + + # 3. Write column headers + if headers: + doc.addRowWithStyle(c, headers, ColorsNamed.Orange, "BoldCenter", word_wrap=word_wrap) + c.addRow(1) + + # 4. Write data rows + range_data = doc.addListOfRowsWithStyle(c, lor, colors, styles, word_wrap=word_wrap) + + # 5. Generate totals + if (column_of_totals or row_of_totals) and range_data: + # Default to skipping the first column if it looks like a label column + skip = 1 if (headers and len(headers) > 1) or (lor and len(lor[0]) > 1) else 0 + final_range = cross_totals_from_range(doc, range_data, key, column_of_totals, row_of_totals, skip_columns=skip) + return Range.from_coords(coord_start, final_range.c_end) + + return Range.from_coords(coord_start, range_data.c_end if range_data else c) + + +def sheet_stylenames(doc): + """ + Creates a new sheet called "Internal style names" listing all available styles + organized into four columns: CellStyles, PageStyles, GraphicStyles, and TableStyles. + Useful for identifying available style names for use with other methods. + + Args: + doc (ODS): The ODS document object. + """ + cell_styles = doc.dict_stylenames.get("CellStyles", []) + page_styles = doc.dict_stylenames.get("PageStyles", []) + graphic_styles = doc.dict_stylenames.get("GraphicStyles", []) + table_styles = doc.dict_stylenames.get("TableStyles", []) -## It's the same of helper_list_of_ordereddicts but withth mandatory keys -## @return Range of the data -def helper_list_of_dicts(doc, coord_start, lod, keys, columns_header=0, color_row_header=ColorsNamed.Orange, color_column_header=ColorsNamed.Green, color=ColorsNamed.White, styles=None): - return helper_list_of_ordereddicts(doc, coord_start, lod, keys, columns_header=0, color_row_header=ColorsNamed.Orange, color_column_header=ColorsNamed.Green, color=ColorsNamed.White, styles=None) - -## Creates a new sheet called "Style names" with alll ods styles grouped by families -def helper_ods_sheet_stylenames(doc): - doc.createSheet("Internal style names") - for column, (family, style_names) in enumerate(doc.dict_stylenames.items()): - doc.addCellWithStyle(C("A1").addColumn(column), family, ColorsNamed.Orange, "BoldCenter") - doc.addColumnWithStyle(C("A2").addColumn(column), style_names) - doc.setColumnsWidth([6,6]) - doc.freezeAndSelect("A2") - -## This helper is used when lor length is bigger than localc limits (1048576) -## With this function you can split a lor automatically in all sheets needed -## If the number of rows is lower than max_rows makes a normal sheet -## You have to set a header of one line. -## @param doc ODS Document -## @param sheet_name Root name of the sheet -## @param lor List of rows with data -## @param headers List of strings -## @param headers_colors Color of the sheet header -## @param columns_width None=3cm. Integer=all that value, List. Defines all columns width -## @param coord_to_freeze Coord with coord to freeze -def helper_split_big_listofrows(doc, sheet_name, lor, headers, headers_colors=ColorsNamed.Orange, columns_width=None, coord_to_freeze="A2", max_rows=1048575): + max_len = max(len(cell_styles), len(page_styles), len(graphic_styles), len(table_styles)) + + lod_ = [] + for i in range(max_len): + lod_.append(OrderedDict({ + "CellStyles": cell_styles[i] if i < len(cell_styles) else "", + "PageStyles": page_styles[i] if i < len(page_styles) else "", + "GraphicStyles": graphic_styles[i] if i < len(graphic_styles) else "", + "TableStyles": table_styles[i] if i < len(table_styles) else "" + })) + + sheet_from_lod(doc, "Internal style names", lod_, freezeandselect="A2", columns_width_mode=types.ColumnsWidthMode.FROM_LOD) + +def sheet_from_lol(doc, sheetname, lor, headers, column_of_totals=False, row_of_totals=False, freezeandselect=None, titulo=None, word_wrap=True, **kwargs_columnswidth): + """ + Creates a sheet from a list of lists (lor) with headers and optional totals. + + Args: + doc (ODS): The ODS document object. + sheetname (str): The name for the new sheet. + lor (list): The list of lists (data rows). + headers (list): The list of header strings. + column_of_totals (bool, optional): Whether to generate a column of totals to the right. Defaults to False. + row_of_totals (bool, optional): Whether to generate a row of totals at the bottom. Defaults to False. + freezeandselect (str, optional): Coordinate to freeze panes at. Defaults to None. + titulo (str, optional): An optional title to merge across the top of the sheet. Defaults to None. + word_wrap (bool, optional): Enable word wrap and optimal height. Defaults to True. + **kwargs_columnswidth: Keyword arguments for setColumnsWidth. + """ + columns_width_mode = kwargs_columnswidth.get("columns_width_mode", types.ColumnsWidthMode.FROM_LOL) + char_to_cm = kwargs_columnswidth.get("char_to_cm", 0.22) + padding_cm = kwargs_columnswidth.get("padding_cm", 0.5) + min_width_cm = kwargs_columnswidth.get("min_width_cm", 2.0) + max_width_cm = kwargs_columnswidth.get("max_width_cm", 15.0) + value = kwargs_columnswidth.get("value") + + doc.createSheet(sheetname) + + range_block = block_from_lol( + doc, "A1", lor, headers, + column_of_totals=column_of_totals, + row_of_totals=row_of_totals, + title=titulo, + word_wrap=word_wrap + ) + + if value is None: + if columns_width_mode == types.ColumnsWidthMode.FROM_SHEET_CELLS: + value = doc + else: + value = [headers] + lor if headers else lor + + doc.setColumnsWidth(value, columns_width_mode, char_to_cm, padding_cm, min_width_cm, max_width_cm) + + if freezeandselect: + doc.freezeAndSelect(freezeandselect, freezeandselect, freezeandselect) + + return range_block + +def sheet_split_with_big_lol(doc, sheet_name, lor, headers, headers_colors=ColorsNamed.Orange, coord_to_freeze="A2", max_rows=1048575, word_wrap=True): + """ + Splits a large list of rows across multiple sheets if it exceeds LibreOffice Calc's row limits. + + If the number of rows is lower than `max_rows`, it generates a single normal sheet. + A one-line header is added to each generated sheet. + + Args: + doc (ODS): The ODS document object. + sheet_name (str): The root name of the generated sheet(s). + lor (list): List of rows containing the data. + headers (list): List of strings representing the column headers. + headers_colors (int, optional): Color for the sheet headers. Defaults to ColorsNamed.Orange. + columns_width (int, list, or None, optional): Defines column widths. If None, sets automatically. + If int, applies to all columns. If list, specifies width per column. Defaults to None. + coord_to_freeze (Coord or str, optional): Coordinate to freeze panes at. Defaults to "A2". + max_rows (int, optional): Maximum number of rows per sheet (Calc limit is 1,048,576). Defaults to 1048575. + word_wrap (bool, optional): Enable word wrap and optimal height. Defaults to True. + """ ceil_=ceil(len(lor)/max_rows) for num_sheet in range(ceil_): #Sets name and headers @@ -247,21 +455,110 @@ def helper_split_big_listofrows(doc, sheet_name, lor, headers, headers_colors=Co else: name=sheet_name doc.createSheet(name) - doc.addRowWithStyle("A1", headers, headers_colors, "BoldCenter") + doc.addRowWithStyle("A1", headers, headers_colors, "BoldCenter", word_wrap=word_wrap) #Splits data from_=max_rows*num_sheet to_=max_rows*(num_sheet+1) if len(lor)>=max_rows*(num_sheet+1) else len(lor) - doc.addListOfRowsWithStyle("A2", lor[from_:to_]) + doc.addListOfRowsWithStyle("A2", lor[from_:to_], word_wrap=word_wrap) #Sets width of columns - if columns_width is None: - doc.setColumnsWidth(ODS.columnsWidth_from_lol(lor)) + + doc.setColumnsWidth(lor[from_:to_], types.ColumnsWidthMode.FROM_LOL) + + doc.freezeAndSelect(Coord.assertCoord(coord_to_freeze)) + + +def sheet_from_lod(doc, sheetname, lod_, column_of_totals=False, row_of_totals=False, freezeandselect=None, title=None, word_wrap=True, styles=None, **kwargs_columnswidth): + """ + kwargs son los parametros de la funcion setColumnsWidth + """ + columns_width_mode=kwargs_columnswidth.get("columns_width_mode", types.ColumnsWidthMode.FROM_LOD) + char_to_cm=kwargs_columnswidth.get("char_to_cm", 0.22) + padding_cm=kwargs_columnswidth.get("padding_cm", 0.5) + min_width_cm=kwargs_columnswidth.get("min_width_cm", 2.0) + max_width_cm=kwargs_columnswidth.get("max_width_cm", 15.0) + value = kwargs_columnswidth.get("value") + + doc.createSheet(sheetname) + + range_final=block_from_lod(doc, "A1", lod_, column_of_totals=column_of_totals, row_of_totals=row_of_totals, word_wrap=word_wrap, styles=styles, title=title ) + + if value is None: + if columns_width_mode == types.ColumnsWidthMode.FROM_SHEET_CELLS: + value = doc else: - if isinstance(columns_width, int): - columns_width = [columns_width] * len(headers) - doc.setColumnsWidth(columns_width) + value = lod_ + + doc.setColumnsWidth(value, columns_width_mode, char_to_cm, padding_cm, min_width_cm, max_width_cm) + if freezeandselect: + doc.freezeAndSelect(freezeandselect,freezeandselect, freezeandselect) + return range_final + + - doc.freezeAndSelect(C.assertCoord(coord_to_freeze)) + +def block_from_lod_with_headers(doc, lod_, coord, subtitles=[], titulo=None, column_of_totals=False, row_of_totals=False, freezeandselect=None, key="#SUM", word_wrap=True): + """ + Writes data from a list of ordered dictionaries with custom header groups, and optional totals. + + Args: + doc (ODS): The ODS document object. + lod_ (list): List of ordered dictionaries containing the data. + coord (Coord or str): Starting coordinate. + subtitles (list): List of lists [title, first_key] defining header groups. + titulo (str, optional): Main title for the entire block. Defaults to None. + column_of_totals (bool, optional): Whether to generate column totals. Defaults to False. + row_of_totals (bool, optional): Whether to generate row totals. Defaults to False. + freezeandselect (str or Coord, optional): Coordinate to freeze and select. Defaults to None. + key (str, optional): Formula key for totals (e.g., "#SUM"). Defaults to "#SUM". + word_wrap (bool, optional): Enable word wrap and optimal height. Defaults to True. + + Returns: + Range: The data range (excluding headers). + """ + if len(lod_)==0: + doc.addCell(coord, "Sin datos que consigar") + return + + coord=Coord.assertCoord(coord) + coord=Coord(coord.string())# To avoid carry internal coord movements + keys=lod.lod_keys(lod_) + + + #Añado en la lista un campo nuevo de indice de inicio y indice de final + for i in range(len(subtitles)): + subtitles[i].append(keys.index(subtitles[i][1])) #Añade el indice de inicio + if i== len(subtitles)-1:#Ultimo titulo + subtitles[i].append(len(keys)-1) + else:# hay mas titulos + subtitles[i].append(keys.index(subtitles[i+1][1])-1) + # Crea titulo principal + if titulo is not None: + if column_of_totals: + add_of_totals=1 + else: + add_of_totals=0 + doc.addCellMergedWithStyle(Range.from_coords(coord,coord.addColumnCopy(len(keys)-1+add_of_totals)), titulo, ColorsNamed.Red, "BoldCenter", word_wrap=word_wrap) + coord.addRow(1) + + + # Crea titulos + for title, key_start, index_start, index_end in subtitles: + c_start=coord.addColumnCopy(index_start) + c_fin=coord.addColumnCopy(index_end) + doc.addCellMergedWithStyle(Range.from_coords(c_start,c_fin), title, ColorsNamed.Orange, "BoldCenter", word_wrap=word_wrap) + + + #Imprime listas de diccionarios + range_=block_from_lod(doc, coord.addRowCopy(1),lod_,color_row_header=ColorsNamed.Yellow, word_wrap=word_wrap) + + if column_of_totals or row_of_totals: + cross_totals_from_range(doc, range_, key, column_of_totals, row_of_totals) + + if freezeandselect: + doc.freezeAndSelect(freezeandselect, freezeandselect, freezeandselect) + + return range_ diff --git a/unogenerator/templates/standard.ods b/unogenerator/templates/standard.ods index 983bd0f..e340517 100644 Binary files a/unogenerator/templates/standard.ods and b/unogenerator/templates/standard.ods differ diff --git a/unogenerator/types.py b/unogenerator/types.py new file mode 100644 index 0000000..e12d08b --- /dev/null +++ b/unogenerator/types.py @@ -0,0 +1,28 @@ +from enum import Enum, unique, auto + + +@unique +class ColumnsWidthMode(Enum): + MANUAL = auto() # Para pasar los anchos manualmente + + FROM_LIST = auto() # Para calcular anchos a partir de una lista simple (e.g., una fila de encabezados) + + FROM_LOL = auto() # Para calcular anchos a partir de una lista de listas (matriz de datos) + FROM_LOL_0 = auto() #Para usar los valores del diccionario 0 + FROM_LOL_1 = auto() #Para usar los valores del diccionario 1 + FROM_LOL_2 = auto() #Para usar los valores del diccionario 2 + FROM_LOL_QUANTILE_90 = auto() # Para usar los valores del percentil 90 + FROM_LOL_QUANTILE_90_ONLY_100= auto() # Para usar los valores del percentil 90 + FROM_LOL_ONLY_100 = auto() # Para usar los 100 primeras listas + + + FROM_LOD = auto() # Para calcular anchos a partir de una lista de diccionarios + FROM_LOD_0 = auto() #Para usar los valores del diccionario 0 + FROM_LOD_1 = auto() #Para usar los valores del diccionario 1 + FROM_LOD_2 = auto() #Para usar los valores del diccionario 2 + FROM_LOD_KEYS = auto() #Para usar las claves + FROM_LOD_QUANTILE_90 = auto() # Para usar los valores del percentil 90 + FROM_LOD_QUANTILE_90_ONLY_100 = auto() # Para usar los valores del percentil 90 + FROM_LOD_ONLY_100 = auto() # Para usar los 100 primeros diccionarios + + FROM_SHEET_CELLS= auto()# Como valor se pasa el doc, saca los valores y calcula el width \ No newline at end of file diff --git a/unogenerator/unogenerator.py b/unogenerator/unogenerator.py index f8aff4b..abb2749 100644 --- a/unogenerator/unogenerator.py +++ b/unogenerator/unogenerator.py @@ -6,6 +6,7 @@ from uno import getComponentContext, createUnoStruct, systemPathToFileUrl, Any, ByteSequence from com.sun.star.beans import PropertyValue from com.sun.star.text import ControlCharacter +from com.sun.star.table.CellVertJustify import CENTER, STANDARD from com.sun.star.awt import Size from com.sun.star.sheet.ConditionEntryType import COLORSCALE from com.sun.star.style.ParagraphAdjust import RIGHT, LEFT @@ -21,7 +22,7 @@ from subprocess import Popen, PIPE, run # Added run for executing external commands from tempfile import TemporaryDirectory from time import sleep -from unogenerator import __version__, exceptions +from unogenerator import __version__, columnswidth, exceptions, types from unogenerator.commons import Coord, ColorsNamed, Range as R, datetime2uno, guess_object_style, datetime2localc1989, date2localc1989, time2localc1989, is_formula, uno2datetime, string_float2object from pydicts.currency import Currency from pydicts.percentage import Percentage @@ -616,9 +617,26 @@ def pageBreak(self, style="Standard", page_before=True): class ODS(ODF): + """ + Class for generating ODS (OpenDocument Spreadsheet) documents. + Provides methods for adding data, styling, and generating totals. + """ def __init__(self, template=None, server=None): + """ + Initializes an ODS document. + + Args: + template (str, optional): Path to an ODS template file. Defaults to None. + server (LibreofficeServer, optional): An existing server instance. Defaults to None. + """ ODF.__init__(self, template, server) self._remove_default_sheet=True + self._wrapped_rows = set() # Track rows that have word wrap enabled to avoid overriding them with False + self.default_row_height = None # Default row height in 1/100th mm. If None, it won't be explicitly set. + self.default_cell_style = "Default" # Default cell style name. + + + def getRemoveDefaultSheet(self): return self._remove_default_sheet @@ -647,6 +665,7 @@ def createSheet(self, name, index=None): index=len(sheets) sheets.insertNewByName(name, index) self.setActiveSheet(index) + self.sheet.getRows().OptimalHeight = False def removeSheet(self, index): current=self.sheet.Name @@ -659,161 +678,86 @@ def setActiveSheet(self, index): self.sheet=self.document.getSheets().getByIndex(index) logger.debug(f"Sheet '{self.sheet.Name}' ({self.sheet_index}) is now active") return self.sheet - - @staticmethod - def columnsWidth_from_list(l, char_to_cm=0.22, padding_cm=0.5, min_width_cm=2.0, max_width_cm=15.0): + + def _set_row_optimal_height(self, row_index, word_wrap): """ - Calcula el ancho recomendado de las columnas basándose en el percentil 90 - de la longitud de los caracteres de una lista de listas (matriz). - - Toma como máximo las 100 primeras filas para optimizar el rendimiento. - Retorna una lista de anchos en cm ordenada por columnas (índice 0, 1, 2...). + Internal method to set row optimal height honoring word wrap memory. """ - if not l: - return [] - - recommended_widths = [] - for v in l: - calculated_width = (len(str(v)) * char_to_cm) + padding_cm - - # Acotar dentro de los márgenes permitidos - final_width = max(min_width_cm, min(calculated_width, max_width_cm)) - - # Redondear para mantener el formato limpio - recommended_widths.append(round(final_width, 2)) - - return recommended_widths + if word_wrap: + self._wrapped_rows.add((self.sheet.Name, row_index)) + self.sheet.getRows().getByIndex(row_index).OptimalHeight = True + else: + if (self.sheet.Name, row_index) not in self._wrapped_rows: + row = self.sheet.getRows().getByIndex(row_index) + row.OptimalHeight = word_wrap # False + if self.default_row_height: + row.Height = self.default_row_height - @staticmethod - def columnsWidth_from_lol(matrix, char_to_cm=0.22, padding_cm=0.5, min_width_cm=2.0, max_width_cm=15.0, percentile=100): + def _set_rows_optimal_height(self, row_range_uno, word_wrap): """ - Calcula el ancho recomendado de las columnas basándose en el percentil 90 - de la longitud de los caracteres de una lista de listas (matriz). - - Toma como máximo las 100 primeras filas para optimizar el rendimiento. - Retorna una lista de anchos en cm ordenada por columnas (índice 0, 1, 2...). + Internal method to set optimal height for a range of rows honoring memory. """ - if not matrix or not matrix[0]: - return [] - - # 1. Acotar a las primeras 100 filas - sample = matrix[:100] - - # 2. Determinar el número de columnas basándonos en la fila más larga del sample - # (Por si hay filas con longitudes variables) - num_cols = max(len(row) for row in sample) - - # Inicializar una lista de listas para guardar las longitudes de cada columna - # Ejemplo para 3 columnas: [[], [], []] - lengths_per_col = [[] for _ in range(num_cols)] - - # 3. Recopilar las longitudes de los caracteres - for row in sample: - for col_idx in range(num_cols): - # Si la fila actual es más corta que num_cols, rellenamos con vacío - value = row[col_idx] if col_idx < len(row) else "" - val_str = "" if value is None else str(value) - lengths_per_col[col_idx].append(len(val_str)) - - # 4. Calcular el percentil 90 y convertir a centímetros - recommended_widths = [] - - for lengths in lengths_per_col: - if not lengths: - p90_length = 0 - elif len(lengths) < 2: - # If there's only one element, that's our effective maximum/percentile - p90_length = lengths[0] - elif percentile == 100: - p90_length = max(lengths) - else: - # For P-th percentile, we need the (P-1)-th index from quantiles(n=100) - # (e.g., 90th percentile is index 89) - p90_length = quantiles(lengths, n=100, method='inclusive')[percentile - 1] - - # Conversión a centímetros basándonos en el texto - calculated_width = (p90_length * char_to_cm) + padding_cm - - # Acotar dentro de los márgenes permitidos - final_width = max(min_width_cm, min(calculated_width, max_width_cm)) + start_row = row_range_uno.RangeAddress.StartRow + end_row = row_range_uno.RangeAddress.EndRow + + if word_wrap: + # Optimization: only iterate if we need to add new rows to the set + for r in range(start_row, end_row + 1): + self._wrapped_rows.add((self.sheet.Name, r)) + row_range_uno.getRows().OptimalHeight = True + else: + # Optimization: If the set is empty, we can skip the loop and do ONE bulk call. + # Even if not empty, we could check for intersection, but an empty set is the common case. + if not self._wrapped_rows: + rows = row_range_uno.getRows() + rows.OptimalHeight = word_wrap # False + if self.default_row_height: + rows.Height = self.default_row_height + return + + # First, set everything to OptimalHeight=False and Height=default_row_height for rows that should NOT be wrapped. + # We do this in blocks to be efficient. + current_start = -1 + for r in range(start_row, end_row + 1): + if (self.sheet.Name, r) not in self._wrapped_rows: + if current_start == -1: + current_start = r + else: + if current_start != -1: + # Set block from current_start to r-1 + rows = self.sheet.getCellRangeByPosition(0, current_start, 0, r - 1).getRows() + rows.OptimalHeight = word_wrap # False + if self.default_row_height: + rows.Height = self.default_row_height + current_start = -1 - # Redondear para mantener el formato limpio - recommended_widths.append(round(final_width, 2)) - - return recommended_widths - - @staticmethod - def columnsWidth_from_lod(lod, char_to_cm=0.22, padding_cm=0.5, min_width_cm=2.0, max_width_cm=15.0, percentile=100): + if current_start != -1: + # Set last block + rows = self.sheet.getCellRangeByPosition(0, current_start, 0, end_row).getRows() + rows.OptimalHeight = word_wrap # False + if self.default_row_height: + rows.Height = self.default_row_height + + def setColumnsWidth(self, value: list[dict] | list[list] | list, columns_width_mode=types.ColumnsWidthMode.MANUAL, char_to_cm=0.22, padding_cm=0.5, min_width_cm=2.0, max_width_cm=15.0): """ - Calcula el ancho recomendado de las columnas basándose en el percentil 90 - de la longitud de los caracteres de una lista de diccionarios (lod). - - Toma como máximo los 100 primeros registros para optimizar el rendimiento. - Retorna una lista de anchos en cm listos para pasar a tu método setColumnsWidth. + Sets columns width + Can use several types.ColumnsWidthMode + By default uses MANUAL and value is a list + Value list is set in cm """ - if not lod: - return [] - - # 1. Acotar a los primeros 100 elementos (o menos si no hay tantos) - sample = lod[:100] - - # 2. Extraer las claves (columnas) manteniendo el orden del primer diccionario - keys = list(lod[0].keys()) - - # Inicializar un diccionario para agrupar las longitudes de cada columna - # Ejemplo: {'col1': [4, 5, 12, ...], 'col2': [2, 2, 3, ...]} - lengths_per_col = {key: [] for key in keys} - - # 3. Recopilar las longitudes de los caracteres (convertidos a string) - for row in sample: - for key in keys: - # Usamos str() para manejar números, fechas o None de forma segura - value = row.get(key, "") - val_str = "" if value is None else str(value) - lengths_per_col[key].append(len(val_str)) - - # 4. Calcular el percentil 90 y convertir a centímetros - recommended_widths = [] - - for key in keys: - lengths = lengths_per_col[key] - - if not lengths: - p90_length = 0 - elif len(lengths) < 2: - # statistics.quantiles requiere al menos un par de datos para calcular, - # si solo hay uno, ese es nuestro valor. - p90_length = lengths[0] - elif percentile == 100: - p90_length = max(lengths) - else: - # quantiles(datos, n=10) nos da los deciles. El índice 8 corresponde al percentil 90. - # Ejemplo: si n=10, devuelve 9 puntos de corte. El 8º corte separa el 90% inferior del 10% superior. - # For P-th percentile, we need the (P-1)-th index from quantiles(n=100) - # (e.g., 90th percentile is index 89) - p90_length = quantiles(lengths, n=100, method='inclusive')[percentile - 1] - - # Convertir caracteres a cm con tus factores de escala - calculated_width = (p90_length * char_to_cm) + padding_cm - - # Acotar entre los límites mínimos y máximos - final_width = max(min_width_cm, min(calculated_width, max_width_cm)) - - # Redondear a 2 decimales para que quede limpio - recommended_widths.append(round(final_width, 2)) - return recommended_widths + columns_widths=columnswidth.guessColumnsWidth(value, columns_width_mode, char_to_cm, padding_cm, min_width_cm, max_width_cm) + columns = self.sheet.getColumns() + if columns.Count0: styles=[] for o in list_rows[0]: - styles.append(guess_object_style(o)) + styles.append(guess_object_style(o, self.default_cell_style)) elif styles.__class__.__name__=="list": styles=styles else: @@ -1073,14 +1064,36 @@ def addListOfRowsWithStyle(self, coord_start, list_rows, colors=ColorsNamed.Whit raise exceptions.UnogeneratorException(_("Colors must have the same number of items as data columns")) if len(styles)!=columns: raise exceptions.UnogeneratorException(_("Styles must have the same number of items as data columns")) - + #Create styles by columns cellranges if rows>0: - for c, o in enumerate(list_rows[0]): - columnrange=self.sheet.getCellRangeByPosition(coord_start.letterIndex()+c, coord_start.numberIndex(), coord_start.letterIndex()+c, coord_start.numberIndex()+rows-1) - columnrange.setPropertyValue("CellStyle", styles[c]) - columnrange.setPropertyValue("CellBackColor", colors[c]) - return range_ + # Optimization: If all styles are default and all colors are White, we can skip this loop + if all(s == self.default_cell_style for s in styles) and all(c == ColorsNamed.White for c in colors): + pass + else: + for c, o in enumerate(list_rows[0]): + columnrange=self.sheet.getCellRangeByPosition(coord_start.letterIndex()+c, coord_start.numberIndex(), coord_start.letterIndex()+c, coord_start.numberIndex()+rows-1) + columnrange.setPropertyValue("CellStyle", styles[c]) + columnrange.setPropertyValue("CellBackColor", colors[c]) + + range_uno.IsTextWrapped = word_wrap + range_uno.VertJustify = CENTER if word_wrap else STANDARD + self._set_rows_optimal_height(range_uno, word_wrap) + + # Finally add content + r=[] + for row in list_rows: + r_row=[] + for o in row: + r_row.append(self.__object_to_dataarray_element(o)) + r.append(r_row) + + if formulas is True: + self.__setFormulaArray(range_uno, r) + else: + self.__setDataArray(range_uno, r) + + return R.from_coords_indexes(*range_indexes) def addListOfColumns(self, coord_start, list_columns, formulas=True): @@ -1095,24 +1108,44 @@ def addListOfColumns(self, coord_start, list_columns, formulas=True): return self.addListOfRows(coord_start, list_rows, formulas) - ## @param style If None tries to guess it - def addListOfColumnsWithStyle(self, coord_start, list_columns, colors=ColorsNamed.White, styles=None, formulas=True): + def addListOfColumnsWithStyle(self, coord_start, list_columns, colors=ColorsNamed.White, styles=None, formulas=True, word_wrap=True): """ - Colors and styles are the colors of the first column. Code is different + Colors and styles are applied per row of the input columns. Parameters: - formulas Boolean. If true formulas will be written as formula. If false as string + - word_wrap Boolean. If True, enables word wrap and optimal row height. If False, keeps fixed row height. Return: Range """ coord_start=Coord.assertCoord(coord_start) - range_=self.addListOfColumns(coord_start, list_columns, formulas) - if range_ is None: - return - - columns=range_.numColumns() - rows=range_.numRows() + columns=len(list_columns) + if columns==0: + rows=0 + else: + rows=len(list_columns[0]) + + if rows==0 or columns==0: + logger.debug(_("addListOfColumnsWithStyle has {0} rows and {1} columns. Nothing to write. Ignoring...").format(rows, columns)) + return + + range_indexes=[coord_start.letterIndex(), coord_start.numberIndex(), coord_start.letterIndex()+columns-1, coord_start.numberIndex()+rows-1] + range_uno=self.sheet.getCellRangeByPosition(*range_indexes) + + # Finally add content + list_rows=lol.lol_transposed(list_columns) + r=[] + for row in list_rows: + r_row=[] + for o in row: + r_row.append(self.__object_to_dataarray_element(o)) + r.append(r_row) + + if formulas is True: + self.__setFormulaArray(range_uno, r) + else: + self.__setDataArray(range_uno, r) # Parse colors. if colors.__class__.__name__=="list": @@ -1126,7 +1159,7 @@ def addListOfColumnsWithStyle(self, coord_start, list_columns, colors=ColorsName if styles is None and rows>0: styles=[] for o in list_columns[0]: - styles.append(guess_object_style(o)) + styles.append(guess_object_style(o, self.default_cell_style)) elif styles.__class__.__name__=="list": styles=styles else: @@ -1134,31 +1167,44 @@ def addListOfColumnsWithStyle(self, coord_start, list_columns, colors=ColorsName if len(colors)!=rows: - raise exceptions.UnogeneratorException(_("Colors must have the same number of items as data columns")) + raise exceptions.UnogeneratorException(_("Colors must have the same number of items as data rows")) if len(styles)!=rows: - raise exceptions.UnogeneratorException(_("Styles must have the same number of items as data columns")) - - #Create styles by columns cellranges + raise exceptions.UnogeneratorException(_("Styles must have the same number of items as data rows")) + + #Create styles by rows if rows>0: - for c, o in enumerate(list_columns[0]): - columnrange=self.sheet.getCellRangeByPosition(coord_start.letterIndex(), coord_start.numberIndex()+c, coord_start.letterIndex()+columns-1, coord_start.numberIndex()+c) - columnrange.setPropertyValue("CellStyle", styles[c]) - columnrange.setPropertyValue("CellBackColor", colors[c]) - return range_ + # Optimization: If all styles are default and all colors are White, we can skip this loop + if all(s == self.default_cell_style for s in styles) and all(c == ColorsNamed.White for c in colors): + pass + else: + for c, o in enumerate(list_columns[0]): + columnrange=self.sheet.getCellRangeByPosition(coord_start.letterIndex(), coord_start.numberIndex()+c, coord_start.letterIndex()+columns-1, coord_start.numberIndex()+c) + columnrange.setPropertyValue("CellBackColor", colors[c]) + columnrange.setPropertyValue("CellStyle", styles[c]) + + range_uno.IsTextWrapped = word_wrap + range_uno.VertJustify = CENTER if word_wrap else STANDARD + self._set_rows_optimal_height(range_uno, word_wrap) + + return R.from_coords_indexes(*range_indexes) ## @param style If None tries to guess it + ## @param word_wrap Boolean. If True, enables word wrap and optimal row height. If False, keeps fixed row height. ## @param rewritewrite If color is ColorsNamed.White, rewrites the color to White instead of ignoring it. Ignore it gains 0.200 ms ## THIS IS THE WAY TO CREATE FORMULAS - def addCellWithStyle(self, coord, o, color=ColorsNamed.White, style=None): + def addCellWithStyle(self, coord, o, color=ColorsNamed.White, style=None, word_wrap=True): coord=Coord.assertCoord(coord) if style is None: - style=guess_object_style(o) + style=guess_object_style(o, self.default_cell_style) cell=self.sheet.getCellByPosition(coord.letterIndex(), coord.numberIndex()) self.__object_to_cell(cell, o) cell.setPropertyValue("CellStyle", style) cell.setPropertyValue("CellBackColor", color) + cell.IsTextWrapped = word_wrap + cell.VertJustify = CENTER if word_wrap else STANDARD + self._set_row_optimal_height(coord.numberIndex(), word_wrap) def setCellName(self, reference, name): """ @@ -1261,12 +1307,23 @@ def addCellMerged(self, range, o): self.__object_to_cell(cell, o) return cell - def addCellMergedWithStyle(self, range, o, color=ColorsNamed.White, style=None): - cell=self.addCellMerged(range, o) + def addCellMergedWithStyle(self, range_o, o, color=ColorsNamed.White, style=None, word_wrap=True): + range_obj=R.assertRange(range_o) + range_uno = range_obj.uno_range(self.sheet) + range_uno.merge(True) + if style is None: - style=guess_object_style(o) - cell.setPropertyValue("CellStyle", style) - cell.setPropertyValue("CellBackColor", color) + style=guess_object_style(o, self.default_cell_style) + + range_uno.CellStyle = style + range_uno.CellBackColor = color + range_uno.IsTextWrapped = word_wrap + range_uno.VertJustify = CENTER if word_wrap else STANDARD + self._set_rows_optimal_height(range_uno, word_wrap) + + cell = self.sheet.getCellByPosition(range_obj.c_start.letterIndex(), range_obj.c_start.numberIndex()) + self.__object_to_cell(cell, o) + return cell def freezeAndSelect(self, freeze, selected=None, topleft=None): freeze=Coord.assertCoord(freeze) @@ -1445,8 +1502,11 @@ def __cell_to_object(self, cell, detailed=False): if detailed is False: return value else: + + # Determine if the cell is merged + is_merged = cell.IsMerged formula = cell.getFormula() if isformula else None - return {"value":value, "string": cell.getString(), "style":cell.CellStyle, "class": value.__class__.__name__, "is_formula": isformula, "formula": formula} + return {"value":value, "string": cell.getString(), "style":cell.CellStyle, "class": value.__class__.__name__, "is_formula": isformula, "formula": formula, "is_merged": is_merged} ## Return a Range object with the limits of the index sheet def getSheetRange(self): @@ -1602,8 +1662,22 @@ def toDictionaryOfDetailedValues(self): return r class ODS_Standard(ODS): + """ + Optimized ODS class that uses the standard project template. + Includes predefined styles (Normal, BoldCenter, etc.) and optimized row heights (452). + """ def __init__(self, server=None): + """ + Initializes an ODS_Standard document using the built-in template. + + Args: + server (LibreofficeServer, optional): An existing server instance. Defaults to None. + """ ODS.__init__(self, files('unogenerator') / 'templates/standard.ods', server) + self.default_row_height = 452 + self.default_cell_style = "Normal" + + class ODT_Standard(ODT): def __init__(self, server=None):