This is an automated email from the ASF dual-hosted git repository.

mridulpathak pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git


The following commit(s) were added to refs/heads/trunk by this push:
     new bd4bff1b87 Implemented: real POI-based .xlsx export via a new 
screenxlsx view-handler type (OFBIZ-13567)
bd4bff1b87 is described below

commit bd4bff1b8749151b473b361d0814cfaca610b99b
Author: Mridul Pathak <[email protected]>
AuthorDate: Tue Sep 8 16:55:10 2026 +0530

    Implemented: real POI-based .xlsx export via a new screenxlsx view-handler 
type (OFBIZ-13567)
    
    Adds a new screenxlsx view-handler type that renders screens as genuine 
binary .xlsx workbooks through Apache POI, additive alongside the existing 
screenxls/screencsv types.
    
    - ScreenXlsxViewHandler drives the existing, unmodified 
ScreenRenderer/FormRenderer widget-tree walk through two new renderer 
implementations
    - XlsxFormRenderer writes typed POI cells for form/grid data: NUMERIC for 
currency/accounting-number fields, real Excel dates for date/date-time fields, 
STRING otherwise, always falling back to STRING on any unparseable value
    - XlsxScreenStringRenderer is a no-op renderer that lets the screen tree 
walk complete, since only form/grid content is in scope
    - New poi-ooxml Gradle dependency supplies the XSSF/SXSSF classes core POI 
lacks
    - New WebtoolsLayoutDemoXlsx demo view-map proves the mechanism end-to-end 
alongside its existing screenxls counterpart, producing a real, correctly-typed 
multi-sheet workbook
    - New CommonViewAsXlsx UI label for the matching entry points added on the 
plugins/example side
    - Includes unit test coverage for the renderer's row/column/cell-typing 
logic
---
 dependencies.gradle                                |   1 +
 framework/common/config/CommonUiLabels.xml         |   3 +
 .../webcommon/WEB-INF/handlers-controller.xml      |   1 +
 .../webapp/webtools/WEB-INF/controller.xml         |   2 +
 .../renderer/xlsx/ScreenXlsxViewHandler.java       | 128 ++++++
 .../widget/renderer/xlsx/XlsxFormRenderer.java     | 433 +++++++++++++++++++++
 .../renderer/xlsx/XlsxScreenStringRenderer.java    | 173 ++++++++
 .../widget/renderer/xlsx/XlsxFormRendererTest.java | 232 +++++++++++
 gradle/libs.versions.toml                          |   1 +
 9 files changed, 974 insertions(+)

diff --git a/dependencies.gradle b/dependencies.gradle
index 20a7f9f510..8ae7ab70b0 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -53,6 +53,7 @@ dependencies {
     implementation libs.log4j.core // Somehow needed by Buildbot to compile 
OFBizDynamicThresholdFilter.java
     implementation libs.log4j.layout.template.json // JSON/ECS structured 
logging profile (log4j2-json.xml)
     implementation libs.poi
+    implementation libs.poi.ooxml
     implementation libs.bundles.pdfbox
     implementation libs.shiro.core
     implementation libs.shiro.crypto.cipher
diff --git a/framework/common/config/CommonUiLabels.xml 
b/framework/common/config/CommonUiLabels.xml
index da56b33e87..f0847bdae7 100644
--- a/framework/common/config/CommonUiLabels.xml
+++ b/framework/common/config/CommonUiLabels.xml
@@ -13150,6 +13150,9 @@
         <value xml:lang="es-MX">Ver como hoja de cálculo</value>
         <value xml:lang="fr">Voir en tableur</value>
     </property>
+    <property key="CommonViewAsXlsx">
+        <value xml:lang="en">View as spreadsheet (.xlsx)</value>
+    </property>
     <property key="CommonViewAsXml">
         <value xml:lang="en">View as xml</value>
         <value xml:lang="es">Ver como XML</value>
diff --git a/framework/common/webcommon/WEB-INF/handlers-controller.xml 
b/framework/common/webcommon/WEB-INF/handlers-controller.xml
index b1de710c83..f0080c20f6 100644
--- a/framework/common/webcommon/WEB-INF/handlers-controller.xml
+++ b/framework/common/webcommon/WEB-INF/handlers-controller.xml
@@ -41,6 +41,7 @@ under the License.
     <handler name="screentext" type="view" 
class="org.apache.ofbiz.widget.renderer.macro.MacroScreenViewHandler"/>
     <handler name="screencsv" type="view" 
class="org.apache.ofbiz.widget.renderer.macro.MacroScreenViewHandler"/>
     <handler name="screenxls" type="view" 
class="org.apache.ofbiz.widget.renderer.macro.MacroScreenViewHandler"/>
+    <handler name="screenxlsx" type="view" 
class="org.apache.ofbiz.widget.renderer.xlsx.ScreenXlsxViewHandler"/>
     <handler name="screenfop" type="view" 
class="org.apache.ofbiz.widget.renderer.fo.ScreenFopViewHandler"/>
     <handler name="jsp" type="view" 
class="org.apache.ofbiz.webapp.view.JspViewHandler"/>
     <handler name="http" type="view" 
class="org.apache.ofbiz.webapp.view.HttpViewHandler"/>
diff --git a/framework/webtools/webapp/webtools/WEB-INF/controller.xml 
b/framework/webtools/webapp/webtools/WEB-INF/controller.xml
index c579a9206a..8f2990fbd2 100644
--- a/framework/webtools/webapp/webtools/WEB-INF/controller.xml
+++ b/framework/webtools/webapp/webtools/WEB-INF/controller.xml
@@ -741,6 +741,7 @@ under the License.
     <request-map uri="WebtoolsLayoutDemoXml"><security https="true" 
auth="true"/><response name="success" type="view" 
value="WebtoolsLayoutDemoXml"/></request-map>
     <request-map uri="WebtoolsLayoutDemoCsv"><security https="true" 
auth="true"/><response name="success" type="view" 
value="WebtoolsLayoutDemoCsv"/></request-map>
     <request-map uri="WebtoolsLayoutDemoXls"><security https="true" 
auth="true"/><response name="success" type="view" 
value="WebtoolsLayoutDemoXls"/></request-map>
+    <request-map uri="WebtoolsLayoutDemoXlsx"><security https="true" 
auth="true"/><response name="success" type="view" 
value="WebtoolsLayoutDemoXlsx"/></request-map>
 
     <request-map uri="AvailableUelList"><security https="true" 
auth="true"/><response name="success" type="view" 
value="AvailableUelList"/></request-map>
     <!-- end of request mappings -->
@@ -857,6 +858,7 @@ under the License.
     <view-map name="WebtoolsLayoutDemoXml" type="screenxml" 
page="component://webtools/widget/MiscScreens.xml#WebtoolsLayoutDemoText" 
content-type="text/xml"/>
     <view-map name="WebtoolsLayoutDemoCsv" type="screencsv" 
page="component://webtools/widget/MiscScreens.xml#WebtoolsLayoutDemoText" 
content-type="text/csv"/>
     <view-map name="WebtoolsLayoutDemoXls" type="screenxls" 
page="component://webtools/widget/MiscScreens.xml#WebtoolsLayoutDemoText" 
content-type="application/vnd.ms-excel"/>
+    <view-map name="WebtoolsLayoutDemoXlsx" type="screenxlsx" 
page="component://webtools/widget/MiscScreens.xml#WebtoolsLayoutDemoText" 
content-type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"/>
 
     <view-map name="AvailableUelList" type="screen" 
page="component://webtools/widget/MiscScreens.xml#AvailableUelList"/>
     <!-- end of view mappings -->
diff --git 
a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/xlsx/ScreenXlsxViewHandler.java
 
b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/xlsx/ScreenXlsxViewHandler.java
new file mode 100644
index 0000000000..a0f6936bbf
--- /dev/null
+++ 
b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/xlsx/ScreenXlsxViewHandler.java
@@ -0,0 +1,128 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ 
*******************************************************************************/
+package org.apache.ofbiz.widget.renderer.xlsx;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.util.Map;
+
+import javax.xml.parsers.ParserConfigurationException;
+
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.ofbiz.base.util.UtilGenerics;
+import org.apache.ofbiz.base.util.UtilHttp;
+import org.apache.ofbiz.base.util.UtilValidate;
+import org.apache.ofbiz.base.util.collections.MapStack;
+import org.apache.ofbiz.webapp.control.ConfigXMLReader;
+import org.apache.ofbiz.webapp.view.AbstractViewHandler;
+import org.apache.ofbiz.webapp.view.ViewHandlerException;
+import org.apache.ofbiz.widget.model.ModelTheme;
+import org.apache.ofbiz.widget.renderer.ScreenRenderer;
+import org.apache.ofbiz.widget.renderer.ScreenStringRenderer;
+import org.apache.ofbiz.widget.renderer.VisualTheme;
+import org.apache.ofbiz.widget.renderer.macro.MacroScreenRenderer;
+import org.apache.poi.xssf.streaming.SXSSFWorkbook;
+import org.xml.sax.SAXException;
+
+import freemarker.template.TemplateException;
+
+/**
+ * Renders a screen's form/grid content as a genuine binary .xlsx workbook via 
Apache POI,
+ * bypassing the FreeMarker macro pipeline entirely (unlike {@code 
ScreenFopViewHandler},
+ * which still renders XSL-FO text via macros and only converts to binary as a 
final step).
+ */
+public class ScreenXlsxViewHandler extends AbstractViewHandler {
+    private static final String MODULE = ScreenXlsxViewHandler.class.getName();
+    private static final String DEFAULT_ERROR_TEMPLATE = 
"component://common/widget/CommonScreens.xml#FoError";
+    public static final String CONTENT_TYPE = 
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+
+    private ServletContext servletContext;
+
+    @Override
+    public void init(ServletContext context) throws ViewHandlerException {
+        this.servletContext = context;
+    }
+
+    @Override
+    public Map<String, Object> prepareViewContext(HttpServletRequest request, 
HttpServletResponse response,
+            ConfigXMLReader.ViewMap viewMap) {
+        MapStack<String> context = MapStack.create();
+        ScreenRenderer.populateContextForRequest(context, null, request, 
response, servletContext, viewMap.isSecureContext());
+        return context;
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public void render(String name, String page, String info, String 
contentType, String encoding, HttpServletRequest request,
+            HttpServletResponse response, Map<String, Object> context) throws 
ViewHandlerException {
+        try (SXSSFWorkbook workbook = new SXSSFWorkbook()) {
+            Writer writer = new StringWriter();
+            XlsxFormRenderer xlsxFormRenderer = new XlsxFormRenderer(workbook);
+            ScreenStringRenderer screenStringRenderer = new 
XlsxScreenStringRenderer();
+            ScreenRenderer screens = new ScreenRenderer(writer, 
UtilGenerics.cast(context), screenStringRenderer);
+            screens.getContext().put("formStringRenderer", xlsxFormRenderer);
+            screens.getContext().put("screens", screens);
+            screens.render(page);
+
+            String responseContentType = (UtilValidate.isEmpty(contentType) || 
"text/html".equals(contentType)) ? CONTENT_TYPE : contentType;
+            response.setContentType(responseContentType);
+            response.setHeader("Content-Disposition", "attachment; 
filename=\"" + name + ".xlsx\"");
+            workbook.write(response.getOutputStream());
+            response.getOutputStream().flush();
+        } catch (IOException | GeneralException | SAXException | 
ParserConfigurationException e) {
+            renderError("Problems rendering the xlsx export", e, request, 
response, context);
+        }
+    }
+
+    /**
+     * Render error.
+     * @param msg      the msg
+     * @param e        the e
+     * @param request  the request
+     * @param response the response
+     * @param context  the context
+     * @throws ViewHandlerException the view handler exception
+     */
+    protected void renderError(String msg, Exception e, HttpServletRequest 
request, HttpServletResponse response,
+            Map<String, Object> context) throws ViewHandlerException {
+        Debug.logError(msg + ": " + e, MODULE);
+        try {
+            Writer writer = new StringWriter();
+            VisualTheme visualTheme = UtilHttp.getVisualTheme(request);
+            ModelTheme modelTheme = visualTheme.getModelTheme();
+            ScreenStringRenderer screenStringRenderer = new 
MacroScreenRenderer(modelTheme.getType("screen"),
+                    modelTheme.getScreenRendererLocation("screen"));
+            ScreenRenderer screens = new ScreenRenderer(writer, 
UtilGenerics.cast(context), screenStringRenderer);
+            screens.getContext().put("errorMessage", msg + ": " + e);
+            screens.render(DEFAULT_ERROR_TEMPLATE);
+            response.setContentType("text/html");
+            response.getWriter().write(writer.toString());
+            writer.close();
+        } catch (IOException | GeneralException | SAXException | 
ParserConfigurationException | TemplateException x) {
+            Debug.logError("Multiple errors rendering xlsx export", MODULE);
+            throw new ViewHandlerException("Multiple errors rendering xlsx 
export", x);
+        }
+    }
+}
diff --git 
a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/xlsx/XlsxFormRenderer.java
 
b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/xlsx/XlsxFormRenderer.java
new file mode 100644
index 0000000000..672f19b708
--- /dev/null
+++ 
b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/xlsx/XlsxFormRenderer.java
@@ -0,0 +1,433 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ 
*******************************************************************************/
+package org.apache.ofbiz.widget.renderer.xlsx;
+
+import java.math.BigDecimal;
+import java.sql.Timestamp;
+import java.util.Map;
+
+import org.apache.ofbiz.widget.model.ModelForm;
+import org.apache.ofbiz.widget.model.ModelFormField;
+import org.apache.ofbiz.widget.renderer.FormStringRenderer;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellStyle;
+import org.apache.poi.ss.usermodel.CreationHelper;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.ss.util.WorkbookUtil;
+
+/**
+ * Writes POI spreadsheet cells directly from widget form/grid rendering 
callbacks,
+ * in place of the macro-call text a {@code FormStringRenderer} normally 
produces.
+ * Used by {@code ScreenXlsxViewHandler} to build a real binary .xlsx workbook.
+ */
+public final class XlsxFormRenderer implements FormStringRenderer {
+
+    private final Workbook workbook;
+    private final CellStyle numericStyle;
+    private final CellStyle dateStyle;
+    private final CellStyle dateTimeStyle;
+
+    private Sheet sheet;
+    private Row currentRow;
+    private Cell currentCell;
+    private int rowIndex;
+    private int colIndex;
+
+    public XlsxFormRenderer(Workbook workbook) {
+        this.workbook = workbook;
+        CreationHelper creationHelper = workbook.getCreationHelper();
+
+        this.numericStyle = workbook.createCellStyle();
+        
this.numericStyle.setDataFormat(creationHelper.createDataFormat().getFormat("#,##0.00"));
+
+        this.dateStyle = workbook.createCellStyle();
+        
this.dateStyle.setDataFormat(creationHelper.createDataFormat().getFormat("m/d/yyyy"));
+
+        this.dateTimeStyle = workbook.createCellStyle();
+        
this.dateTimeStyle.setDataFormat(creationHelper.createDataFormat().getFormat("m/d/yyyy
 h:mm"));
+    }
+
+    Sheet getSheet() {
+        return sheet;
+    }
+
+    @Override
+    public void renderFormatListWrapperOpen(Appendable writer, Map<String, 
Object> context, ModelForm modelForm) {
+        this.sheet = 
workbook.createSheet(WorkbookUtil.createSafeSheetName(modelForm.getName()));
+        this.rowIndex = 0;
+        this.colIndex = 0;
+    }
+
+    @Override
+    public void renderFormatListWrapperClose(Appendable writer, Map<String, 
Object> context, ModelForm modelForm) {
+    }
+
+    @Override
+    public void renderFormatHeaderOpen(Appendable writer, Map<String, Object> 
context, ModelForm modelForm) {
+    }
+
+    @Override
+    public void renderFormatHeaderClose(Appendable writer, Map<String, Object> 
context, ModelForm modelForm) {
+    }
+
+    @Override
+    public void renderFormatHeaderRowOpen(Appendable writer, Map<String, 
Object> context, ModelForm modelForm) {
+        this.currentRow = sheet.createRow(rowIndex++);
+        this.colIndex = 0;
+    }
+
+    @Override
+    public void renderFormatHeaderRowClose(Appendable writer, Map<String, 
Object> context, ModelForm modelForm) {
+    }
+
+    @Override
+    public void renderFormatHeaderRowCellOpen(Appendable writer, Map<String, 
Object> context, ModelForm modelForm,
+            ModelFormField modelFormField, int positionSpan) {
+        this.currentCell = currentRow.createCell(colIndex);
+    }
+
+    @Override
+    public void renderFormatHeaderRowCellClose(Appendable writer, Map<String, 
Object> context, ModelForm modelForm,
+            ModelFormField modelFormField) {
+        this.currentCell = null;
+        this.colIndex++;
+    }
+
+    @Override
+    public void renderFormatHeaderRowFormCellOpen(Appendable writer, 
Map<String, Object> context, ModelForm modelForm) {
+    }
+
+    @Override
+    public void renderFormatHeaderRowFormCellClose(Appendable writer, 
Map<String, Object> context, ModelForm modelForm) {
+    }
+
+    @Override
+    public void renderFormatHeaderRowFormCellTitleSeparator(Appendable writer, 
Map<String, Object> context, ModelForm modelForm,
+            ModelFormField modelFormField, boolean isLast) {
+    }
+
+    @Override
+    public void renderFieldTitle(Appendable writer, Map<String, Object> 
context, ModelFormField modelFormField) {
+        Cell cell = obtainCell();
+        cell.setCellValue(modelFormField.getTitle(context));
+    }
+
+    @Override
+    public void renderSingleFormFieldTitle(Appendable writer, Map<String, 
Object> context, ModelFormField modelFormField) {
+        renderFieldTitle(writer, context, modelFormField);
+    }
+
+    @Override
+    public void renderFormatItemRowOpen(Appendable writer, Map<String, Object> 
context, ModelForm modelForm) {
+        this.currentRow = sheet.createRow(rowIndex++);
+        this.colIndex = 0;
+    }
+
+    @Override
+    public void renderFormatItemRowClose(Appendable writer, Map<String, 
Object> context, ModelForm modelForm) {
+    }
+
+    @Override
+    public void renderFormatItemRowCellOpen(Appendable writer, Map<String, 
Object> context, ModelForm modelForm,
+            ModelFormField modelFormField, int positionSpan) {
+        this.currentCell = currentRow.createCell(colIndex);
+    }
+
+    @Override
+    public void renderFormatItemRowCellClose(Appendable writer, Map<String, 
Object> context, ModelForm modelForm,
+            ModelFormField modelFormField) {
+        this.currentCell = null;
+        this.colIndex++;
+    }
+
+    @Override
+    public void renderFormatItemRowFormCellOpen(Appendable writer, Map<String, 
Object> context, ModelForm modelForm) {
+    }
+
+    @Override
+    public void renderFormatItemRowFormCellClose(Appendable writer, 
Map<String, Object> context, ModelForm modelForm) {
+    }
+
+    @Override
+    public void renderFormatSingleWrapperOpen(Appendable writer, Map<String, 
Object> context, ModelForm modelForm) {
+        this.sheet = 
workbook.createSheet(WorkbookUtil.createSafeSheetName(modelForm.getName()));
+        this.rowIndex = 0;
+        this.colIndex = 0;
+    }
+
+    @Override
+    public void renderFormatSingleWrapperClose(Appendable writer, Map<String, 
Object> context, ModelForm modelForm) {
+    }
+
+    @Override
+    public void renderFormatFieldRowOpen(Appendable writer, Map<String, 
Object> context, ModelForm modelForm) {
+        this.currentRow = sheet.createRow(rowIndex++);
+        this.colIndex = 0;
+    }
+
+    @Override
+    public void renderFormatFieldRowClose(Appendable writer, Map<String, 
Object> context, ModelForm modelForm) {
+    }
+
+    @Override
+    public void renderFormatFieldRowTitleCellOpen(Appendable writer, 
Map<String, Object> context, ModelFormField modelFormField) {
+        this.currentCell = currentRow.createCell(colIndex);
+    }
+
+    @Override
+    public void renderFormatFieldRowTitleCellClose(Appendable writer, 
Map<String, Object> context, ModelFormField modelFormField) {
+        this.currentCell = null;
+        this.colIndex++;
+    }
+
+    @Override
+    public void renderFormatFieldRowSpacerCell(Appendable writer, Map<String, 
Object> context, ModelFormField modelFormField) {
+    }
+
+    @Override
+    public void renderFormatFieldRowWidgetCellOpen(Appendable writer, 
Map<String, Object> context, ModelFormField modelFormField,
+            int positions, int positionSpan, Integer nextPositionInRow) {
+        this.currentCell = currentRow.createCell(colIndex);
+    }
+
+    @Override
+    public void renderFormatFieldRowWidgetCellClose(Appendable writer, 
Map<String, Object> context, ModelFormField modelFormField,
+            int positions, int positionSpan, Integer nextPositionInRow) {
+        this.currentCell = null;
+        this.colIndex++;
+    }
+
+    @Override
+    public void renderFormatEmptySpace(Appendable writer, Map<String, Object> 
context, ModelForm modelForm) {
+    }
+
+    @Override
+    public void renderDisplayField(Appendable writer, Map<String, Object> 
context, ModelFormField.DisplayField displayField) {
+        String type = displayField.getType();
+        String rawValue = displayField.getModelFormField().getEntry(context);
+        String displayValue = displayField.getDescription(context);
+        if ("currency".equals(type) || "accounting-number".equals(type)) {
+            writeNumericOrFallback(rawValue, displayValue);
+        } else if ("date".equals(type)) {
+            writeDateOrFallback(rawValue, displayValue, true);
+        } else if ("date-time".equals(type)) {
+            writeDateOrFallback(rawValue, displayValue, false);
+        } else {
+            writeString(displayValue);
+        }
+    }
+
+    @Override
+    public void renderTextField(Appendable writer, Map<String, Object> 
context, ModelFormField.TextField textField) {
+        String type = textField.getType();
+        String rawValue = textField.getModelFormField().getEntry(context);
+        if ("currency".equals(type) || "accounting-number".equals(type)) {
+            writeNumericOrFallback(rawValue, rawValue);
+        } else {
+            writeString(rawValue);
+        }
+    }
+
+    @Override
+    public void renderDateTimeField(Appendable writer, Map<String, Object> 
context, ModelFormField.DateTimeField dateTimeField) {
+        String rawValue = dateTimeField.getModelFormField().getEntry(context);
+        writeDateOrFallback(rawValue, rawValue, dateTimeField.isDateType());
+    }
+
+    private void writeString(String value) {
+        Cell cell = obtainCell();
+        cell.setCellValue(value == null ? "" : value);
+    }
+
+    private void writeNumericOrFallback(String rawValue, String displayValue) {
+        BigDecimal numeric = parseNumeric(rawValue);
+        if (numeric != null) {
+            Cell cell = obtainCell();
+            cell.setCellValue(numeric.doubleValue());
+            cell.setCellStyle(numericStyle);
+        } else {
+            writeString(displayValue);
+        }
+    }
+
+    private void writeDateOrFallback(String rawValue, String displayValue, 
boolean isDateType) {
+        java.util.Date parsed = parseDateTime(rawValue, isDateType);
+        if (parsed != null) {
+            Cell cell = obtainCell();
+            cell.setCellValue(parsed);
+            cell.setCellStyle(isDateType ? dateStyle : dateTimeStyle);
+        } else {
+            writeString(displayValue);
+        }
+    }
+
+    /**
+     * Returns the cell that a title/value write should land in. When an 
explicit
+     * {@code renderFormat*CellOpen} call already created {@link 
#currentCell}, that same cell is
+     * returned unchanged. Otherwise (a field rendered without a separate 
column, the default when
+     * {@code separate-columns}/{@code separate-column} is not set) a cell is 
created at the current
+     * column and the column index is advanced, so subsequent bare fields land 
in the next column.
+     */
+    private Cell obtainCell() {
+        if (currentCell == null) {
+            return currentRow.createCell(colIndex++);
+        }
+        return currentCell;
+    }
+
+    private static BigDecimal parseNumeric(String rawValue) {
+        if (rawValue == null || rawValue.isEmpty()) {
+            return null;
+        }
+        try {
+            return new BigDecimal(rawValue.trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private static java.util.Date parseDateTime(String rawValue, boolean 
isDateType) {
+        if (rawValue == null || rawValue.isEmpty()) {
+            return null;
+        }
+        try {
+            return isDateType ? java.sql.Date.valueOf(rawValue.trim()) : 
Timestamp.valueOf(rawValue.trim());
+        } catch (IllegalArgumentException e) {
+            return null;
+        }
+    }
+
+    @Override
+    public void renderDropDownField(Appendable writer, Map<String, Object> 
context, ModelFormField.DropDownField dropDownField) {
+        writeString(dropDownField.getModelFormField().getEntry(context));
+    }
+
+    @Override
+    public void renderCheckField(Appendable writer, Map<String, Object> 
context, ModelFormField.CheckField checkField) {
+        writeString(checkField.getModelFormField().getEntry(context));
+    }
+
+    @Override
+    public void renderRadioField(Appendable writer, Map<String, Object> 
context, ModelFormField.RadioField radioField) {
+        writeString(radioField.getModelFormField().getEntry(context));
+    }
+
+    @Override
+    public void renderHiddenField(Appendable writer, Map<String, Object> 
context, ModelFormField modelFormField, String value) {
+    }
+
+    @Override
+    public void renderHiddenField(Appendable writer, Map<String, Object> 
context, ModelFormField.HiddenField hiddenField) {
+    }
+
+    @Override
+    public void renderIgnoredField(Appendable writer, Map<String, Object> 
context, ModelFormField.IgnoredField ignoredField) {
+    }
+
+    @Override
+    public void renderSubmitField(Appendable writer, Map<String, Object> 
context, ModelFormField.SubmitField submitField) {
+    }
+
+    @Override
+    public void renderResetField(Appendable writer, Map<String, Object> 
context, ModelFormField.ResetField resetField) {
+    }
+
+    @Override
+    public void renderFormOpen(Appendable writer, Map<String, Object> context, 
ModelForm modelForm) {
+    }
+
+    @Override
+    public void renderFormClose(Appendable writer, Map<String, Object> 
context, ModelForm modelForm) {
+    }
+
+    @Override
+    public void renderMultiFormClose(Appendable writer, Map<String, Object> 
context, ModelForm modelForm) {
+    }
+
+    // Everything below has no place in a tabular data export (styling, 
hyperlinks, lookups,
+    // find-fields, banners, tooltips, field groups, images) - matches the 
CSV/XLS FTL macro
+    // libraries' own no-op behavior for the same methods.
+
+    @Override
+    public void renderHyperlinkField(Appendable writer, Map<String, Object> 
context, ModelFormField.HyperlinkField hyperlinkField) {
+    }
+
+    @Override
+    public void renderMenuField(Appendable writer, Map<String, Object> 
context, ModelFormField.MenuField menuField) {
+    }
+
+    @Override
+    public void renderTextareaField(Appendable writer, Map<String, Object> 
context, ModelFormField.TextareaField textareaField) {
+    }
+
+    @Override
+    public void renderTextFindField(Appendable writer, Map<String, Object> 
context, ModelFormField.TextFindField textField) {
+    }
+
+    @Override
+    public void renderDateFindField(Appendable writer, Map<String, Object> 
context, ModelFormField.DateFindField textField) {
+    }
+
+    @Override
+    public void renderRangeFindField(Appendable writer, Map<String, Object> 
context, ModelFormField.RangeFindField textField) {
+    }
+
+    @Override
+    public void renderDateRangePickerField(Appendable writer, Map<String, 
Object> context,
+            ModelFormField.DateRangePickerField textField) {
+    }
+
+    @Override
+    public void renderLookupField(Appendable writer, Map<String, Object> 
context, ModelFormField.LookupField textField) {
+    }
+
+    @Override
+    public void renderFileField(Appendable writer, Map<String, Object> 
context, ModelFormField.FileField textField) {
+    }
+
+    @Override
+    public void renderPasswordField(Appendable writer, Map<String, Object> 
context, ModelFormField.PasswordField textField) {
+    }
+
+    @Override
+    public void renderImageField(Appendable writer, Map<String, Object> 
context, ModelFormField.ImageField textField) {
+    }
+
+    @Override
+    public void renderBanner(Appendable writer, Map<String, Object> context, 
ModelForm.Banner banner) {
+    }
+
+    @Override
+    public void renderContainerFindField(Appendable writer, Map<String, 
Object> context, ModelFormField.ContainerField containerField) {
+    }
+
+    @Override
+    public void renderFieldGroupOpen(Appendable writer, Map<String, Object> 
context, ModelForm.FieldGroup fieldGroup) {
+    }
+
+    @Override
+    public void renderFieldGroupClose(Appendable writer, Map<String, Object> 
context, ModelForm.FieldGroup fieldGroup) {
+    }
+
+    @Override
+    public void renderEmptyFormDataMessage(Appendable writer, Map<String, 
Object> context, ModelForm modelForm) {
+    }
+}
diff --git 
a/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/xlsx/XlsxScreenStringRenderer.java
 
b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/xlsx/XlsxScreenStringRenderer.java
new file mode 100644
index 0000000000..173c115ba6
--- /dev/null
+++ 
b/framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/xlsx/XlsxScreenStringRenderer.java
@@ -0,0 +1,173 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ 
*******************************************************************************/
+package org.apache.ofbiz.widget.renderer.xlsx;
+
+import java.io.IOException;
+import java.util.Map;
+
+import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.widget.model.ModelScreen;
+import org.apache.ofbiz.widget.model.ModelScreenWidget;
+import org.apache.ofbiz.widget.renderer.ScreenStringRenderer;
+
+/**
+ * No-op {@code ScreenStringRenderer}: lets {@code ScreenRenderer} walk a 
screen's
+ * container/section/label tree without error while an xlsx export is in 
progress.
+ * None of this screen-level chrome is part of the tabular data export - only 
the
+ * form/grid content reached via {@code XlsxFormRenderer} matters.
+ */
+public class XlsxScreenStringRenderer implements ScreenStringRenderer {
+
+    @Override
+    public String getRendererName() {
+        return "XlsxScreenStringRenderer";
+    }
+
+    @Override
+    public void renderBegin(Appendable writer, Map<String, Object> context) 
throws IOException {
+    }
+
+    @Override
+    public void renderEnd(Appendable writer, Map<String, Object> context) 
throws IOException {
+    }
+
+    @Override
+    public void renderScreenBegin(Appendable writer, Map<String, Object> 
context, ModelScreen modelScreen) throws IOException {
+    }
+
+    @Override
+    public void renderScreenEnd(Appendable writer, Map<String, Object> 
context, ModelScreen modelScreen) throws IOException {
+    }
+
+    @Override
+    public void renderSectionBegin(Appendable writer, Map<String, Object> 
context, ModelScreenWidget.Section section) throws IOException {
+    }
+
+    @Override
+    public void renderSectionEnd(Appendable writer, Map<String, Object> 
context, ModelScreenWidget.Section section) throws IOException {
+    }
+
+    @Override
+    public void renderColumnContainer(Appendable writer, Map<String, Object> 
context, ModelScreenWidget.ColumnContainer columnContainer)
+            throws IOException {
+    }
+
+    @Override
+    public void renderContainerBegin(Appendable writer, Map<String, Object> 
context, ModelScreenWidget.Container container) throws IOException {
+    }
+
+    @Override
+    public void renderContainerEnd(Appendable writer, Map<String, Object> 
context, ModelScreenWidget.Container container) throws IOException {
+    }
+
+    @Override
+    public void renderContentBegin(Appendable writer, Map<String, Object> 
context, ModelScreenWidget.Content content) throws IOException {
+    }
+
+    @Override
+    public void renderContentBody(Appendable writer, Map<String, Object> 
context, ModelScreenWidget.Content content) throws IOException {
+    }
+
+    @Override
+    public void renderContentEnd(Appendable writer, Map<String, Object> 
context, ModelScreenWidget.Content content) throws IOException {
+    }
+
+    @Override
+    public void renderSubContentBegin(Appendable writer, Map<String, Object> 
context, ModelScreenWidget.SubContent content) throws IOException {
+    }
+
+    @Override
+    public void renderSubContentBody(Appendable writer, Map<String, Object> 
context, ModelScreenWidget.SubContent content) throws IOException {
+    }
+
+    @Override
+    public void renderSubContentEnd(Appendable writer, Map<String, Object> 
context, ModelScreenWidget.SubContent content) throws IOException {
+    }
+
+    @Override
+    public void renderHorizontalSeparator(Appendable writer, Map<String, 
Object> context, ModelScreenWidget.HorizontalSeparator separator)
+            throws IOException {
+    }
+
+    @Override
+    public void renderLabel(Appendable writer, Map<String, Object> context, 
ModelScreenWidget.Label label) throws IOException {
+    }
+
+    @Override
+    public void renderLink(Appendable writer, Map<String, Object> context, 
ModelScreenWidget.ScreenLink link) throws IOException {
+    }
+
+    @Override
+    public void renderImage(Appendable writer, Map<String, Object> context, 
ModelScreenWidget.ScreenImage image) throws IOException {
+    }
+
+    @Override
+    public void renderContentFrame(Appendable writer, Map<String, Object> 
context, ModelScreenWidget.Content content) throws IOException {
+    }
+
+    @Override
+    public void renderScreenletBegin(Appendable writer, Map<String, Object> 
context, boolean collapsed, ModelScreenWidget.Screenlet screenlet)
+            throws IOException {
+    }
+
+    @Override
+    public void renderScreenletSubWidget(Appendable writer, Map<String, 
Object> context, ModelScreenWidget subWidget,
+            ModelScreenWidget.Screenlet screenlet) throws GeneralException, 
IOException {
+    }
+
+    @Override
+    public void renderScreenletEnd(Appendable writer, Map<String, Object> 
context, ModelScreenWidget.Screenlet screenlet) throws IOException {
+    }
+
+    @Override
+    public void renderPortalPageBegin(Appendable writer, Map<String, Object> 
context, ModelScreenWidget.PortalPage portalPage)
+            throws GeneralException, IOException {
+    }
+
+    @Override
+    public void renderPortalPageEnd(Appendable writer, Map<String, Object> 
context, ModelScreenWidget.PortalPage portalPage)
+            throws GeneralException, IOException {
+    }
+
+    @Override
+    public void renderPortalPageColumnBegin(Appendable writer, Map<String, 
Object> context, ModelScreenWidget.PortalPage portalPage,
+                                     GenericValue portalPageColumn) throws 
GeneralException, IOException {
+    }
+
+    @Override
+    public void renderPortalPageColumnEnd(Appendable writer, Map<String, 
Object> context, ModelScreenWidget.PortalPage portalPage,
+                                   GenericValue portalPageColumn) throws 
GeneralException, IOException {
+    }
+
+    @Override
+    public void renderPortalPagePortletBegin(Appendable writer, Map<String, 
Object> context, ModelScreenWidget.PortalPage portalPage,
+                                      GenericValue portalPortlet) throws 
GeneralException, IOException {
+    }
+
+    @Override
+    public void renderPortalPagePortletBody(Appendable writer, Map<String, 
Object> context, ModelScreenWidget.PortalPage portalPage,
+                                     GenericValue portalPortlet) throws 
GeneralException, IOException {
+    }
+
+    @Override
+    public void renderPortalPagePortletEnd(Appendable writer, Map<String, 
Object> context, ModelScreenWidget.PortalPage portalPage,
+                                    GenericValue portalPortlet) throws 
GeneralException, IOException {
+    }
+}
diff --git 
a/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/xlsx/XlsxFormRendererTest.java
 
b/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/xlsx/XlsxFormRendererTest.java
new file mode 100644
index 0000000000..7427caf93c
--- /dev/null
+++ 
b/framework/widget/src/test/java/org/apache/ofbiz/widget/renderer/xlsx/XlsxFormRendererTest.java
@@ -0,0 +1,232 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ 
*******************************************************************************/
+package org.apache.ofbiz.widget.renderer.xlsx;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.sql.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.ofbiz.widget.model.ModelForm;
+import org.apache.ofbiz.widget.model.ModelFormField;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellType;
+import org.apache.poi.ss.usermodel.DateUtil;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.junit.jupiter.api.Test;
+
+final class XlsxFormRendererTest {
+
+    private final Map<String, Object> context = new HashMap<>();
+    private final XSSFWorkbook workbook = new XSSFWorkbook();
+    private final XlsxFormRenderer renderer = new XlsxFormRenderer(workbook);
+
+    @Test
+    void writesHeaderRowThenPlainTextItemRow() {
+        ModelForm modelForm = mock(ModelForm.class);
+        when(modelForm.getName()).thenReturn("TestForm");
+
+        ModelFormField titleField = mock(ModelFormField.class, 
RETURNS_DEEP_STUBS);
+        when(titleField.getTitle(context)).thenReturn("Product Name");
+
+        ModelFormField.TextField textField = 
mock(ModelFormField.TextField.class, RETURNS_DEEP_STUBS);
+        when(textField.getType()).thenReturn("");
+        
when(textField.getModelFormField().getEntry(context)).thenReturn("Widget");
+
+        renderer.renderFormatListWrapperOpen(null, context, modelForm);
+        renderer.renderFormatHeaderRowOpen(null, context, modelForm);
+        renderer.renderFormatHeaderRowCellOpen(null, context, modelForm, 
titleField, 1);
+        renderer.renderFieldTitle(null, context, titleField);
+        renderer.renderFormatHeaderRowCellClose(null, context, modelForm, 
titleField);
+        renderer.renderFormatHeaderRowClose(null, context, modelForm);
+
+        renderer.renderFormatItemRowOpen(null, context, modelForm);
+        renderer.renderFormatItemRowCellOpen(null, context, modelForm, 
titleField, 1);
+        renderer.renderTextField(null, context, textField);
+        renderer.renderFormatItemRowCellClose(null, context, modelForm, 
titleField);
+        renderer.renderFormatItemRowClose(null, context, modelForm);
+        renderer.renderFormatListWrapperClose(null, context, modelForm);
+
+        Sheet sheet = renderer.getSheet();
+        assertThat(sheet.getSheetName(), equalTo("TestForm"));
+        assertThat(sheet.getRow(0).getCell(0).getStringCellValue(), 
equalTo("Product Name"));
+        assertThat(sheet.getRow(1).getCell(0).getStringCellValue(), 
equalTo("Widget"));
+    }
+
+    @Test
+    void writesCurrencyDisplayFieldAsNumericCell() {
+        ModelForm modelForm = mock(ModelForm.class);
+        when(modelForm.getName()).thenReturn("TestForm");
+
+        ModelFormField.DisplayField priceField = 
mock(ModelFormField.DisplayField.class, RETURNS_DEEP_STUBS);
+        when(priceField.getType()).thenReturn("currency");
+        
when(priceField.getModelFormField().getEntry(context)).thenReturn("19.99");
+        when(priceField.getDescription(context)).thenReturn("$19.99");
+
+        renderer.renderFormatListWrapperOpen(null, context, modelForm);
+        renderer.renderFormatItemRowOpen(null, context, modelForm);
+        renderer.renderFormatItemRowCellOpen(null, context, modelForm, 
priceField.getModelFormField(), 1);
+        renderer.renderDisplayField(null, context, priceField);
+        renderer.renderFormatItemRowCellClose(null, context, modelForm, 
priceField.getModelFormField());
+
+        Cell cell = renderer.getSheet().getRow(0).getCell(0);
+        assertThat(cell.getCellType(), equalTo(CellType.NUMERIC));
+        assertThat(cell.getNumericCellValue(), equalTo(19.99));
+    }
+
+    @Test
+    void fallsBackToStringWhenCurrencyValueIsUnparseable() {
+        ModelForm modelForm = mock(ModelForm.class);
+        when(modelForm.getName()).thenReturn("TestForm");
+
+        ModelFormField.DisplayField priceField = 
mock(ModelFormField.DisplayField.class, RETURNS_DEEP_STUBS);
+        when(priceField.getType()).thenReturn("currency");
+        when(priceField.getModelFormField().getEntry(context)).thenReturn("");
+        when(priceField.getDescription(context)).thenReturn("N/A");
+
+        renderer.renderFormatListWrapperOpen(null, context, modelForm);
+        renderer.renderFormatItemRowOpen(null, context, modelForm);
+        renderer.renderFormatItemRowCellOpen(null, context, modelForm, 
priceField.getModelFormField(), 1);
+        renderer.renderDisplayField(null, context, priceField);
+        renderer.renderFormatItemRowCellClose(null, context, modelForm, 
priceField.getModelFormField());
+
+        Cell cell = renderer.getSheet().getRow(0).getCell(0);
+        assertThat(cell.getCellType(), equalTo(CellType.STRING));
+        assertThat(cell.getStringCellValue(), equalTo("N/A"));
+    }
+
+    @Test
+    void writesDateDisplayFieldAsRealDateCell() {
+        ModelForm modelForm = mock(ModelForm.class);
+        when(modelForm.getName()).thenReturn("TestForm");
+
+        ModelFormField.DisplayField shipDateField = 
mock(ModelFormField.DisplayField.class, RETURNS_DEEP_STUBS);
+        when(shipDateField.getType()).thenReturn("date");
+        
when(shipDateField.getModelFormField().getEntry(context)).thenReturn("2026-01-15");
+        when(shipDateField.getDescription(context)).thenReturn("01/15/2026");
+
+        renderer.renderFormatListWrapperOpen(null, context, modelForm);
+        renderer.renderFormatItemRowOpen(null, context, modelForm);
+        renderer.renderFormatItemRowCellOpen(null, context, modelForm, 
shipDateField.getModelFormField(), 1);
+        renderer.renderDisplayField(null, context, shipDateField);
+        renderer.renderFormatItemRowCellClose(null, context, modelForm, 
shipDateField.getModelFormField());
+
+        Cell cell = renderer.getSheet().getRow(0).getCell(0);
+        assertThat(DateUtil.isCellDateFormatted(cell), equalTo(true));
+        assertThat(cell.getDateCellValue(), 
equalTo(Date.valueOf("2026-01-15")));
+    }
+
+    @Test
+    void writesDateTimeFieldAsRealDateCell() {
+        ModelForm modelForm = mock(ModelForm.class);
+        when(modelForm.getName()).thenReturn("TestForm");
+
+        ModelFormField.DateTimeField orderDateField = 
mock(ModelFormField.DateTimeField.class, RETURNS_DEEP_STUBS);
+        when(orderDateField.isDateType()).thenReturn(false);
+        when(orderDateField.isTimeType()).thenReturn(false);
+        
when(orderDateField.getModelFormField().getEntry(context)).thenReturn("2026-01-15
 10:30:00.0");
+
+        renderer.renderFormatListWrapperOpen(null, context, modelForm);
+        renderer.renderFormatItemRowOpen(null, context, modelForm);
+        renderer.renderFormatItemRowCellOpen(null, context, modelForm, 
orderDateField.getModelFormField(), 1);
+        renderer.renderDateTimeField(null, context, orderDateField);
+        renderer.renderFormatItemRowCellClose(null, context, modelForm, 
orderDateField.getModelFormField());
+
+        Cell cell = renderer.getSheet().getRow(0).getCell(0);
+        assertThat(DateUtil.isCellDateFormatted(cell), equalTo(true));
+        assertThat(cell.getDateCellValue(), 
equalTo(java.sql.Timestamp.valueOf("2026-01-15 10:30:00.0")));
+    }
+
+    @Test
+    void writesDropDownFieldRawValueAsString() {
+        ModelForm modelForm = mock(ModelForm.class);
+        when(modelForm.getName()).thenReturn("TestForm");
+
+        ModelFormField.DropDownField statusField = 
mock(ModelFormField.DropDownField.class, RETURNS_DEEP_STUBS);
+        
when(statusField.getModelFormField().getEntry(context)).thenReturn("ORDER_APPROVED");
+
+        renderer.renderFormatListWrapperOpen(null, context, modelForm);
+        renderer.renderFormatItemRowOpen(null, context, modelForm);
+        renderer.renderFormatItemRowCellOpen(null, context, modelForm, 
statusField.getModelFormField(), 1);
+        renderer.renderDropDownField(null, context, statusField);
+        renderer.renderFormatItemRowCellClose(null, context, modelForm, 
statusField.getModelFormField());
+
+        Cell cell = renderer.getSheet().getRow(0).getCell(0);
+        assertThat(cell.getStringCellValue(), equalTo("ORDER_APPROVED"));
+    }
+
+    @Test
+    void writesSingleFormAsTitleValueRows() {
+        ModelForm modelForm = mock(ModelForm.class);
+        when(modelForm.getName()).thenReturn("DetailForm");
+
+        ModelFormField nameField = mock(ModelFormField.class, 
RETURNS_DEEP_STUBS);
+        when(nameField.getTitle(context)).thenReturn("Order Id");
+
+        ModelFormField.TextField valueField = 
mock(ModelFormField.TextField.class, RETURNS_DEEP_STUBS);
+        when(valueField.getType()).thenReturn("");
+        
when(valueField.getModelFormField().getEntry(context)).thenReturn("WS10000");
+
+        renderer.renderFormatSingleWrapperOpen(null, context, modelForm);
+        renderer.renderFormatFieldRowOpen(null, context, modelForm);
+        renderer.renderFormatFieldRowTitleCellOpen(null, context, nameField);
+        renderer.renderSingleFormFieldTitle(null, context, nameField);
+        renderer.renderFormatFieldRowTitleCellClose(null, context, nameField);
+        renderer.renderFormatFieldRowWidgetCellOpen(null, context, nameField, 
1, 1, null);
+        renderer.renderTextField(null, context, valueField);
+        renderer.renderFormatFieldRowWidgetCellClose(null, context, nameField, 
1, 1, null);
+        renderer.renderFormatFieldRowClose(null, context, modelForm);
+
+        Sheet sheet = renderer.getSheet();
+        assertThat(sheet.getSheetName(), equalTo("DetailForm"));
+        assertThat(sheet.getRow(0).getCell(0).getStringCellValue(), 
equalTo("Order Id"));
+        assertThat(sheet.getRow(0).getCell(1).getStringCellValue(), 
equalTo("WS10000"));
+    }
+
+    @Test
+    void writesBareHeaderTitlesWithoutExplicitCellOpenIntoDistinctColumns() {
+        ModelForm modelForm = mock(ModelForm.class);
+        when(modelForm.getName()).thenReturn("TestForm");
+
+        ModelFormField firstField = mock(ModelFormField.class, 
RETURNS_DEEP_STUBS);
+        when(firstField.getTitle(context)).thenReturn("Product Name");
+
+        ModelFormField secondField = mock(ModelFormField.class, 
RETURNS_DEEP_STUBS);
+        when(secondField.getTitle(context)).thenReturn("Quantity");
+
+        renderer.renderFormatListWrapperOpen(null, context, modelForm);
+        renderer.renderFormatHeaderRowOpen(null, context, modelForm);
+        // No renderFormatHeaderRowCellOpen calls: 
modelForm.getSeparateColumns() and
+        // modelFormField.getSeparateColumn() are both false, the default 
case, so FormRenderer
+        // invokes renderFieldTitle directly for each field with no preceding 
cell-open.
+        renderer.renderFieldTitle(null, context, firstField);
+        renderer.renderFieldTitle(null, context, secondField);
+        renderer.renderFormatHeaderRowClose(null, context, modelForm);
+
+        Sheet sheet = renderer.getSheet();
+        assertThat(sheet.getRow(0).getCell(0).getStringCellValue(), 
equalTo("Product Name"));
+        assertThat(sheet.getRow(0).getCell(1).getStringCellValue(), 
equalTo("Quantity"));
+    }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f390c46dde..7a4a31963d 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -148,6 +148,7 @@ log4j-api = { module = 
"org.apache.logging.log4j:log4j-api", version.ref = "log4
 log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = 
"log4j" }
 log4j-layout-template-json = { module = 
"org.apache.logging.log4j:log4j-layout-template-json", version.ref = "log4j" }
 poi = { module = "org.apache.poi:poi", version.ref = "poi" }
+poi-ooxml = { module = "org.apache.poi:poi-ooxml", version.ref = "poi" }
 pdfbox-core = { module = "org.apache.pdfbox:pdfbox", version.ref = "pdfbox" }
 pdfbox-io = { module = "org.apache.pdfbox:pdfbox-io", version.ref = "pdfbox" }
 shiro-core = { module = "org.apache.shiro:shiro-core", version.ref = 
"shiro-core" }

Reply via email to