JasperReportWrapper.java

/*
** Module   : JasperReportWrapper.java
** Abstract : Jasper reporting facility implemented as 4GL syntax extension.
**
** Copyright (c) 2017-2025, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------Description----------------------------------
** 001 SVL 20171030 Created initial version.
** 002 SVL 20180205 Switched to data sources concept.
** 003 HC  20180410 Renamed ReportOutputFormat to MediaType.
** 004 EVL 20180423 Renamed Report to FwdReport.  Adding support for decimal to Integer mapping.
**                  Also added case when we will bypass data mapping.  Fixes jasperreport file
**                  handling exceptions that causing application to end.
** 005 OM  20181003 Text in cells is cut only when constrained by page layout (PDF).
** 006 CA  20191005 Added export-report-html for FWD report.
** 006 EVL 20210212 Adding methods to handle chart related reports.
**     EVL 20210305 Adding methods to handle rtf/docx related reports.  Fixed output stream issue for RTF
**                  exporter.
** 007 RNC 20250131 When there are too many columns in a PDF report, split the report into multiple pages
*                   rather than shrinking the cells to fit.
*/

/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 of the
** License, or (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
** GNU Affero General Public License for more details.
**
** You may find a copy of the GNU Affero GPL version 3 at the following
** location: https://www.gnu.org/licenses/agpl-3.0.en.html
**
** Additional terms under GNU Affero GPL version 3 section 7:
**
**   Under Section 7 of the GNU Affero GPL version 3, the following additional
**   terms apply to the works covered under the License.  These additional terms
**   are non-permissive additional terms allowed under Section 7 of the GNU
**   Affero GPL version 3 and may not be removed by you.
**
**   0. Attribution Requirement.
**
**     You must preserve all legal notices or author attributions in the covered
**     work or Appropriate Legal Notices displayed by works containing the covered
**     work.  You may not remove from the covered work any author or developer
**     credit already included within the covered work.
**
**   1. No License To Use Trademarks.
**
**     This license does not grant any license or rights to use the trademarks
**     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
**     of Golden Code Development Corporation. You are not authorized to use the
**     name Golden Code, FWD, or the names of any author or contributor, for
**     publicity purposes without written authorization.
**
**   2. No Misrepresentation of Affiliation.
**
**     You may not represent yourself as Golden Code Development Corporation or FWD.
**
**     You may not represent yourself for publicity purposes as associated with
**     Golden Code Development Corporation, FWD, or any author or contributor to
**     the covered work, without written authorization.
**
**   3. No Misrepresentation of Source or Origin.
**
**     You may not represent the covered work as solely your work.  All modified
**     versions of the covered work must be marked in a reasonable way to make it
**     clear that the modified work is not originating from Golden Code Development
**     Corporation or FWD.  All modified versions must contain the notices of
**     attribution required in this license.
*/

package com.goldencode.p2j.reporting;

import com.goldencode.p2j.persist.*;
import com.goldencode.p2j.ui.BrowseWidget;
import com.goldencode.p2j.util.*;
import net.sf.jasperreports.engine.*;
import net.sf.jasperreports.engine.base.JRBasePrintPage;
import net.sf.jasperreports.engine.export.*;
import net.sf.jasperreports.engine.export.ooxml.JRDocxExporter;
import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter;
import net.sf.jasperreports.engine.fill.JRTemplatePrintText;
import net.sf.jasperreports.export.*;

import java.io.*;
import java.math.*;
import java.util.*;

import static com.goldencode.p2j.reporting.ReportFactory.showError;

/**
 * Jasper reporting facility implemented as 4GL syntax extension.
 */
class JasperReportWrapper
implements FwdReport
{
   /** Buffer size for exporting report to a file. */
   private final static int OUTPUT_BUFFER_SIZE = 64000;

   /** Report parameters (the ones which were set in 4GL code). */
   private Map<String, BaseDataType> parameters;

   /** Data source used by this report. */
   private JasperDataSource dataSource;

   /** File name of the report design file. */
   private String designFileName;

   /** The resource ID. */
   private Long id;

   /**
    * Get Jasper-compatible value from the 4GL-compatible value. In most cases Jasper-compatible
    * value has Java primitive data type.
    *
    * @param   value
    *          4GL-compatible value
    * @param   jasperValueClass
    *          Expected Jasper-compatible data type.
    *
    * @return  Jasper-compatible value.
    *
    * @throws IllegalArgumentException
    *         If 4GL-compatible data type is not supported or doesn't match expected
    *         Jasper-compatible data type.
    */
   static Object getJavaValue(BaseDataType value, Class<?> jasperValueClass)
   {
      // first handle case when no need to make data transformation
      if (value.getClass().equals(jasperValueClass))
      {
         return value;
      }
      else if (value instanceof Text && String.class.equals(jasperValueClass))
      {
         return ((character) value).getValue();
      }
      else if (value instanceof logical && Boolean.class.equals(jasperValueClass))
      {
         return !value.isUnknown() ? ((logical) value).getValue() : null;
      }
      else if (value instanceof int64)
      {
         if (Integer.class.equals(jasperValueClass))
         {
            return ((int64) value).toJavaIntegerType();
         }
         else if (Long.class.equals(jasperValueClass))
         {
            return ((int64) value).toJavaLongType();
         }
         else if (Short.class.equals(jasperValueClass))
         {
            Integer val = ((int64) value).toJavaIntegerType();
            return val != null ? val.shortValue() : null;
         }
      }
      else if (value instanceof decimal)
      {
         if (Double.class.equals(jasperValueClass))
         {
            BigDecimal val = ((decimal) value).toBigDecimal();
            return val != null ? val.doubleValue() : null;
         }
         else if (Float.class.equals(jasperValueClass))
         {
            BigDecimal val = ((decimal) value).toBigDecimal();
            return val != null ? val.floatValue() : null;
         }
         else if (Integer.class.equals(jasperValueClass))
         {
            BigDecimal val = ((decimal) value).toBigDecimal();
            return val != null ? val.intValueExact() : null;
         }
         else if (BigDecimal.class.equals(jasperValueClass))
         {
            return ((decimal) value).toBigDecimal();
         }
      }
      else if (value instanceof date && Date.class.equals(jasperValueClass))
      {
         return !value.isUnknown() ? ((date) value).dateValue() : null;
      }

      throw new IllegalArgumentException(
            String.format("Value of type %s cannot be converted to Jasper report parameter " +
                  "of type %s", value.getClass(), jasperValueClass));
   }

   /**
    * Set the parameter used by the report.
    *
    * @param  param
    *         Name of the parameter.
    * @param  value
    *         Value of the parameter.
    *
    * @return <code>true</code> on success.
    */
   @Override
   public logical setReportParameter(String param, BaseDataType value)
   {
      return setReportParameter(new character(param), value);
   }

   /**
    * Set the parameter used by the report.
    *
    * @param  param
    *         Name of the parameter.
    * @param  value
    *         Value of the parameter.
    *
    * @return <code>true</code> on success.
    */
   @Override
   public logical setReportParameter(String param, String value)
   {
      return setReportParameter(new character(param), new character(value));
   }

   /**
    * Set the parameter used by the report.
    *
    * @param  param
    *         Name of the parameter.
    * @param  value
    *         Value of the parameter.
    *
    * @return <code>true</code> on success.
    */
   @Override
   public logical setReportParameter(character param, BaseDataType value)
   {
      if (param.isUnknown())
      {
         showError("Report parameter name cannot be an unknown value.");
         return new logical(false);
      }

      if (parameters == null)
      {
         parameters = new HashMap<>();
      }
      parameters.put(param.getValue(), value);

      return new logical(true);
   }

   /**
    * Run report end export result to the PDF file.
    *
    * @param  fileName
    *         Name of the output PDF file.
    *
    * @return <code>true</code> on success.
    */
   @Override
   public logical exportReportPdf(String fileName)
   {
      return exportReportPdf(new character(fileName));
   }

   /**
    * Run report end export result to the PDF file.
    *
    * @param  fileName
    *         Name of the output PDF file.
    *
    * @return <code>true</code> on success.
    */
   @Override
   public logical exportReportPdf(character fileName)
   {
      return exportReport(fileName, MediaType.PDF);
   }

   /**
    * Run report end export result to the XLS file.
    *
    * @param  fileName
    *         Name of the output XLS file.
    *
    * @return <code>true</code> on success.
    */
   @Override
   public logical exportReportXls(String fileName)
   {
      return exportReportXls(new character(fileName));
   }

   /**
    * Run report end export result to the XLS file.
    *
    * @param  fileName
    *         Name of the output XLS file.
    *
    * @return <code>true</code> on success.
    */
   @Override
   public logical exportReportXls(character fileName)
   {
      return exportReport(fileName, MediaType.XLS);
   }

   /**
    * Run report end export result to the XLSX file.
    *
    * @param  fileName
    *         Name of the output XLSX file.
    *
    * @return <code>true</code> on success.
    */
   @Override
   public logical exportReportXlsx(String fileName)
   {
      return exportReportXlsx(new character(fileName));
   }

   /**
    * Run report end export result to the XLSX file.
    *
    * @param  fileName
    *         Name of the output XLSX file.
    *
    * @return <code>true</code> on success.
    */
   @Override
   public logical exportReportXlsx(character fileName)
   {
      return exportReport(fileName, MediaType.XLSX);
   }

   /**
    * Run report end export result to the RTF file.
    *
    * @param  fileName
    *         Name of the output RTF file.
    *
    * @return <code>true</code> on success.
    */
   @Override
   public logical exportReportRtf(String fileName)
   {
      return exportReportRtf(new character(fileName));
   }

   /**
    * Run report end export result to the RTF file.
    *
    * @param  fileName
    *         Name of the output RTF file.
    *
    * @return <code>true</code> on success.
    */
   @Override
   public logical exportReportRtf(character fileName)
   {
      return exportReport(fileName, MediaType.RTF);
   }

   /**
    * Run report end export result to the DOCX file.
    *
    * @param  fileName
    *         Name of the output DOCX file.
    *
    * @return <code>true</code> on success.
    */
   @Override
   public logical exportReportDocx(String fileName)
   {
      return exportReportDocx(new character(fileName));
   }

   /**
    * Run report end export result to the DOCX file.
    *
    * @param  fileName
    *         Name of the output DOCX file.
    *
    * @return <code>true</code> on success.
    */
   @Override
   public logical exportReportDocx(character fileName)
   {
      return exportReport(fileName, MediaType.DOCX);
   }
   
   /**
    * Run report end export result to the CSV file.
    *
    * @param  fileName
    *         Name of the output CSV file.
    *
    * @return <code>true</code> on success.
    */
   @Override
   public logical exportReportCsv(String fileName)
   {
      return exportReportCsv(new character(fileName));
   }

   /**
    * Run report end export result to the CSV file.
    *
    * @param  fileName
    *         Name of the output CSV file.
    *
    * @return <code>true</code> on success.
    */
   @Override
   public logical exportReportCsv(character fileName)
   {
      return exportReport(fileName, MediaType.CSV);
   }

   /**
    * Import CSV file as data source for report generation.
    *
    * @param  fileName
    *         Name of the output CSV file.
    *
    * @return <code>true</code> on success.
    */
   @Override
   public logical importCsvDataSource(character fileName)
   {
      return importCsvDataSource(fileName.toStringMessage());
   }

   /**
    * Import CSV file as data source for report generation.
    *
    * @param  fileName
    *         Name of the output CSV file.
    *
    * @return <code>true</code> on success.
    */
   @Override
   public logical importCsvDataSource(String fileName)
   {
      return new logical(true);
   }

   /**
    * Gets the total coluns range of the report sheet.
    *
    * @param  sheetName
    *         The name of the sheet to inspect.
    *
    * @return The total columns count.
    */
   @Override
   public integer getSheetRangeColumns(character sheetName)
   {
      return getSheetRangeColumns(sheetName.toStringMessage());
   }
   
   /**
    * Gets the total coluns range of the report sheet.
    *
    * @param  sheetName
    *         The name of the sheet to inspect.
    *
    * @return The total columns count.
    */
   @Override
   public integer getSheetRangeColumns(String sheetName)
   {
      int iRet = 0;
      
      return new integer(iRet);
   }
   
   /**
    * Gets the total rows range of the report sheet.
    *
    * @param  sheetName
    *         The name of the sheet to inspect.
    *
    * @return The total rows count.
    */
   @Override
   public integer getSheetRangeRows(character sheetName)
   {
      return getSheetRangeRows(sheetName.toStringMessage());
   }
   
   /**
    * Gets the total rows range of the report sheet.
    *
    * @param  sheetName
    *         The name of the sheet to inspect.
    *
    * @return The total rows count.
    */
   @Override
   public integer getSheetRangeRows(String sheetName)
   {
      int iRet = 0;
      
      return new integer(iRet);
   }

   /**
    * Starts to prepare data for new chart.
    */
   @Override
   public void addChart()
   {
   }
   
   /**
    * Gets the total rows range of the report sheet.
    *
    * @param  pictName
    *         The name of the picture file.
    * @param  x
    *         X position of the picture.
    * @param  y
    *         Y position of the picture.
    * @param  w
    *         Width of the picture.
    * @param  h
    *         Height of the picture.
    */
   @Override
   public void chartAddPicture(character pictName, int x, int y, int w, int h)
   {
      chartAddPicture(pictName.toStringMessage(), x, y, w, h);
   }
   
   /**
    * Gets the total rows range of the report sheet.
    *
    * @param  pictName
    *         The name of the picture file.
    * @param  x
    *         X position of the picture.
    * @param  y
    *         Y position of the picture.
    * @param  w
    *         Width of the picture.
    * @param  h
    *         Height of the picture.
    */
   @Override
   public void chartAddPicture(String pictName, int x, int y, int w, int h)
   {
      // TODO: Implement this
   }

   /**
    * Run report end export result to the HTML file.
    *
    * @param  fileName
    *         Name of the output HTML file.
    *
    * @return <code>true</code> on success.
    */
   @Override
   public logical exportReportHtml(String fileName)
   {
      return exportReportHtml(new character(fileName));
   }

   /**
    * Run report end export result to the HTML file.
    *
    * @param  fileName
    *         Name of the output HTML file.
    *
    * @return <code>true</code> on success.
    */
   @Override
   public logical exportReportHtml(character fileName)
   {
      return exportReport(fileName, MediaType.HTML);
   }

   /**
    * Set report design file.
    *
    * @param designFileName
    *        Name of the report design file.
    */
   @Override
   public void setReportDesign(character designFileName)
   {
      if (designFileName.isUnknown() || designFileName.getValue().isEmpty())
      {
         showError("Report design file name cannot be an unknown value or empty string.");
         return;
      }

      this.designFileName = designFileName.getValue();
   }

   /**
    * Set report design file.
    *
    * @param designFileName
    *        Name of the report design file.
    */
   @Override
   public void setReportDesign(String designFileName)
   {
      setReportDesign(new character(designFileName));
   }

   /**
    * Get the name of the report design file.
    *
    * @return the name of the report design file.
    */
   @Override
   public character getReportDesign()
   {
      return new character(designFileName);
   }

   /**
    * Set the data source used by this report.
    *
    * @param dataSource
    *        Handle to the data source.
    */
   @Override
   public void setReportDataSource(handle dataSource)
   {
      Object resource = dataSource.get();

      if (resource instanceof JasperDataSource)
      {
         this.dataSource = (JasperDataSource) resource;
      }
      else if (resource instanceof QueryWrapper)
      {
         this.dataSource = new QueryJasperDataSource((QueryWrapper) resource);
      }
      else if (resource instanceof BrowseWidget)
      {
         BrowseWidget browse = (BrowseWidget) resource;
         // designFileName will be set later when we know the output format
         this.dataSource = new BrowseJasperDataSource(browse);
      }
      else
      {
         showError(String.format(
               "Invalid handle of type %s was was passed an report's DATA-SOURCE attribute",
               ((HandleResource) resource).type()));
      }
   }

   /**
    * Set the query used as the data source by this report.
    *
    * @param dataSource
    *        Handle to the query used as the data source by this report.
    */
   @Override
   public void setReportDataSource(QueryWrapper dataSource)
   {
      setReportDataSource(new handle(dataSource));
   }

   /**
    * Get the data source used by this report.
    *
    * @return handle to the data source used by this report.
    */
   @Override
   public handle getReportDataSource()
   {
      return dataSource.asProgressObject();
   }

   /**
    * Reports if this object is valid for use.
    *
    * @return   always <code>true</code>.
    */
   @Override
   public boolean valid()
   {
      return true;
   }

   /**
    * Reports if this object is unknown.
    *
    * @return   always <code>false</code>.
    */
   @Override
   public boolean unknown()
   {
      return false;
   }

   /**
    * Get this resource's ID, if is already set.
    * <p>
    * This is for internal usage only.  If you need the resource ID, use
    * {@link handle#resourceId}, which will compute it and save it at the resource, too.
    *
    * @return   The resource's ID or <code>null</code> if not set.
    */
   @Override
   public Long id()
   {
      return id;
   }

   /**
    * Set this resource's ID.
    *
    * @param    id
    *           The resource's ID.
    */
   @Override
   public void id(long id)
   {
      this.id = id;
   }

   /**
    * Run report end export result using the specified format to the specified file.
    *
    * @param  outputFileName
    *         Name of the output file.
    * @param  outputFormat
    *         Output format (e.g. PDF).
    *
    * @return <code>true</code> on success.
    */
   @Override
   public logical exportReport(character outputFileName, MediaType outputFormat)
   {
      if (designFileName == null)
      {
         showError("Cannot execute report: REPORT-DESIGN is not set.");
         return new logical(false);
      }
      
      if (dataSource == null)
      {
         showError("Cannot execute report: REPORT-DATA-SOURCE is not set.");
         return new logical(false);
      }
      
      if (outputFileName.isUnknown())
      {
         showError(String.format("Output file name is not specified for report %s.",
                                 designFileName));
         return new logical(false);
      }
      
      // load report file
      JasperReport report = dataSource.getReport(designFileName);
      if (report == null)
      {
         return new logical(false);
      }
      
      // convert Report parameters from 4GL to Jasper data types
      Map<String, Object> jasperParameters = new HashMap<>();
      if (parameters != null)
      {
         for (JRParameter parameter : report.getParameters())
         {
            String parameterName = parameter.getName();
            BaseDataType progressValue = parameters.get(parameterName);
            if (progressValue != null)
            {
               try
               {
                  jasperParameters.put(parameterName, getJavaValue(progressValue,
                                                                   parameter.getValueClass()));
               }
               catch (IllegalArgumentException e)
               {
                  showError("Invalid data type of report parameter " + parameterName, e);
                  return new logical(false);
               }
            }
         }
      }

      if (!dataSource.reportStarted(report))
      {
         return new logical(false);
      }
      
      // fill report with data
      boolean error = false;
      try
      {
         JasperPrint jasperPrint;
         try
         {
            jasperPrint = JasperFillManager.fillReport(report, jasperParameters, dataSource);
         }
         catch (JRException e)
         {
            error = true;
            showError(String.format("Cannot fill report %s", designFileName), e);
            return new logical(false);
         }
         catch (JasperReportException jre)
         {
            error = true;
            showError(String.format("Internal error for the report %s", designFileName), jre);
            return new logical(false);
         }

         // if columns overflowed the page width, we need to split each page
         String colNoProperty = report.getProperty("colNo");
         if (colNoProperty != null)
         {
            int colNo = Integer.parseInt(colNoProperty);
            int splitsPerPage = Integer.parseInt(report.getProperty("splitsPerPage"));
            List<JRPrintPage> oldPages = jasperPrint.getPages();
            List<JRPrintPage> newPages = splitPages(oldPages, colNo, splitsPerPage);

            oldPages.clear();
            oldPages.addAll(newPages);
         }

         // export report
         String output = outputFileName.getValue();
         Stream reportStream;
         try
         {
            reportStream = StreamFactory.openFileStream(output, true, false);
         }
         catch (ErrorConditionException e)
         {
            error = true;
            showError(String.format("Cannot export report stream for file %s", output), e);
            return new logical(false);
         }

         OutputStream streamWrapper =
               new BufferedOutputStream(new OutputStreamWrapper(reportStream), OUTPUT_BUFFER_SIZE);
         try
         {
            Exporter exporter;
            switch (outputFormat)
            {
               case CSV:
                  exporter = new JRCsvExporter();
                  exporter.setExporterOutput(new SimpleWriterExporterOutput(streamWrapper));
                  break;

               case DOCX:
                  exporter = new JRDocxExporter();
                  exporter.setExporterOutput(new SimpleOutputStreamExporterOutput(streamWrapper));
                  break;

               case HTML:
                  exporter = new HtmlExporter();
                  exporter.setExporterOutput(new SimpleHtmlExporterOutput(streamWrapper));
                  break;

               case PDF:
                  exporter = new JRPdfExporter();
                  exporter.setExporterOutput(new SimpleOutputStreamExporterOutput(streamWrapper));
                  break;

               case RTF:
                  exporter = new JRRtfExporter();
                  exporter.setExporterOutput(new SimpleWriterExporterOutput(streamWrapper));
                  break;

               case XLS:
                  exporter = new JRXlsExporter();
                  exporter.setExporterOutput(new SimpleOutputStreamExporterOutput(streamWrapper));
                  break;

               case XLSX:
                  exporter = new JRXlsxExporter();
                  exporter.setExporterOutput(new SimpleOutputStreamExporterOutput(streamWrapper));
                  break;

               default:
                  throw new IllegalArgumentException("Invalid export format " + outputFormat);
            }
            exporter.setExporterInput(new SimpleExporterInput(jasperPrint));
            exporter.exportReport();
         }
         catch (JRException e)
         {
            error = true;
            showError(String.format("Cannot export report %s into %s",
                  designFileName, output), e);
            return new logical(false);
         }
         finally
         {
            try
            {
               streamWrapper.flush();
               streamWrapper.close();
            }
            catch (IOException e)
            {
               if (!error)
               {
                  error = true;
                  showError(String.format("Cannot flush report %s into %s",
                        designFileName, output), e);
                  return new logical(false);
               }
            }
         }
      }
      finally
      {
         dataSource.reportFinished(error);
      }

      return new logical(true);
   }


   /**
    * Splits every given page into multiple ones. A fresh page will be created by moving the column
    * and data cells until it detects that the X coordinate goes back to the start of the page.
    * The X coordinate need to be already arranged before the splitting is performed because the method
    * will just copy them as they are to new pages.<p>
    * Used primarily when there are too many columns in a PDF report, being an alternative to shrinking
    * down the columns in order for all to fit, which can make the cells unintelligible.
    *
    * @param  pages
    *         The pages which will be split.
    * @param  colNo
    *         The number of columns for the data source. Used to iterate through the data cells.
    * @param  splitsPerPage
    *         Represents how many times we need to create a fresh page due to a split.
    *
    * @return A list with all the fresh pages created.
    */
   private List<JRPrintPage> splitPages(List<JRPrintPage> pages, int colNo, int splitsPerPage)
   {
      List<JRPrintPage> newPages = new ArrayList<>();
      if (pages == null || pages.isEmpty())
      {
         return newPages;
      }

      if (colNo == 1)
      {
         return pages;
      }

      int startRow = 1;
      int columnStart;
      for (int i = 0, len = pages.size(); i < len; i++)
      {
         JRPrintPage page = pages.get(i);
         List<JRPrintPage> freshPages = splitPage(page, i, colNo, len, splitsPerPage, startRow);
         if (freshPages != null)
         {
            newPages.addAll(freshPages);
         }

         columnStart = i == 0 ? 1 : 0;
         startRow += (page.getElements().size() - columnStart - colNo - 2) / colNo;
      }

      return newPages;
   }

   /**
    * Actual implementation of splitting a single page into multiple ones.
    *
    * @param  page
    *         The page which will be split.
    * @param  pageNo
    *         The number of the page from the original report. If <code>0</code>,
    *         it will assume the first element is a report title, and it will include it.
    * @param  colNo
    *         The number of columns for the data source. Used to iterate through the data cells.
    * @param  oldPagesNumber
    *         The size (pages number) of the report from which the page was taken. Used to customize the
    *         pagination footer.
    * @param  splitsPerPage
    *         Represents how many times we need to create a fresh page due to a split. Used to customize
    *         the pagination footer.
    * @param  startRow
    *         The 1-indexed number of the first row present in the page. Used to customize the pagination
    *         footer.
    *
    * @return A list with the fresh pages created, or <code>null</code> if the page is null or empty,
    *         or the column number is less or equal than 1 which means the page can't be split.
    */
   private List<JRPrintPage> splitPage(JRPrintPage page, int pageNo, int colNo,
                                       int oldPagesNumber, int splitsPerPage, int startRow)
   {
      if (page == null)
      {
         return null;
      }

      List<JRPrintElement> oldElements = page.getElements();
      if (pageNo < 0 || oldElements == null || oldElements.isEmpty() || colNo <= 1)
      {
         return null;
      }

      List<JRPrintPage> newPages = new ArrayList<>();
      JRPrintPage newPage = new JRBasePrintPage();

      List<JRPrintElement> newElements = newPage.getElements();
      int oldElementsSize = oldElements.size();
      int columnStart = 0;

      if (pageNo == 0)
      {
         // add the report title
         columnStart = 1;
         newElements.add(oldElements.get(0));
      }

      int totalPageNumber = (splitsPerPage + 1) * oldPagesNumber;
      int currentPageNumber = (splitsPerPage + 1) * pageNo + 1;

      for (int i = columnStart; i < colNo + columnStart; i++)
      {
         JRPrintElement currentElement = oldElements.get(i);

         int currentX = currentElement.getX();
         int nextX = oldElements.get(i + 1).getX();

         while (currentX < nextX)
         {
            // add the current column + all the data cells for this column
            newElements.add(currentElement);
            int index = colNo + i;
            // when we reach at the last 2 page elements which are considered to be the page numbers, exit
            while (oldElementsSize - index > 2)
            {
               newElements.add(oldElements.get(index));
               index += colNo;
            }

            if (++i == colNo + columnStart)
            {
               // we reached at the last column
               break;
            }
            else
            {
               currentX = nextX;
               currentElement = oldElements.get(i);
               nextX = oldElements.get(i + 1).getX();
            }
         }

         if (i != colNo + columnStart)
         {
            // we reached at the split point, add the last column + data cells to the page
            newElements.add(currentElement);
            int index = colNo + i;
            while (oldElementsSize - index > 2)
            {
               newElements.add(oldElements.get(index));
               index += colNo;
            }
         }

         // clone and customize the footer element
         int endRow = startRow + (oldElementsSize - columnStart - colNo - 2) / colNo - 1;
         String pageInfo = totalPageNumber + ", Rows " + startRow + " to " + endRow;

         JRTemplatePrintText oldFooterElement = (JRTemplatePrintText) oldElements.get(oldElementsSize - 2);
         JRTemplatePrintText newFooterElement = cloneTextElementCore(oldFooterElement);

         String customPage = "Page " + currentPageNumber++ + " of " + pageInfo;

         newFooterElement.setText(customPage);

         newElements.add(newFooterElement);
         newPages.add(newPage);

         newPage = new JRBasePrintPage();
         newElements = newPage.getElements();
      }

      return newPages;
   }

   /**
    * Clones only the core fields from a text element. Properties such as hyperlinks are omitted.
    *
    * @param  element
    *         The element to be cloned.
    *
    * @return A partial copy with only the core fields set.
    */
   private JRTemplatePrintText cloneTextElementCore(JRTemplatePrintText element)
   {
      JRTemplatePrintText newElement = new JRTemplatePrintText();

      newElement.setText(element.getFullText());
      newElement.setValue(element.getValue());
      newElement.setLineSpacingFactor(element.getLineSpacingFactor());
      newElement.setLeadingOffset(element.getLeadingOffset());
      newElement.setRunDirection(element.getRunDirectionValue());
      newElement.setTextHeight(element.getTextHeight());
      newElement.setTemplate(element.getTemplate());
      newElement.setX(element.getX());
      newElement.setY(element.getY());
      newElement.setHeight(element.getHeight());
      newElement.setWidth(element.getWidth());
      newElement.setSourceElementId(element.getSourceElementId());
      newElement.setAnchorName(element.getAnchorName());

      return newElement;
   }
}