GUIPrinterStreamSupport.java

/*
** Module   : GUIPrinterStreamSupport.java
** Abstract : Implementation of GUI printer stream.
**
** Copyright (c) 2017-2023, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------Description----------------------------------
** 001 HC  20171024 Initial version.
** 002 ECF 20171030 Added write(byte[], int, int).
** 003 CA  20180210 Renamed deliverPDFPrintOutput to deliverPrintOutput and added report format 
**                  parameter.
** 004 SBI 20180410 Changed closeStream() due to deliverPrintOutput() was renamed to
**                  deliverDocumentOutput().
** 005 OM  20181001 Tracked document output using an UUID.
** 006 HC  20190104 Javadoc fixes.
** 007 EVL 20220325 Base refactoring for Stream based classes getting single byte and array of bytes.
** 008 SBI 20220105 Fixed javadoc references.
** 009 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
*/

/*
** 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.util;

import com.goldencode.p2j.ui.chui.*;
import com.goldencode.p2j.ui.client.*;
import com.goldencode.p2j.ui.client.gui.driver.*;
import com.goldencode.p2j.ui.client.gui.driver.web.*;

import com.goldencode.p2j.util.logging.*;
import org.apache.pdfbox.pdmodel.*;
import org.apache.pdfbox.pdmodel.common.*;
import org.apache.pdfbox.pdmodel.font.*;
import org.apache.pdfbox.pdmodel.font.encoding.*;

import java.io.*;
import java.util.*;
import java.util.function.*;
import java.util.logging.*;

/**
 * The class implements all the necessary logic behind the GUI printer streams and servers as the
 * core of the runtime support of the 4GL statement OUTPUT TO PRINTER.
 * <p>
 * The expected flow of the print outputs in the Web GUI driver is as follows:
 * <ol>
 * <li>OUTPUT TO PRINTER... PUT... and stream close creates PDF output data
 * <li>The data is delivered to the driver with
 *    {@link GuiDriver#deliverDocumentOutput(Consumer, Runnable, MediaType, String, boolean)}.
 * <li>The driver stores the delivered PDF data with
 *    {@link GuiWebDriver#enqueueDocumentOutput(DocumentOutput)}.
 * <li>The driver notifies the JS client with MSG_OPEN_MIME_RESOURCE web socket message.
 * <li>JS client reads the print output id, constructs the print output URL with the id from
 *    the message and opens new browser window pointing it to the URL.
 * <li>{@link DocumentOutputHandler} catches the request, it calls
 *    {@link GuiWebDriver#dequeueDocumentOutput(UUID)} and serves the PDF data to the JS client.
 *    When there is any error during this step (for example an intermittent network error)
 *    the handler returns the PDF output to the storage with
 *    {@link GuiWebDriver#enqueueDocumentOutput(DocumentOutput)}.
 * </ol>
 */
public class GUIPrinterStreamSupport
{
   /** Logger */
   private static final CentralLogger LOG = CentralLogger.get(GUIPrinterStreamSupport.class.getName());

   /** The PDF user-space line height */
   private static final float LINE_SPACING = 1f;

   /** The print options in effect */
   private final PrintOptions options;

   /** Printer name passed to OUTPUT TO PRINTER, currently not used */
   private final String printerName;

   /** The font passed to OUTPUT TO PRINTER */
   private int fontNum = -1;

   /** The effective PDFBox font */
   private PDFont font;

   /** The effective font size */
   private float fontSize;

   /** Current output line */
   private StringBuilder curLine;

   /** The page setup in effect */
   private PDFPageSetup pageSetup;

   /** The PDF document object */
   private PDDocument doc;

   /** Current PDF page object */
   private PDPage currPage;

   /** Current PDF content stream */
   private PDPageContentStream curContent;

   // the X coordinate of the origin (upper left corner) of the first line of text
   private float startX;

   // the Y coordinate of the origin (upper left corner) of the first line of text
   private float startY;

   /** The actual printer stream */
   private PrinterStream printerStream;

   /**
    * Ctor.
    *
    * @param   printerName
    *          Printer name.
    * @param   options
    *          Print options.
    */
   private GUIPrinterStreamSupport(String printerName, PrintOptions options)
   {
      this.printerName = printerName;
      this.options = options;
   }

   /**
    * Opens a printer stream and returns its remote id.
    *
    * @param   printer
    *          Printer name.
    * @param   sd
    *          Stream daemon instance.
    * @param   options
    *          Effective print options.
    */
   public static int openPrinterStream(String printer, StreamDaemon sd, PrintOptions options)
   {
      return sd.store(new GUIPrinterStreamSupport(printer, options).asStream());
   }

   /**
    * Returns an instance of {@link Stream} that provides the printer output.
    *
    * @return  See above.
    */
   public Stream asStream()
   {
      if (printerStream == null)
      {
         printerStream = new PrinterStream();
      }

      return printerStream;
   }

   /**
    * Initializes the instance. The method prepares the objects necessary for PDFBox output and starts new
    * page of the resulting PDF document.
    */
   private void init()
   {
      if (doc != null)
      {
         return;
      }

      doc = new PDDocument();

      // resolve print font
      FontDetails fd = null;

      // the font may be defined in the OUTPUT TO PRINTER statement
      if (fontNum >= 0)
      {
         Window currWindow = WindowManager.getCurrentWindow();
         fd = FontManager.getFontDetails(currWindow, fontNum);
      }

      if (fd == null)
      {
         // get the  configured print font when not in OUTPUT
         fd = FontManager.getFontDetails(null, FontManager.PRINT_FONT);
      }

      if (fd != null && fd.fontAlias != null)
      {
         fd = fd.fontAlias;
      }

      if (fd != null && fd.fontDefinition != null)
      {
         // assume true type font data
         try
         {
            font = PDTrueTypeFont.load(doc,
                                       new ByteArrayInputStream(fd.fontDefinition),
                                       WinAnsiEncoding.INSTANCE);
         }
         catch (IOException e)
         {
            LOG.log(Level.WARNING, "Print font failed to load!", e);
         }
      }

      if (font == null)
      {
         // no font data found, fallback to some default
         font = PDType1Font.COURIER;
      }

      fontSize = fd.pointSize;
      pageSetup = PDFPageSetup.fromPrintOptions(options);

      openPage();
   }

   /**
    * Renders a single line to the PDF document and optionally breaks to a new line.
    *
    * @param   addNewline
    *          When <code>true</code> new line is added after the line is rendered to the PDF document.
    */
   private void flushLine(boolean addNewline)
   {
      if (curLine == null)
      {
         return;
      }

      try
      {
         if (curLine.length() > 0)
         {
            curContent.showText(curLine.toString());
            curLine = null;
         }

         if (addNewline)
         {
            curContent.newLine();
         }
      }
      catch (IOException e)
      {
         LOG.warning("", e);
      }
   }

   /**
    * Opens new page in the PDF document.
    */
   private void openPage()
   {
      printerStream.registerPageBreakListener(out -> openPage());

      // close previous page if any
      closePage();

      currPage = new PDPage(pageSetup.pageSize);

      // Note that the right and bottom margins are used to set the correct PDF trim box
      // but don't actually trim the text rendered outside of the trim box.
      float trimWidth  = pageSetup.pageSize.getWidth() - pageSetup.leftMargin - pageSetup.rightMargin;
      float trimHeight = pageSetup.pageSize.getHeight() - pageSetup.topMargin - pageSetup.bottomMargin;
      currPage.setTrimBox(new PDRectangle(pageSetup.leftMargin, pageSetup.topMargin, trimWidth, trimHeight));
      doc.addPage(currPage);

      PDRectangle mediabox = currPage.getMediaBox();
      startX = mediabox.getLowerLeftX() + pageSetup.leftMargin;
      startY = mediabox.getUpperRightY() - pageSetup.topMargin;

      try
      {
         curContent = new PDPageContentStream(doc, currPage, PDPageContentStream.AppendMode.APPEND, true,
                                              true);
         curContent.beginText();
         curContent.setFont(font, fontSize);
         curContent.setLeading(LINE_SPACING * fontSize);
         curContent.newLineAtOffset(startX, startY - fontSize);
      }
      catch (IOException ex)
      {
         LOG.severe("", ex);
      }
   }

   /**
    * Closes current page in the PDF document.
    */
   private void closePage()
   {
      if (curContent == null)
      {
         return;
      }

      try
      {
         flushLine(false);
         curContent.endText();
         curContent.close();
         curContent = null;
      }
      catch (IOException e)
      {
         LOG.warning("", e);
      }
   }

   /**
    * Closes the stream. The method closes the current pahe and sends the PDF data to the screen driver.
    */
   private void closeStream()
   {
      if (doc == null)
      {
         return;
      }

      closePage();

      GuiDriver driver = (GuiDriver) ThinClient.getInstance().getOutputManager().getInstanceDriver();

      Consumer<OutputStream> consumer = outputStream ->
      {
         try
         {
            doc.save(outputStream);
         }
         catch (IOException e)
         {
            throw new RuntimeException(e);
         }
      };

      Runnable finalizer = () ->
      {
         try
         {
            doc.close();
         }
         catch (IOException e)
         {
            throw new RuntimeException(e);
         }
      };
      
      String uuid = UUID.randomUUID().toString();
      driver.deliverDocumentOutput(consumer, finalizer, MediaType.PDF, uuid, false);
   }

   /**
    * The actual printer stream implementation.
    */
   private class PrinterStream
   extends Stream
   {
      /**
       * The number of bytes available to be immediately read without blocking.
       *
       * @return   The number of available bytes.
       *
       * @throws   IOException
       *           if an I/O error occurs.
       */
      @Override
      public long available() throws
                              IOException
      {
         throw new UnsupportedOperationException();
      }

      /**
       * The 0-based offset into the stream at which the next read or write will
       * occur.  This does not work for "streams" that require sequential
       * access.
       *
       * @return   The current position in the stream.
       *
       * @throws   UnsupportedOperationException
       *           If the requested operation is not supported.
       * @throws   IOException
       *           If an I/O error occurs.
       */
      @Override
      public long getPos() throws
                           UnsupportedOperationException,
                           IOException
      {
         throw new UnsupportedOperationException();
      }

      /**
       * Moves the current read/write position to the specified absolute 0-based
       * offset.  A negative offset is ignored (no action is taken).  If the
       * specified offset is larger than the length of the stream, the seek
       * position will report as the number requested, but if no subsequent
       * writes occur to the file, the file is truncated to 0 bytes.  If writes
       * do occur, all writes occur at byte 0 BUT the file actually is of a size
       * that is the requested offset + the number of bytes written!
       * <p>
       * This does not work for "streams" that require sequential access.
       *
       * @param    pos
       *           The new read/write position in the stream.
       *
       * @throws   UnsupportedOperationException
       *           If the requested operation is not supported.
       * @throws   IOException
       *           If an I/O error occurs.
       */
      @Override
      public void setPos(long pos) throws
                                   UnsupportedOperationException,
                                   IOException
      {
         throw new UnsupportedOperationException();
      }

      /**
       * The length of the stream in bytes.  This does not work for "streams"
       * that require sequential access.
       *
       * @return   The length of the stream.
       *
       * @throws   UnsupportedOperationException
       *           If the requested operation is not supported.
       * @throws   IOException
       *           If an I/O error occurs.
       */
      @Override
      public long getLen() throws
                           UnsupportedOperationException,
                           IOException
      {
         throw new UnsupportedOperationException();
      }

      /**
       * Truncates or extends the stream to the specified length if this stream
       * supports such an operation. If truncation is requested, all data
       * located after this point in the file is discarded.  If extending the
       * file is requested, the values of the data in the extended portion of
       * the file is undefined.
       *
       * @param    len
       *           The new length of the file.
       *
       * @throws   UnsupportedOperationException
       *           If the requested operation is not supported.
       * @throws   IOException
       *           If an I/O error occurs.
       */
      @Override
      public void setLen(long len) throws
                                   UnsupportedOperationException,
                                   IOException
      {
         throw new UnsupportedOperationException();
      }

      /**
       * Write the given character to the output stream.
       *
       * @param    ch
       *           The character to be written.
       *
       * @throws   IOException
       *           If an I/O error occurs.
       */
      @Override
      public void writeCh(char ch) throws
                                   IOException
      {
         init();

         if (ch == '\n')
         {
            flushLine(true);
         }
         else if (ch == '\f')
         {
            // ignore
         }
         else
         {
            if (curLine == null)
            {
               curLine = new StringBuilder();
            }

            curLine.append(ch);
         }
      }

      /**
       * Write the given byte to the output stream.
       *
       * @param    b
       *           The byte to be written.
       *
       * @throws   IOException
       *           If an I/O error occurs.
       */
      @Override
      public void writeByte(byte b) throws
                                    IOException
      {
         throw new UnsupportedOperationException();
      }

      /**
       * Write the given string to the output stream.
       *
       * @param    data
       *           The data to be written.
       *
       * @throws   IOException
       *           If an I/O error occurs.
       */
      @Override
      public void write(String data) throws
                                     IOException
      {
         char[] ch = data.toCharArray();
         for (int i = 0; i < ch.length; i++)
         {
            writeCh(ch[i]);
         }
      }

      /**
       * Write the given byte array to the output stream.
       *
       * @param    data
       *           The data to be written.
       *
       * @throws   IOException
       *           If an I/O error occurs.
       */
      @Override
      public void write(byte[] data) throws
                                     IOException
      {
         throw new UnsupportedOperationException();
      }

      /**
       * Write the specified range of bytes from the given byte array to the output stream.
       *
       * @param    data
       *           The data to be written.
       * @param    off
       *           Starting offset in data from which to read bytes to be written. Must be
       *           non-negative and {@code &lt; data.length}.
       * @param    len
       *           Length of data to be written. Must be non-negative and {@code &lt;=
       *           (data.length - offset)}.
       *
       * @throws   IOException
       *           If an I/O error occurs.
       */
      public  void write(byte[] data, int off, int len)
      throws IOException
      {
         throw new UnsupportedOperationException();
      }

      /**
       * Peeks at the character from the current read position in the stream (reads a character from
       * the current read position in the stream without incrementing stream read position. The next
       * {@code peekCh()} and {@code readCh()} will return the same value).
       * <p>
       * The underlying stream subclass determines the content of the result. Byte oriented streams
       * such as pipes or files will return a byte while streams that generate keystrokes or
       * characters may return a DBCS or Unicode character.
       *
       * @return  The next character read from the stream, -1 on any failure and -2 upon an
       *          {@code EOF}.
       */
      @Override
      public int peekCh()
      {
         throw new UnsupportedOperationException();
      }

      /**
       * Read a character from the current read position in the stream and increment stream pointer.
       * <p>
       * The underlying stream subclass determines the content of the result.
       * Byte oriented streams such as pipes or files will return a byte while
       * streams that generate keystrokes or characters may return a DBCS or
       * Unicode character.
       *
       * @return   The next character read from the stream, -1 on any failure
       *           and -2 upon an <code>EOF</code>.
       */
      @Override
      public int readCh()
      {
         throw new UnsupportedOperationException();
      }

      /**
       * Read a single byte from the underlying stream.
       * 
       * @return   A single byte from the stream, -1 on any failure and -2 upon an {@code EOF}.
       */
      @Override
      public int readByte()
      {
         return -2;
      }
   
      /**
       * Read all characters from the current read position in the stream to the
       * next line separator (as determined by the <code>File.separator</code>
       * or to the <code>EOF</code>. Any line separator character(s) and the
       * <code>EOF</code> character are not returned.
       *
       * @return   The next line read from the stream.
       *
       * @throws   IOException
       *           If an I/O error occurs.
       * @throws   EOFException
       *           If this input stream reaches the end before reading all the bytes.
       * @throws   InterruptedException
       *           If any thread interrupted the current thread before or while
       *           the current thread was waiting for a notification.
       */
      @Override
      public String readLn() throws
                             EOFException,
                             IOException,
                             InterruptedException
      {
         throw new UnsupportedOperationException();
      }

      /**
       * Closes the input stream and releases OS resources associated with it.
       */
      @Override
      public void closeIn()
      {
         throw new UnsupportedOperationException();
      }

      /**
       * Closes the output stream and releases OS resources associated with it.
       */
      @Override
      public void closeOut()
      {
         closeStream();
      }

      /**
       * Closes both the input and output streams and releases OS resources
       * associated with it.
       */
      @Override
      public void close()
      {
         closeStream();
      }

      /**
       * State of the input side of the stream.
       *
       * @return   <code>true</code> if the input side of the stream is active.
       */
      @Override
      public boolean isIn()
      {
         return false;
      }

      /**
       * State of the output side of the stream.
       *
       * @return   <code>true</code> if the output side of the stream is active.
       */
      @Override
      public boolean isOut()
      {
         return true;
      }

      /**
       * Assigns the internal stream reference to the given reference.
       *
       * @param    stream
       *           The new internal stream reference to use for all operations.
       *
       * @throws   UnsupportedOperationException
       *           If the requested operation is not supported.
       */
      @Override
      public void assign(Stream stream)
      throws
      UnsupportedOperationException
      {
         throw new UnsupportedOperationException();
      }

      /**
       * Set stream display mode to landscape
       */
      @Override
      public void setLandscape()
      {
         super.setLandscape();
         options.pageOrientation = PrintOptions.PageOrientation.LANDSCAPE;
      }

      /**
       * Set stream display mode to portrait
       */
      @Override
      public void setPortrait()
      {
         super.setPortrait();
         options.pageOrientation = PrintOptions.PageOrientation.PORTRAIT;
      }

      /**
       * Sets the stream font number. The number denotes an index into the font table
       * of the current environment.
       * The method is part of the runtime support of the FONT option of the OUTPUT TO PRINTER
       * (FWD extension to 4GL) statement. This method is only implemented by the printer stream.
       *
       * @param   num
       *          A font number.
       */
      @Override
      public void setFont(int num)
      {
         fontNum = num;
      }
   }
}