feat!: faster generation and formatting utils for excel exports (#36323)

* feat: Style builder for report xlsx formatting

* fix: update report to use direct import for query report execution

* refactor: simplify module method retrieval in report execution

* feat: get xlsx styles for report

* refactor: enhance XLSXStyleBuilder with currency formatting and default style registration

* feat: add xlsxwriter dependency for enhanced XLSX report generation

* refactor: enhance XLSXStyleBuilder with improved style registration and formatting methods

* feat: enhance XLSX export functionality with improved styling and metadata support

* refactor: default formatting of currency

* chore: remove some typo

* feat: update make_xlsx function to use xlsxwriter for improved Excel file generation and styling

* perf: some micro optimisations

* refactor: inline generator back and improve condition

* refactor: replace frappe.request_cache with functools.cache

* fix: handle styling in email

* fix: fix old test case to handle styles in export

* refactor: enhance XLSX style handling and registration methods

* refactor: improve currency formatting logic

* fix: update make_xlsx to use constant_memory for large datasets and improve row style handling

* fix: handle None style_id in XLSXStyleBuilder methods to prevent errors

* fix: include owner field with proper doctype naming

* fix: set default date format in XLSX workbook creation

* fix: pass applied filters to metadata

* fix: getting accurate field info for report view exporting

* chore: Minor changes

* feat: add function to generate default XLSX styles for exports

* feat: integrate default XLSX styles into builder report export functionality

* feat: styles on export docs xlsx

* feat: enhance make_xlsx function to support file path saving

* feat: add make_xls function for creating Excel files in old format and improve sheet name sanitization

* fix: handle default date formatting

* refactor: changes xlsx builder usage

* refactor: update xlsx style builder usage

* refactor: enhance field info retrieval with default field support

* fix: handle update key in report data

* refactor: enhance get_field_info to include options and improve label retrieval

* fix: improve error handling for unsupported file formats and ensure applied filters are set correctly

* refactor: update XLSX header index handling and improve metadata structure

* fix: handle currency formatting in reportview export

* fix: update default date format to datetime format in XLSX creation

* fix: update serial number field in auto email report to use 'sr' instead of 'idx'

* fix: enhance XLSX styling by adding right alignment for specific field types

* chore: remove unused code

* fix: update XLSXMetadata attributes for improved report styling options

* perf: further improve currency styling

* fix: correct column index mapping in XLSX export header

* refactor: optimize indentation style registration in XLSXStyleBuilder

* perf: improve apply_indentations

* fix: reduce more attr lookup

* refactor: remove duplication

* fix: use report name in XLSX export instead of hardcoded title

* fix: remove ignore_visible_idx from XLSXMetadata

* fix: review

* fix: update XLSX style fetching logic in build_xlsx_data function

* fix: add right alignment to date, time, and datetime styles in XLSXStyleBuilder

* fix: simplify number format handling in XLSXStyleBuilder

* fix: register common styles in XLSXStyleBuilder for improved style management

* test: add tests for XLSX styles structure and fieldtype column styles in XLSXStyleBuilder

---------

Co-authored-by: Sagar Vora <16315650+sagarvora@users.noreply.github.com>
This commit is contained in:
Abdeali Chharchhodawala 2026-04-21 19:07:43 +05:30 committed by GitHub
parent 9f5b45167d
commit 690826ff9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 879 additions and 158 deletions

View file

@ -7,7 +7,7 @@ from frappe.model import display_fieldtypes, no_value_fields
from frappe.model import table_fields as table_fieldtypes from frappe.model import table_fields as table_fieldtypes
from frappe.utils import flt, format_duration, groupby_metric from frappe.utils import flt, format_duration, groupby_metric
from frappe.utils.csvutils import build_csv_response from frappe.utils.csvutils import build_csv_response
from frappe.utils.xlsxutils import build_xlsx_response from frappe.utils.xlsxutils import build_xlsx_response, get_default_xlsx_styles
class Exporter: class Exporter:
@ -253,7 +253,17 @@ class Exporter:
if self.file_type == "CSV": if self.file_type == "CSV":
build_csv_response(self.get_csv_array_for_export(), _(self.doctype)) build_csv_response(self.get_csv_array_for_export(), _(self.doctype))
elif self.file_type == "Excel": elif self.file_type == "Excel":
build_xlsx_response(self.get_csv_array_for_export(), _(self.doctype)) data = self.get_csv_array_for_export()
styles = get_default_xlsx_styles(
columns=self.fields,
# exclude header row
data=data[1:],
# from the second child row onwards, parent values will be empty
# so currency value from parent doc may be absent, avoid inconsistency
currency_formatting=False,
)
build_xlsx_response(data, _(self.doctype), styles=styles)
def group_children_data_by_parent(self, children_data: dict[str, list]): def group_children_data_by_parent(self, children_data: dict[str, list]):
return groupby_metric(children_data, key="parent") return groupby_metric(children_data, key="parent")

View file

@ -5,16 +5,17 @@ import json
import threading import threading
import frappe import frappe
import frappe.desk.query_report
from frappe import _, scrub from frappe import _, scrub
from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles
from frappe.core.doctype.page.page import delete_custom_role from frappe.core.doctype.page.page import delete_custom_role
from frappe.desk.query_report import run
from frappe.desk.reportview import append_totals_row from frappe.desk.reportview import append_totals_row
from frappe.model.document import Document from frappe.model.document import Document
from frappe.modules import make_boilerplate from frappe.modules import make_boilerplate
from frappe.modules.export_file import export_to_files from frappe.modules.export_file import export_to_files
from frappe.utils import cint, cstr from frappe.utils import cint, cstr
from frappe.utils.safe_exec import check_safe_sql_query, safe_exec from frappe.utils.safe_exec import check_safe_sql_query, safe_exec
from frappe.utils.xlsxutils import XLSXMetadata, XLSXStyleBuilder
class Report(Document): class Report(Document):
@ -211,11 +212,14 @@ class Report(Document):
return res return res
def get_module_method(self, method):
module = self.module or frappe.db.get_value("DocType", self.ref_doctype, "module")
method_path = get_report_module_dotted_path(module, self.name) + "." + method
return frappe.get_attr(method_path)
def execute_module(self, filters): def execute_module(self, filters):
# report in python module # report in python module
module = self.module or frappe.db.get_value("DocType", self.ref_doctype, "module") return self.get_module_method("execute")(frappe._dict(filters))
method_name = get_report_module_dotted_path(module, self.name) + ".execute"
return frappe.get_attr(method_name)(frappe._dict(filters))
def execute_script(self, filters): def execute_script(self, filters):
# server script # server script
@ -251,7 +255,7 @@ class Report(Document):
self, filters=None, user=None, ignore_prepared_report=False, are_default_filters=True self, filters=None, user=None, ignore_prepared_report=False, are_default_filters=True
): ):
columns, result = [], [] columns, result = [], []
data = frappe.desk.query_report.run( data = run(
self.name, self.name,
filters=filters, filters=filters,
user=user, user=user,
@ -323,8 +327,6 @@ class Report(Document):
columns = params.get("fields") columns = params.get("fields")
elif params.get("columns"): elif params.get("columns"):
columns = params.get("columns") columns = params.get("columns")
elif params.get("fields"):
columns = params.get("fields")
else: else:
columns = [["name", self.ref_doctype]] columns = [["name", self.ref_doctype]]
columns.extend( columns.extend(
@ -457,6 +459,18 @@ class Report(Document):
self.db_set("disabled", cint(disable)) self.db_set("disabled", cint(disable))
def get_xlsx_styles_from_module(self, metadata: XLSXMetadata) -> dict:
if self.is_standard != "Yes" or self.report_type not in ("Query Report", "Script Report"):
return
try:
method = self.get_module_method("get_xlsx_styles")
except AttributeError:
# Ignore if hook(method) is not defined
return
return method(metadata)
def is_prepared_report_enabled(report): def is_prepared_report_enabled(report):
return cint(frappe.db.get_value("Report", report, "prepared_report")) return cint(frappe.db.get_value("Report", report, "prepared_report"))

View file

@ -18,6 +18,7 @@ from frappe.monitor import add_data_to_monitor
from frappe.permissions import get_role_permissions, get_roles, has_permission from frappe.permissions import get_role_permissions, get_roles, has_permission
from frappe.utils import cint, cstr, flt, format_datetime, format_duration, formatdate, get_html_format, sbool from frappe.utils import cint, cstr, flt, format_datetime, format_duration, formatdate, get_html_format, sbool
from frappe.utils.caching import request_cache from frappe.utils.caching import request_cache
from frappe.utils.xlsxutils import XLSXMetadata, XLSXStyleBuilder, handle_html, make_xlsx
def get_report_doc(report_name): def get_report_doc(report_name):
@ -368,7 +369,6 @@ def run_export_query_job(user_email: str, form_params, csv_params):
def _export_query(form_params, csv_params, populate_response=True): def _export_query(form_params, csv_params, populate_response=True):
from frappe.desk.utils import get_csv_bytes, provide_binary_file from frappe.desk.utils import get_csv_bytes, provide_binary_file
from frappe.utils.xlsxutils import handle_html, make_xlsx
report_name = form_params.report_name report_name = form_params.report_name
file_format_type = form_params.file_format_type file_format_type = form_params.file_format_type
@ -390,7 +390,10 @@ def _export_query(form_params, csv_params, populate_response=True):
) )
data = frappe._dict(data) data = frappe._dict(data)
data.filters = form_params.applied_filters
data.report_name = report_name
data.filters = form_params.filters
data.applied_filters = form_params.applied_filters
if not data.columns: if not data.columns:
frappe.respond_as_web_page( frappe.respond_as_web_page(
@ -417,28 +420,33 @@ def _export_query(form_params, csv_params, populate_response=True):
format_fields(data) format_fields(data)
xlsx_data, column_widths, header_index = build_xlsx_data( xlsx_data, column_widths, styles = build_xlsx_data(
data, data,
include_indentation=include_indentation, include_indentation=include_indentation,
include_filters=include_filters, include_filters=include_filters,
include_hidden_columns=include_hidden_columns, include_hidden_columns=include_hidden_columns,
build_styles=file_format_type == "Excel",
) )
if file_format_type == "CSV": if file_format_type == "CSV":
file_extension = "csv"
content = get_csv_bytes( content = get_csv_bytes(
[[handle_html(v) if isinstance(v, str) else v for v in r] for r in xlsx_data], [[handle_html(v) if isinstance(v, str) else v for v in r] for r in xlsx_data],
csv_params, csv_params,
) )
file_extension = "csv"
elif file_format_type == "Excel": elif file_format_type == "Excel":
file_extension = "xlsx" file_extension = "xlsx"
content = make_xlsx( content = make_xlsx(
xlsx_data, xlsx_data,
"Query Report", report_name,
column_widths=column_widths, column_widths=column_widths,
header_index=header_index, styles=styles,
has_filters=bool(include_filters),
).getvalue() ).getvalue()
else:
frappe.throw(
title=_("Unsupported file format: {0}").format(file_format_type),
msg=_("Only CSV and Excel formats are supported for export"),
)
if include_filters: if include_filters:
for value in (data.filters or {}).values(): for value in (data.filters or {}).values():
@ -491,6 +499,10 @@ def format_fields(data: frappe._dict) -> None:
row[index] = format_datetime(val) row[index] = format_datetime(val)
def format_filter_value(value):
return ", ".join([cstr(x) for x in value]) if isinstance(value, list) else cstr(value)
def build_xlsx_data( def build_xlsx_data(
data: frappe._dict, data: frappe._dict,
visible_idx: list[int] | None = None, visible_idx: list[int] | None = None,
@ -498,24 +510,29 @@ def build_xlsx_data(
include_filters: bool = False, include_filters: bool = False,
ignore_visible_idx: bool = False, ignore_visible_idx: bool = False,
include_hidden_columns: bool = False, include_hidden_columns: bool = False,
) -> tuple[list[list[Any]], list[int], int]: *,
build_styles: bool = False,
) -> tuple[list[list[Any]], list[int], dict | None]:
""" """
Build Excel data structure from report data with proper formatting. Build Excel data structure from report data with proper formatting.
Args: Args:
data: Report data containing columns, result, and filters data: Report data containing columns, result, filters, applied_filters, report_name etc.
visible_idx: Deprecated (v17). Row indices to include. visible_idx: Deprecated (v17). Row indices to include.
include_indentation: Whether to include indentation for tree-like data include_indentation: Whether to include indentation for tree-like data
include_filters: Whether to include filter rows at the top of the Excel sheet include_filters: Whether to include filter rows at the top of the Excel sheet
ignore_visible_idx: Deprecated (v17). Skips visible_idx filtering. ignore_visible_idx: Deprecated (v17). Skips visible_idx filtering.
include_hidden_columns: Whether to include columns marked as hidden include_hidden_columns: Whether to include columns marked as hidden
build_styles: Whether to build style metadata for Excel formatting
Returns: Returns:
tuple: A tuple containing: tuple: A tuple containing:
- result: List of rows for the Excel sheet - result: List of rows for the Excel sheet
- column_widths: List of column widths for the Excel sheet - column_widths: List of column widths for the Excel sheet
- header_index: Index of the header row in the result - styles: Dictionary of styles for Excel formatting (if applicable)
""" """
metadata = None
EXCEL_TYPES = ( EXCEL_TYPES = (
str, str,
bool, bool,
@ -546,41 +563,65 @@ def build_xlsx_data(
visible_idx = set(visible_idx) visible_idx = set(visible_idx)
result = [] result = []
column_data = []
column_widths = [] column_widths = []
header_index = 0
include_hidden_columns = cint(include_hidden_columns) excel_row_idx = 0
include_filters = cint(include_filters)
include_indentation = cint(include_indentation) include_indentation = cint(include_indentation)
include_hidden_columns = cint(include_hidden_columns)
has_total_row = sbool(data.get("add_total_row"))
if cint(include_filters) and data.filters: if build_styles:
metadata = XLSXMetadata(
report_name=data.report_name,
filters=data.filters,
has_total_row=has_total_row,
has_indentation=include_indentation,
)
# adding applied filter rows
if include_filters and data.applied_filters:
filter_data = [] filter_data = []
for filter_name, filter_value in data.filters.items(): for filter_name, filter_value in data.applied_filters.items():
if not filter_value: if not filter_value:
continue continue
filter_value = (
", ".join([cstr(x) for x in filter_value]) applied_filter = [cstr(filter_name), format_filter_value(filter_value)]
if isinstance(filter_value, list)
else cstr(filter_value) if build_styles:
) metadata.applied_filters_map[excel_row_idx] = applied_filter
filter_data.append([cstr(filter_name), filter_value]) excel_row_idx += 1
filter_data.append(applied_filter)
# empty row after filters
filter_data.append([]) filter_data.append([])
excel_row_idx += 1
result += filter_data result += filter_data
# header is after filters + 1 empty row # adding header row
header_index = len(result) column_idx = 0
column_data = []
for column in data.columns: for column in data.columns:
if column.get("hidden") and not include_hidden_columns: if column.get("hidden") and not include_hidden_columns:
continue continue
if build_styles:
metadata.column_map[column_idx] = column
column_idx += 1
column_data.append(_(column.get("label"))) column_data.append(_(column.get("label")))
column_width = cint(column.get("width", 0)) column_width = cint(column.get("width", 0))
# to convert into scale accepted by openpyxl # to convert into scale accepted by xlsxwriter
column_width /= 10 column_width /= 10
column_widths.append(column_width) column_widths.append(column_width)
result.append(column_data) result.append(column_data)
excel_row_idx += 1
# build table from result # build table from result
handle_indentation = include_indentation and not build_styles
for row_idx, row in enumerate(data.result): for row_idx, row in enumerate(data.result):
# NOTE: for backwards compatibility. remove in v17. # NOTE: for backwards compatibility. remove in v17.
if not (ignore_visible_idx or row_idx in visible_idx): if not (ignore_visible_idx or row_idx in visible_idx):
@ -589,6 +630,16 @@ def build_xlsx_data(
row_data = [] row_data = []
row_is_dict = isinstance(row, dict) row_is_dict = isinstance(row, dict)
indent = 0
if row_is_dict and handle_indentation:
indent = row.get("indent") or 0
if indent:
indent = cint(indent)
if build_styles:
metadata.row_map[excel_row_idx] = row
excel_row_idx += 1
for col_idx, column in enumerate(data.columns): for col_idx, column in enumerate(data.columns):
if column.get("hidden") and not include_hidden_columns: if column.get("hidden") and not include_hidden_columns:
continue continue
@ -600,14 +651,31 @@ def build_xlsx_data(
if not isinstance(cell_value, EXCEL_TYPES): if not isinstance(cell_value, EXCEL_TYPES):
cell_value = cstr(cell_value) cell_value = cstr(cell_value)
if row_is_dict and include_indentation and "indent" in row and col_idx == 0: if handle_indentation and indent and col_idx == 0:
cell_value = (" " * cint(row["indent"])) + cstr(cell_value) cell_value = (" " * indent) + cstr(cell_value)
row_data.append(cell_value) row_data.append(cell_value)
result.append(row_data) result.append(row_data)
return result, column_widths, header_index return result, column_widths, get_xlsx_styles(metadata, data.report_name) if build_styles else None
def get_xlsx_styles(metadata: XLSXMetadata, report_name: str | None = None) -> dict | None:
"""
Returns styles for XLSX export.
If report_name is provided, it tries to fetch styles defined in the report's module.
"""
styles = None
if report_name:
report = frappe.get_doc("Report", report_name)
styles = report.get_xlsx_styles_from_module(metadata)
if not styles:
styles = XLSXStyleBuilder(metadata).result
return styles
def add_total_row( def add_total_row(

View file

@ -415,13 +415,14 @@ def run_report_view_export_job(user_email, form_params, csv_params):
def _export_query(form_params, csv_params, populate_response=True): def _export_query(form_params, csv_params, populate_response=True):
from frappe.desk.utils import get_csv_bytes, provide_binary_file from frappe.desk.utils import get_csv_bytes, provide_binary_file
from frappe.utils.xlsxutils import handle_html, make_xlsx from frappe.utils.xlsxutils import get_default_xlsx_styles, handle_html, make_xlsx
doctype = form_params.pop("doctype") doctype = form_params.pop("doctype")
owner_field = f"`tab{doctype}`.`owner`"
if isinstance(form_params["fields"], list): if isinstance(form_params["fields"], list):
form_params["fields"].append("owner") form_params["fields"].append(owner_field)
elif isinstance(form_params["fields"], tuple): elif isinstance(form_params["fields"], tuple):
form_params["fields"] = form_params["fields"] + ("owner",) form_params["fields"] = form_params["fields"] + (owner_field,)
file_format_type = form_params.pop("file_format_type") file_format_type = form_params.pop("file_format_type")
title = form_params.pop("title", doctype) title = form_params.pop("title", doctype)
add_totals_row = 1 if form_params.pop("add_totals_row", None) == "1" else None add_totals_row = 1 if form_params.pop("add_totals_row", None) == "1" else None
@ -456,7 +457,8 @@ def _export_query(form_params, csv_params, populate_response=True):
fields_info = get_field_info(db_query.fields, doctype) fields_info = get_field_info(db_query.fields, doctype)
labels = [info["label"] for info in fields_info] labels = [info["label"] for info in fields_info]
data = [[_("Sr"), *labels]] sr_label = _("Sr")
data = [[sr_label, *labels]]
processed_data = [] processed_data = []
if frappe.local.lang == "en" or not translate_values: if frappe.local.lang == "en" or not translate_values:
@ -481,7 +483,21 @@ def _export_query(form_params, csv_params, populate_response=True):
) )
elif file_format_type == "Excel": elif file_format_type == "Excel":
file_extension = "xlsx" file_extension = "xlsx"
content = make_xlsx(data, doctype).getvalue()
styles = get_default_xlsx_styles(
columns=[
{
"fieldname": "sr",
"label": sr_label,
"fieldtype": "Int",
},
*fields_info,
],
data=data[1:], # exclude header row
has_total_row=bool(add_totals_row),
)
content = make_xlsx(data, doctype, styles=styles).getvalue()
if not populate_response: if not populate_response:
return title, file_extension, content return title, file_extension, content
@ -509,69 +525,91 @@ def append_totals_row(data):
return data return data
def get_field_info(fields, doctype): def get_field_info(fields, parent_doctype):
"""Get column names, labels, field types, and translatable properties based on column names.""" """
Get field's
- fieldname
- label
- fieldtype
- translatable
- options (if any)
:param fields: List of field names (can include child table fields and aggregate functions).
:param parent_doctype: The main doctype from which the report is generated.
"""
from frappe.model.meta import get_default_df
field_info = [] field_info = []
for key in fields:
for field in fields:
df = None df = None
doctype = None
try: try:
parenttype, fieldname = parse_field(key) doctype, fieldname = parse_field(field)
except ValueError: except ValueError:
# handles aggregate functions # handles aggregate functions
parenttype = doctype if isinstance(field, dict):
if isinstance(key, dict): # Eg: {"COUNT": "name", "as": "count_name"} -> "COUNT"
fieldname = next(k for k in key if k != "as") fieldname = next(f for f in field if f != "as")
else: else:
fieldname = key.split("(", 1)[0] # Eg: "count(name)" -> "count"
fieldname = field.split("(", 1)[0]
fieldname = fieldname.capitalize() fieldname = fieldname.capitalize()
parenttype = parenttype or doctype doctype = doctype or parent_doctype
options = None
if parenttype == doctype and fieldname == "name": # Special-case the primary `name` column on the parent doctype
name = fieldname if doctype == parent_doctype and fieldname == "name":
label = _("ID", context="Label of name column in report") label = _("ID", context="Label of name column in report")
fieldtype = "Data" fieldtype = "Data"
translatable = True translatable = True
else: else:
df = frappe.get_meta(parenttype).get_field(fieldname) meta = frappe.get_meta(doctype)
if df and df.fieldtype in ("Data", "Select", "Small Text", "Text"): meta_df = meta.get_field(fieldname)
name = df.name df = meta_df or get_default_df(fieldname)
label = _(df.label)
if df:
fieldname = df.fieldname
label = _(df.label or "") if meta_df else meta.get_label(fieldname)
fieldtype = df.fieldtype fieldtype = df.fieldtype
translatable = getattr(df, "translatable", False) translatable = df.translatable or False
elif df and df.fieldtype == "Link" and frappe.get_meta(df.options).translated_doctype: options = df.options
name = df.name
label = _(df.label) if df.fieldtype == "Link" and options and frappe.get_meta(options).translated_doctype:
fieldtype = df.fieldtype translatable = True
translatable = True
else: else:
name = fieldname label = _(frappe.unscrub(fieldname))
label = _(df.label) if df else _(fieldname)
fieldtype = "Data" fieldtype = "Data"
translatable = False translatable = False
if parenttype != doctype: if doctype != parent_doctype:
# If the column is from a child table, append the child doctype. # If the column is from a child table, append the child doctype.
# For example, "Item Code (Sales Invoice Item)". # For example, "Item Code (Sales Invoice Item)".
label += f" ({_(parenttype)})" label += f" ({_(doctype)})"
field_info.append( field_info.append(
{"name": name, "label": label, "fieldtype": fieldtype, "translatable": translatable} {
"fieldname": fieldname,
"label": label,
"fieldtype": fieldtype,
"translatable": translatable,
"options": options,
}
) )
return field_info return field_info
def handle_duration_fieldtype_values(doctype, data, fields): def handle_duration_fieldtype_values(parent_doctype, data, fields):
for field in fields: for field in fields:
try: try:
parenttype, fieldname = parse_field(field) doctype, fieldname = parse_field(field)
except ValueError: except ValueError:
continue continue
parenttype = parenttype or doctype doctype = doctype or parent_doctype
df = frappe.get_meta(parenttype).get_field(fieldname) df = frappe.get_meta(doctype).get_field(fieldname)
if df and df.fieldtype == "Duration": if df and df.fieldtype == "Duration":
index = fields.index(field) + 1 index = fields.index(field) + 1
@ -584,7 +622,14 @@ def handle_duration_fieldtype_values(doctype, data, fields):
def parse_field(field: str | dict) -> tuple[str | None, str]: def parse_field(field: str | dict) -> tuple[str | None, str]:
"""Parse a field into parenttype and fieldname.""" """
Parse a field into doctype and fieldname.
:param field: The field string to parse.
:returns: A tuple of (doctype, fieldname). Doctype is None if not specified.
:raises ValueError: If the field contains aggregate functions.
"""
if isinstance(field, dict): # for aggregates via qb if isinstance(field, dict): # for aggregates via qb
raise ValueError raise ValueError
@ -690,7 +735,7 @@ def get_stats(stats: str, doctype: str, filters: str | None = None):
try: try:
db_columns = frappe.db.get_table_columns(doctype) db_columns = frappe.db.get_table_columns(doctype)
except (frappe.db.InternalError, frappe.db.ProgrammingError): except frappe.db.InternalError, frappe.db.ProgrammingError:
# raised when _user_tags column is added on the fly # raised when _user_tags column is added on the fly
# raised if its a virtual doctype # raised if its a virtual doctype
db_columns = [] db_columns = []

View file

@ -159,9 +159,9 @@ class AutoEmailReport(Document):
) )
# add serial numbers # add serial numbers
columns.insert(0, frappe._dict(fieldname="idx", label="", width="30px")) columns.insert(0, frappe._dict(fieldname="sr", label=_("Sr"), fieldtype="Int", width="30px"))
for i in range(len(data)): for i in range(len(data)):
data[i]["idx"] = i + 1 data[i]["sr"] = i + 1
if len(data) == 0 and self.send_if_data: if len(data) == 0 and self.send_if_data:
return None return None
@ -172,21 +172,23 @@ class AutoEmailReport(Document):
return self.get_html_table(columns, data) return self.get_html_table(columns, data)
elif self.format in ("XLSX", "CSV"): elif self.format in ("XLSX", "CSV"):
report_data = frappe._dict() report_data = frappe._dict(
report_data["columns"] = columns {
report_data["result"] = data "report_name": self.report,
"filters": self.filters,
"columns": columns,
"result": data,
}
)
is_excel = self.format == "XLSX"
xlsx_data, column_widths, header_index = build_xlsx_data( xlsx_data, column_widths, styles = build_xlsx_data(
report_data, [], 1, ignore_visible_idx=True report_data, [], 1, ignore_visible_idx=True, build_styles=is_excel
) )
if self.format == "XLSX": if is_excel:
xlsx_file = make_xlsx( xlsx_file = make_xlsx(
xlsx_data, xlsx_data, "Auto Email Report", column_widths=column_widths, styles=styles
"Auto Email Report",
column_widths=column_widths,
header_index=header_index,
has_filters=bool(self.filters),
) )
return xlsx_file.getvalue() return xlsx_file.getvalue()

View file

@ -2,10 +2,9 @@
# License: MIT. See LICENSE # License: MIT. See LICENSE
import frappe import frappe
import frappe.utils
from frappe.desk.query_report import build_xlsx_data, export_query, run from frappe.desk.query_report import build_xlsx_data, export_query, run
from frappe.tests import IntegrationTestCase from frappe.tests import IntegrationTestCase
from frappe.utils.xlsxutils import make_xlsx from frappe.utils.xlsxutils import XLSXMetadata, XLSXStyleBuilder, make_xlsx
class TestQueryReport(IntegrationTestCase): class TestQueryReport(IntegrationTestCase):
@ -31,7 +30,7 @@ class TestQueryReport(IntegrationTestCase):
self.assertEqual(type(xlsx_data), list) self.assertEqual(type(xlsx_data), list)
self.assertEqual(len(xlsx_data), 4) # columns + data self.assertEqual(len(xlsx_data), 4) # columns + data
# column widths are divided by 10 to match the scale that is supported by openpyxl # column widths are divided by 10 to match the scale that is supported by xlsxwriter
self.assertListEqual(column_widths, [0, 10, 15]) self.assertListEqual(column_widths, [0, 10, 15])
for row in xlsx_data: for row in xlsx_data:
@ -47,19 +46,19 @@ class TestQueryReport(IntegrationTestCase):
# Create mock data # Create mock data
data = create_mock_data() data = create_mock_data()
data.filters = {"Label 1": "Filter Value", "Label 2": None, "Label 3": list(range(5))}
# Define the visible rows # Define the visible rows
visible_idx = [0, 2, 3] visible_idx = [0, 2, 3]
# Build the result # Build the result
xlsx_data, _column_widths, header_index = build_xlsx_data( xlsx_data, _column_widths, _ = build_xlsx_data(
data, visible_idx, include_indentation=False, include_filters=True data,
visible_idx,
include_indentation=False,
include_filters=True,
) )
self.assertEqual(header_index, 3) # 2 filter rows + 1 empty row # Check if unset filters are skipped | Rows -> 2 filters + 1 empty + 1 column + 3 data
# Check if unset filters are skipped | Rows - 2 filters + 1 empty + 1 column + 3 data
self.assertEqual(len(xlsx_data), 7) self.assertEqual(len(xlsx_data), 7)
# Check filter formatting # Check filter formatting
@ -69,6 +68,7 @@ class TestQueryReport(IntegrationTestCase):
"""Test excel export using rows with composite cell value""" """Test excel export using rows with composite cell value"""
data = frappe._dict() data = frappe._dict()
data.columns = [ data.columns = [
{"label": "Column A", "fieldname": "column_a", "fieldtype": "Float"}, {"label": "Column A", "fieldname": "column_a", "fieldtype": "Float"},
{"label": "Column B", "fieldname": "column_b", "width": 150, "fieldtype": "Data"}, {"label": "Column B", "fieldname": "column_b", "width": 150, "fieldtype": "Data"},
@ -82,9 +82,9 @@ class TestQueryReport(IntegrationTestCase):
visible_idx = [0, 1] visible_idx = [0, 1]
# Build the result # Build the result
xlsx_data, column_widths, header_index = build_xlsx_data(data, visible_idx, include_indentation=0) xlsx_data, column_widths, _ = build_xlsx_data(data, visible_idx, include_indentation=0)
# Export to excel # Export to excel
make_xlsx(xlsx_data, "Query Report", column_widths=column_widths, header_index=header_index) make_xlsx(xlsx_data, "Query Report", column_widths=column_widths)
for row in xlsx_data: for row in xlsx_data:
# column_b should be 'str' even with composite cell value # column_b should be 'str' even with composite cell value
@ -250,6 +250,75 @@ data = columns, result
raise e raise e
frappe.db.rollback() frappe.db.rollback()
def test_xlsx_styles_structure(self):
"""build_xlsx_data with build_styles=True returns a well-formed styles dict"""
data = create_mock_data()
data.pop("report_name") # module not needed for this test
_, _, styles = build_xlsx_data(data, build_styles=True)
self.assertIsNotNone(styles)
for key in ("styles", "column_styles", "row_styles", "cell_styles"):
self.assertIn(key, styles)
# style registry must be non-empty
self.assertGreater(len(styles["styles"]), 0)
# header row (index 0, no filters included) must have bold style
self.assertIn(0, styles["row_styles"])
# resolve the header row's style IDs and confirm bold is set
registry = styles["styles"]
header_style_ids = styles["row_styles"][0]
header_merged = {}
for sid in header_style_ids:
header_merged.update(registry[sid])
self.assertTrue(header_merged.get("bold"))
def test_xlsx_style_builder_fieldtype_column_styles(self):
"""XLSXStyleBuilder applies column styles for Float/Percent/Date but not Data"""
column_map = {
0: {"fieldname": "name", "fieldtype": "Data", "label": "Name"},
1: {"fieldname": "score", "fieldtype": "Float", "label": "Score"},
2: {"fieldname": "pct", "fieldtype": "Percent", "label": "Pct"},
3: {"fieldname": "dt", "fieldtype": "Date", "label": "Date"},
}
row_map = {1: {"name": "A", "score": 1.0, "pct": 10.0, "dt": "2025-01-01"}}
metadata = XLSXMetadata(column_map=column_map, row_map=row_map)
builder = XLSXStyleBuilder(metadata, default_styling=False)
builder.apply_default_fieldtype_formats(currency_formatting=False)
def resolve(col_idx):
"""Merge all style dicts registered for a column into one dict."""
merged = {}
for sid in builder.column_styles[col_idx]:
merged.update(builder.styles[sid])
return merged
# Float, Percent, Date → column-level styles
self.assertIn(1, builder.column_styles)
self.assertIn(2, builder.column_styles)
self.assertIn(3, builder.column_styles)
# Data column → no column style
self.assertNotIn(0, builder.column_styles)
# Float → has num_format, no alignment override
float_style = resolve(1)
self.assertIn("num_format", float_style)
self.assertNotIn("align", float_style)
# Percent → num_format contains "%"
percent_style = resolve(2)
self.assertIn("num_format", percent_style)
self.assertIn("%", percent_style["num_format"])
# Date → has num_format and explicitly right-aligned
date_style = resolve(3)
self.assertIn("num_format", date_style)
self.assertEqual(date_style.get("align"), "right")
def test_export_report_via_email(self): def test_export_report_via_email(self):
REPORT_NAME = "Test CSV Report" REPORT_NAME = "Test CSV Report"
REF_DOCTYPE = "DocType" REF_DOCTYPE = "DocType"
@ -287,15 +356,22 @@ data = columns, result
def create_mock_data(): def create_mock_data():
data = frappe._dict() data = frappe._dict()
data.report_name = "Mock Report"
data.columns = [ data.columns = [
{"label": "Column A", "fieldname": "column_a", "fieldtype": "Float"}, {"label": "Column A", "fieldname": "column_a", "fieldtype": "Float"},
{"label": "Column B", "fieldname": "column_b", "width": 100, "fieldtype": "Float"}, {"label": "Column B", "fieldname": "column_b", "width": 100, "fieldtype": "Float"},
{"label": "Column C", "fieldname": "column_c", "width": 150, "fieldtype": "Duration"}, {"label": "Column C", "fieldname": "column_c", "width": 150, "fieldtype": "Duration"},
] ]
data.result = [ data.result = [
[1.0, 3.0, 600], [1.0, 3.0, 600],
{"column_a": 22.1, "column_b": 21.8, "column_c": 86412}, {"column_a": 22.1, "column_b": 21.8, "column_c": 86412},
{"column_b": 5.1, "column_c": 53234, "column_a": 11.1}, {"column_b": 5.1, "column_c": 53234, "column_a": 11.1},
[3.0, 1.5, 333], [3.0, 1.5, 333],
] ]
data.applied_filters = {"Label 1": "Filter Value", "Label 2": None, "Label 3": list(range(5))}
data.filters = {"label_1": "Filter Value", "label_2": None, "label_3": list(range(5))}
return data return data

View file

@ -1,46 +1,488 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE # License: MIT. See LICENSE
import datetime import datetime
import functools
import re import re
from dataclasses import dataclass
from dataclasses import field as dataclass_field
from io import BytesIO from io import BytesIO
from typing import Any from typing import Any, ClassVar, Literal
import openpyxl
import xlrd import xlrd
import xlsxwriter
from openpyxl import load_workbook from openpyxl import load_workbook
from openpyxl.cell import WriteOnlyCell from xlsxwriter.format import Format
from openpyxl.styles import Font
from openpyxl.utils import get_column_letter
from openpyxl.workbook.child import INVALID_TITLE_REGEX
import frappe import frappe
from frappe import _
from frappe.core.utils import html2text from frappe.core.utils import html2text
from frappe.utils import cint
from frappe.utils.html_utils import unescape_html from frappe.utils.html_utils import unescape_html
ILLEGAL_CHARACTERS_RE = re.compile( ILLEGAL_CHARACTERS_RE = re.compile(
r"[\000-\010]|[\013-\014]|[\016-\037]|\uFEFF|\uFFFE|\uFFFF|[\uD800-\uDFFF]" r"[\000-\010]|[\013-\014]|[\016-\037]|\uFEFF|\uFFFE|\uFFFF|[\uD800-\uDFFF]"
) )
# as required by XLSXWriter
def get_excel_date_format(): INVALID_SHEET_NAME_RE = re.compile(r"[\[\]:*?/\\]")
date_format = frappe.get_system_settings("date_format") MAX_SHEET_NAME_LENGTH = 31
time_format = frappe.get_system_settings("time_format")
# Excel-compatible format
date_format = date_format.replace("mm", "MM")
return date_format, time_format
# return xlsx file object ### XLSX Formatter ###
@dataclass(slots=True)
class XLSXMetadata:
"""
Metadata container for XLSX report styling.
- All indexes must be 0-based respecting xlsxwriter's indexing.
Attributes:
column_map: Maps column index to column dict (fieldname, fieldtype, etc.).
row_map: Maps row index to row data (dict or list).
applied_filters_map: Maps row index to list of applied filter label-value pairs.
has_total_row: Whether the last row is a total row.
has_indentation: Whether indentation styling should be applied.
# optional metadata for custom style builders
report_name: Name of the report.
filters: Raw filter values.
"""
column_map: dict[int, dict] = dataclass_field(default_factory=dict)
row_map: dict[int, dict | list] = dataclass_field(default_factory=dict)
applied_filters_map: dict[int, list] = dataclass_field(default_factory=dict)
has_total_row: bool = False
has_indentation: bool = False
# optional
report_name: str = ""
filters: dict = dataclass_field(default_factory=dict)
def get_column(self, fieldname: str) -> dict | None:
"""
Get column dict by fieldname, or None if not found.
"""
return next((col for col in self.column_map.values() if col.get("fieldname") == fieldname), None)
def get_header_index(self) -> int:
"""
Get header row index based on applied filters.
Assumes header is always 1 row after the last filter row.
"""
count = len(self.applied_filters_map)
return count + 1 if count else 0
def get_first_row_index(self) -> int:
return min(self.row_map.keys()) if self.row_map else 0
def get_last_row_index(self) -> int:
return max(self.row_map.keys()) if self.row_map else 0
class XLSXStyleBuilder:
"""
Builder for XLSX cell styles based on report metadata.
Builds a style dictionary with:
- styles: List of style definitions (xlsxwriter format properties). List index is the style ID.
- column_styles: Maps column index to list of style IDs.
- row_styles: Maps row index to list of style IDs.
- cell_styles: Maps (row, col) tuple to list of style IDs.
**Usage:**
```
builder = XLSXStyleBuilder(metadata)
builder.style_column(0, builder.register_style({"bold": True}))
styles = builder.result
```
"""
RIGHT_ALIGN_FIELDTYPES: ClassVar[set[str]] = {
*frappe.model.numeric_fieldtypes,
*frappe.model.datetime_fields,
"Rating",
}
def __init__(self, metadata: XLSXMetadata, default_styling: bool = True):
self.metadata = metadata
# column fieldname -> index mapping
self.field_index_map = {
col["fieldname"]: idx for idx, col in self.metadata.column_map.items() if col.get("fieldname")
}
self.styles: list[dict] = []
self.column_styles: dict[int, list[int]] = {}
self.row_styles: dict[int, list[int]] = {}
self.cell_styles: dict[tuple[int, int], list[int]] = {}
self.result = {
"styles": self.styles,
"column_styles": self.column_styles,
"row_styles": self.row_styles,
"cell_styles": self.cell_styles,
}
# metadata indexes for quick access
self.header_index = self.metadata.get_header_index()
self.first_row_index = self.metadata.get_first_row_index()
self.last_row_index = self.metadata.get_last_row_index()
self.row_is_dict = isinstance(self.metadata.row_map.get(self.first_row_index), dict)
self._register_common_styles()
if default_styling:
self.apply_default_styles()
### STYLE REGISTRATION ###
def _register_common_styles(self):
self.bold_style_id = self.register_style({"bold": True})
def register_style(self, style: dict) -> int:
"""
Register a style and return its ID.
Style dict uses xlsxwriter format properties.
"""
if not style:
frappe.throw(_("Cannot register an empty XLSX style"))
style_id = len(self.styles)
self.styles.append(style)
return style_id
### STYLE APPLICATION ###
def style_column(self, col_idx: int, style_id: int):
"""
Apply a style to all cells in a column.
Args:
col_idx: 0-based column index
style_id: ID of the style to apply (from register_style)
"""
if col_idx not in self.column_styles:
self.column_styles[col_idx] = []
self.column_styles[col_idx].append(style_id)
return self
def style_row(self, row_idx: int, style_id: int):
"""
Apply a style to all cells in a row.
Args:
row_idx: 0-based row index
style_id: ID of the style to apply (from register_style)
"""
if row_idx not in self.row_styles:
self.row_styles[row_idx] = []
self.row_styles[row_idx].append(style_id)
return self
def style_cell(self, row_idx: int, col_idx: int, style_id: int):
"""
Apply a style to a specific cell.
Args:
row_idx: 0-based row index
col_idx: 0-based column index
style_id: ID of the style to apply (from register_style)
"""
key = (row_idx, col_idx)
cell_styles = self.cell_styles
if key not in cell_styles:
cell_styles[key] = []
cell_styles[key].append(style_id)
return self
### UTILITY METHODS FOR STYLING ###
def apply_default_styles(self, currency_formatting: bool = True):
"""
Apply all default styles:
- Header row styling
- Filter rows styling
- Total row styling (if has_total_row)
- Indentation styling (if has_indentation)
- Default fieldtype formatting (numbers, dates, etc.)
- Currency formatting can be toggled with currency_formatting flag
"""
self.style_header()
if self.metadata.applied_filters_map:
self.style_filters()
if self.metadata.has_total_row:
self.style_total_row()
if self.metadata.has_indentation:
self.apply_indentations()
self.apply_default_fieldtype_formats(currency_formatting)
return self
def style_header(self):
header_index = self.header_index
self.style_row(header_index, self.bold_style_id)
right_align = self.register_style({"align": "right"})
left_align = self.register_style({"align": "left"})
for col_idx, col in self.metadata.column_map.items():
self.style_cell(
header_index,
col_idx,
right_align if col.get("fieldtype") in self.RIGHT_ALIGN_FIELDTYPES else left_align,
)
return self
def style_filters(self):
for row_idx in self.metadata.applied_filters_map.keys():
# style only the label column (0th index)
self.style_cell(row_idx, 0, self.bold_style_id)
return self
def apply_indentations(self, col_idx: int = 0, field: str = "indent", pt: int = 2):
if not self.row_is_dict:
return self
@functools.cache
def register_indent_style(indent: int) -> int:
return self.register_style({"align": "left", "indent": indent * pt})
# quick access for hot loop
last_row_index = self.last_row_index
skip_last_row = self.metadata.has_total_row
style_cell = self.style_cell
for row_idx, row in self.metadata.row_map.items():
if skip_last_row and row_idx == last_row_index:
continue
if indent := row.get(field):
style_cell(row_idx, col_idx, register_indent_style(indent))
return self
def style_total_row(self):
return self.style_row(self.last_row_index, self.bold_style_id)
def apply_default_fieldtype_formats(self, currency_formatting: bool = True):
formats: dict[str, int] = {
"Float": self.register_style({"num_format": self.get_number_format("Float")}),
"Percent": self.register_style({"num_format": self.get_number_format("Percent")}),
"Date": self.register_style({"num_format": self.get_date_format(), "align": "right"}),
"Time": self.register_style({"num_format": self.get_time_format(), "align": "right"}),
"Datetime": self.register_style({"num_format": self.get_datetime_format(), "align": "right"}),
}
for idx, col in self.metadata.column_map.items():
style_id = formats.get(col.get("fieldtype"))
if style_id is not None:
self.style_column(idx, style_id)
if currency_formatting:
self.apply_currency_fieldtype_formats()
return self
def apply_currency_fieldtype_formats(self):
currency_options = {
col_idx: col.get("options")
for col_idx, col in self.metadata.column_map.items()
if col.get("fieldtype") == "Currency"
}
if not currency_options:
return self
default_currency = frappe.db.get_default("currency")
# quick access for hot loop
last_row_index = self.last_row_index
skip_last_row = self.metadata.has_total_row
currency_options_items = currency_options.items()
style_cell = self.style_cell
# helpers
@functools.cache
def _get_value(doctype: str, docname: str, fieldname: str) -> str | None:
return frappe.db.get_value(doctype, docname, fieldname)
@functools.cache
def parse_options(options: str) -> tuple:
parts = options.split(":")
return parts if len(parts) == 3 else (None, None, None)
@functools.cache
def register_currency_style(currency: str) -> int:
return self.register_style({"num_format": self.get_number_format("Currency", currency)})
# dispatch dict/list row access once, not per cell
if self.row_is_dict:
def get_row_value(row, field):
return row.get(field)
else:
_field_index_get = self.field_index_map.get
def get_row_value(row, field):
idx = _field_index_get(field)
return row[idx] if idx is not None else None
# currency formatting
for row_idx, row in self.metadata.row_map.items():
if skip_last_row and row_idx == last_row_index:
continue
for col_idx, options in currency_options_items:
currency = None
if options:
if ":" not in options:
currency = get_row_value(row, options)
else:
doctype, link_field, currency_field = parse_options(options)
if doctype is not None and (link_value := get_row_value(row, link_field)):
currency = _get_value(doctype, link_value, currency_field)
style_cell(row_idx, col_idx, register_currency_style(currency or default_currency))
return self
@staticmethod
def _get_currency_symbol_info(currency: str | None) -> tuple[str, bool]:
if not currency or frappe.db.get_default("hide_currency_symbol") == "Yes":
return "", False
symbol, on_right = frappe.db.get_value("Currency", currency, ["symbol", "symbol_on_right"])
return (symbol or currency), bool(on_right)
@staticmethod
def _build_currency_format(
format_string: str,
currency_symbol: str | None = None,
symbol_on_right: bool = False,
) -> str:
if not currency_symbol:
return format_string
if symbol_on_right:
return f'{format_string}" {currency_symbol}";-{format_string}" {currency_symbol}"'
return f'"{currency_symbol} "{format_string};"{currency_symbol} "-{format_string}'
### FORMAT GETTERS ###
@staticmethod
def get_number_format(
fieldtype: Literal["Currency", "Float", "Percent"],
currency: str | None = None,
) -> str:
"""
Get Excel number format string for the given fieldtype.
"""
from frappe.locale import get_number_format as _get_format
number_format = _get_format()
thousands_sep = number_format.thousands_separator
precision = number_format.precision
if fieldtype == "Currency":
precision = cint(frappe.db.get_default("currency_precision")) or precision
format_str = XLSXStyleBuilder._build_number_format(thousands_sep, precision)
currency_symbol, symbol_on_right = XLSXStyleBuilder._get_currency_symbol_info(currency)
return XLSXStyleBuilder._build_currency_format(format_str, currency_symbol, symbol_on_right)
elif fieldtype in ("Float", "Percent"):
precision = cint(frappe.db.get_default("float_precision")) or precision
format_str = XLSXStyleBuilder._build_number_format(thousands_sep, precision)
return f'{format_str}"%" ' if fieldtype == "Percent" else format_str
return "General"
@staticmethod
def _build_number_format(thousands_sep: str, precision: int = 0) -> str:
# Decimal separator is hardcoded to '.' because Excel only understands '.' in format strings.
# The system decimal separator is intentionally ignored here.
# TODO: can be improved by passing a language/locale to xlsxwriter's Workbook for locale-aware formatting.
integer_part = "#,##0" if thousands_sep else "#0"
decimal_part = ("." + "0" * precision) if precision > 0 else ""
return f"{integer_part}{decimal_part}"
@staticmethod
def get_date_format() -> str:
return frappe.get_system_settings("date_format")
@staticmethod
def get_time_format() -> str:
return frappe.get_system_settings("time_format")
@staticmethod
def get_datetime_format() -> str:
return f"{XLSXStyleBuilder.get_date_format()} {XLSXStyleBuilder.get_time_format()}"
def get_default_xlsx_styles(
columns: list[dict],
data: list[list | dict],
applied_filters: list[list] | None = None,
*,
has_total_row: bool = False,
has_indentation: bool = False,
currency_formatting: bool = True,
) -> dict:
"""
Generate default XLSX styles for xlsx exports.
Args:
columns: Column definitions with keys: fieldname, fieldtype, label, options.
data: Row data as list of dicts or lists (excluding header and filter rows).
applied_filters: Filter rows to display at top of sheet. Each item is [label, value].
has_total_row: If True, applies bold styling to the last row.
has_indentation: If True, applies indent styles based on row's 'indent' key.
currency_formatting: If True, applies currency number formats to Currency fields.
"""
applied_filters = applied_filters or []
header_index = len(applied_filters) + 1 if applied_filters else 0
applied_filters_map = dict(enumerate(applied_filters))
column_map = dict(enumerate(columns))
row_map = dict(enumerate(data, start=header_index + 1)) # +1 for header row
metadata = XLSXMetadata(
column_map=column_map,
row_map=row_map,
applied_filters_map=applied_filters_map,
has_total_row=has_total_row,
has_indentation=has_indentation,
)
return XLSXStyleBuilder(metadata, default_styling=False).apply_default_styles(currency_formatting).result
### Excel Creation ###
def make_xlsx( def make_xlsx(
data: list[list[Any]], data: list[list[Any]],
sheet_name: str, sheet_name: str,
wb: openpyxl.Workbook | None = None, wb: xlsxwriter.Workbook | None = None,
column_widths: list[int] | None = None, column_widths: list[int] | None = None,
header_index: int = 0, styles: dict | None = None,
has_filters: bool = False, ) -> BytesIO | None:
) -> BytesIO:
""" """
Create an Excel file with the given data and formatting options. Create an Excel file with the given data and formatting options.
@ -48,64 +490,127 @@ def make_xlsx(
data: List of rows, where each row is a list of cell values data: List of rows, where each row is a list of cell values
sheet_name: Name of the Excel sheet sheet_name: Name of the Excel sheet
wb: Existing workbook to add sheet to. If None, creates new workbook wb: Existing workbook to add sheet to. If None, creates new workbook
- Workbook must be closed by caller if provided
- Should be created with constant_memory=True for large datasets
column_widths: List of column widths in Excel units. If None, auto-sized column_widths: List of column widths in Excel units. If None, auto-sized
header_index: Row index (0-based) that should be formatted as header making it bold styles: Dictionary defining styles for cells, rows, and columns
has_filters: If True, applies bold formatting to the first column of filter rows - as returned by XLSXStyleBuilder.result
Returns: Returns:
BytesIO: object containing the Excel file data BytesIO | None: BytesIO object containing the Excel file data if a new workbook was created, otherwise None
""" """
column_widths = column_widths or [] column_widths = column_widths or []
styles = styles or {}
# creating workbook
xlsx_file = None
created_wb = False # to know to close it later
if wb is None: if wb is None:
wb = openpyxl.Workbook(write_only=True) xlsx_file = BytesIO()
options = {"constant_memory": True}
sheet_name_sanitized = INVALID_TITLE_REGEX.sub(" ", sheet_name) if not styles:
ws = wb.create_sheet(sheet_name_sanitized, 0) options["default_date_format"] = XLSXStyleBuilder.get_datetime_format()
wb = xlsxwriter.Workbook(xlsx_file, options)
created_wb = True
ws = wb.add_worksheet(get_sanitized_sheet_name(sheet_name))
# extract style components
def _extract_ids(key: str) -> dict:
return {k: tuple(v) for k, v in (styles.get(key) or {}).items() if v}
style_registry: list[dict] = styles.get("styles") or []
col_style_ids: dict[int, tuple[int, ...]] = _extract_ids("column_styles")
row_style_ids: dict[int, tuple[int, ...]] = _extract_ids("row_styles")
cell_style_ids: dict[tuple[int, int], tuple[int, ...]] = _extract_ids("cell_styles")
styling_enabled = bool(col_style_ids or row_style_ids or cell_style_ids)
if not styling_enabled:
ws.set_row(0, cell_format=wb.add_format({"bold": True}))
def resolve_style_ids(style_ids: tuple[int, ...]) -> dict:
if len(style_ids) == 1:
return style_registry[style_ids[0]]
result = {}
for sid in style_ids:
result.update(style_registry[sid])
return result
@functools.cache
def get_format(style_ids: tuple[int, ...]) -> Format:
return wb.add_format(resolve_style_ids(style_ids))
# set column widths
for i, column_width in enumerate(column_widths): for i, column_width in enumerate(column_widths):
if column_width: if column_width:
ws.column_dimensions[get_column_letter(i + 1)].width = column_width ws.set_column(i, i, column_width)
date_format, time_format = get_excel_date_format() # column level styles
bold_font = Font(name="Calibri", bold=True) for col_idx, style_ids in col_style_ids.items():
ws.set_column(col_idx, col_idx, cell_format=get_format(style_ids))
# row level styles (sorted because constant_memory mode requires writing rows in order)
for row_idx, style_ids in sorted(row_style_ids.items()):
ws.set_row(row_idx, cell_format=get_format(style_ids))
# priority: column < row < cell (later in tuple = higher priority)
cell_formats: dict[tuple[int, int], Format] = {}
# process explicit cell styles
for pos, cell_ids in cell_style_ids.items():
row_idx, col_idx = pos
col_ids = col_style_ids.get(col_idx, ())
row_ids = row_style_ids.get(row_idx, ())
cell_formats[pos] = get_format(col_ids + row_ids + cell_ids)
# process row x column intersections (no explicit cell style)
for row_idx, row_ids in row_style_ids.items():
for col_idx, col_ids in col_style_ids.items():
pos = (row_idx, col_idx)
if pos not in cell_formats:
cell_formats[pos] = get_format(col_ids + row_ids)
# quick access for hot loop
handle_html_content = sheet_name not in {"Data Import Template", "Data Export"}
illegal_chars_search = ILLEGAL_CHARACTERS_RE.search
illegal_chars_sub = ILLEGAL_CHARACTERS_RE.sub
write = ws.write
has_cell_formats = bool(cell_formats)
get_cell_format = cell_formats.get
for row_idx, row in enumerate(data): for row_idx, row in enumerate(data):
clean_row = [] for col_idx, value in enumerate(row):
is_header_row = row_idx == header_index if isinstance(value, str):
is_filter_row = has_filters and row_idx < header_index if handle_html_content:
value = handle_html(value)
for col_idx, item in enumerate(row): if illegal_chars_search(value):
if isinstance(item, str) and (sheet_name not in ["Data Import Template", "Data Export"]): value = illegal_chars_sub("", value)
value = handle_html(item)
else:
value = item
if isinstance(item, str) and next(ILLEGAL_CHARACTERS_RE.finditer(value), None): cell_format = get_cell_format((row_idx, col_idx)) if has_cell_formats else None
# Remove illegal characters from the string write(row_idx, col_idx, value, cell_format)
value = ILLEGAL_CHARACTERS_RE.sub("", value)
cell = WriteOnlyCell(ws, value=value) if not created_wb:
return
if isinstance(value, datetime.date | datetime.datetime): wb.close()
number_format = date_format xlsx_file.seek(0)
if isinstance(value, datetime.datetime):
number_format = f"{date_format} {time_format}"
cell.number_format = number_format
# Apply bold font for header row or first column of filter rows
if is_header_row or (is_filter_row and col_idx == 0):
cell.font = bold_font
clean_row.append(cell)
ws.append(clean_row)
xlsx_file = BytesIO()
wb.save(xlsx_file)
return xlsx_file return xlsx_file
### Utilities ### ### Utilities ###
def get_sanitized_sheet_name(name: str) -> str:
return INVALID_SHEET_NAME_RE.sub(" ", name)[:MAX_SHEET_NAME_LENGTH]
def handle_html(data: str) -> str: def handle_html(data: str) -> str:
# return if no html tags found # return if no html tags found
if "<" not in data or ">" not in data: if "<" not in data or ">" not in data:
@ -148,7 +653,7 @@ def read_xls_file_from_attached_file(content):
return [sheet.row_values(i) for i in range(sheet.nrows)] return [sheet.row_values(i) for i in range(sheet.nrows)]
def build_xlsx_response(data, filename): def build_xlsx_response(data, filename, styles: dict | None = None):
from frappe.desk.utils import provide_binary_file from frappe.desk.utils import provide_binary_file
provide_binary_file(filename, "xlsx", make_xlsx(data, filename).getvalue()) provide_binary_file(filename, "xlsx", make_xlsx(data, filename, styles=styles).getvalue())

View file

@ -48,6 +48,7 @@ dependencies = [
"num2words~=0.5.14", "num2words~=0.5.14",
"oauthlib~=3.3.1", "oauthlib~=3.3.1",
"openpyxl~=3.1.5", "openpyxl~=3.1.5",
"xlsxwriter~=3.2.9",
"orjson~=3.11.5", "orjson~=3.11.5",
"passlib~=1.7.4", "passlib~=1.7.4",
"pdfkit~=1.0.0", "pdfkit~=1.0.0",