contentDrawnListenerList = new ArrayList<>();
+
+ /**
+ * Constructs a cell with the default alignment
+ * {@link VerticalAlignment#TOP} {@link HorizontalAlignment#LEFT}.
+ * @see Cell#Cell(Row, float, String, boolean, HorizontalAlignment,
+ * VerticalAlignment)
+ */
+ Cell(final Row row, final float width, final String text, final boolean isCalculated) {
+ this(row, width, text, isCalculated, HorizontalAlignment.LEFT, VerticalAlignment.TOP);
+ }
+
+ /**
+ * Constructs a cell.
+ * @param row
+ * The parent row
+ * @param width
+ * absolute width in points or in % of table width (depending on
+ * the parameter {@code isCalculated})
+ * @param text
+ * The text content of the cell
+ * @param isCalculated
+ * If {@code true}, the width is interpreted in % to the table
+ * width
+ * @param align
+ * The {@link HorizontalAlignment} of the cell content
+ * @param valign
+ * The {@link VerticalAlignment} of the cell content
+ * @see Cell#Cell(Row, float, String, boolean)
+ */
+ Cell(final Row row, final float width, final String text, final boolean isCalculated, final HorizontalAlignment align,
+ final VerticalAlignment valign) {
+ this.row = row;
+ if (isCalculated) {
+ double calculatedWidth = row.getWidth() * (width / 100);
+ this.width = (float) calculatedWidth;
+ } else {
+ this.width = width;
+ }
+
+ if (getWidth() > row.getWidth()) {
+ throw new IllegalArgumentException(
+ "Cell Width=" + getWidth() + " can't be bigger than row width=" + row.getWidth());
+ }
+ //check if we have new default font
+ if(!FontUtils.getDefaultfonts().isEmpty()){
+ font = FontUtils.getDefaultfonts().get("font");
+ fontBold = FontUtils.getDefaultfonts().get("fontBold");
+ }
+ this.text = text == null ? "" : text;
+ this.align = align;
+ this.valign = valign;
+ this.wrappingFunction = null;
+ }
+
+ /**
+ * Returns cell's width without (left,right) padding.
+ */
+ public float getInnerWidth() {
+ return getWidth() - getLeftPadding() - getRightPadding()
+ - (leftBorderStyle == null ? 0 : leftBorderStyle.getWidth())
+ - (rightBorderStyle == null ? 0 : rightBorderStyle.getWidth());
+ }
+
+ /**
+ * Returns cell's height without (top,bottom) padding.
+ */
+ public float getInnerHeight() {
+ return getHeight() - getBottomPadding() - getTopPadding()
+ - (topBorderStyle == null ? 0 : topBorderStyle.getWidth())
+ - (bottomBorderStyle == null ? 0 : bottomBorderStyle.getWidth());
+ }
+
+ /**
+ * Sets cell's text value
+ * @param text
+ * Text value of the cell
+ */
+ public void setText(final String text) {
+ this.text = text;
+
+ // paragraph invalidated
+ paragraph = null;
+ }
+
+ /**
+ * Returns appropriate {@link PDFont} for current cell.
+ * @throws IllegalArgumentException
+ * if font
is not set.
+ */
+ public PDFont getFont() {
+ if (font == null) throw new IllegalArgumentException("Font not set.");
+ return isHeaderCell() ? fontBold : font;
+ }
+
+ /**
+ * Sets appropriate {@link PDFont} for current cell.
+ * @param font
+ * {@link PDFont} for current cell
+ */
+ public void setFont(final PDFont font) {
+ this.font = font;
+
+ // paragraph invalidated
+ paragraph = null;
+ }
+
+ /**
+ * Sets {@link PDFont} size for current cell (in points).
+ * @param fontSize
+ * {@link PDFont} size for current cell (in points).
+ */
+ public void setFontSize(final float fontSize) {
+ this.fontSize = fontSize;
+
+ // paragraph invalidated
+ paragraph = null;
+ }
+
+ /**
+ * Retrieves a valid {@link Paragraph} depending of cell's {@link PDFont}
+ * and value rotation.
+ *
+ * If cell has rotated value then {@link Paragraph} width is depending of
+ * {@link Cell#getInnerHeight()} otherwise {@link Cell#getInnerWidth()}
+ * @return Cell's {@link Paragraph}
+ */
+ public Paragraph getParagraph() {
+ if (paragraph == null) {
+ // if it is header cell then use font bold
+ if (isHeaderCell()) {
+ if (isTextRotated()) {
+ paragraph = new Paragraph(text, fontBold, fontSize, getInnerHeight(), align, textColor, null,
+ wrappingFunction, lineSpacing);
+ } else {
+ paragraph = new Paragraph(text, fontBold, fontSize, getInnerWidth(), align, textColor, null,
+ wrappingFunction, lineSpacing);
+ }
+ } else {
+ if (isTextRotated()) {
+ paragraph = new Paragraph(text, font, fontSize, getInnerHeight(), align, textColor, null,
+ wrappingFunction, lineSpacing);
+ } else {
+ paragraph = new Paragraph(text, font, fontSize, getInnerWidth(), align, textColor, null,
+ wrappingFunction, lineSpacing);
+ }
+ }
+ }
+ return paragraph;
+ }
+
+ public float getExtraWidth() {
+ return this.row.getLastCellExtraWidth() + getWidth();
+ }
+
+ /**
+ * Returns the cell's height according to {@link Row}'s height
+ */
+ public float getHeight() {
+ return row.getHeight();
+ }
+
+ /**
+ * Gets the height of the single cell, opposed to {@link #getHeight()},
+ * which returns the row's height.
+ *
+ * Depending of rotated/normal cell's value there is two cases for
+ * calculation:
+ *
+ * - Rotated value - cell's height is equal to overall text length in the
+ * cell with necessery paddings (top,bottom)
+ * - Normal value - cell's height is equal to {@link Paragraph}'s height
+ * with necessery paddings (top,bottom)
+ *
+ * @return Cell's height
+ * @throws IllegalStateException
+ * if font
is not set.
+ */
+ public float getCellHeight() {
+ if (height != null) {
+ return height;
+ }
+
+ if (isTextRotated()) {
+ try {
+ // TODO: maybe find more optimal way then this
+ return getFont().getStringWidth(getText()) / 1000 * getFontSize() + getTopPadding()
+ + (getTopBorderStyle() == null ? 0 : getTopBorderStyle().getWidth()) + getBottomPadding()
+ + (getBottomBorderStyle() == null ? 0 : getBottomBorderStyle().getWidth());
+ } catch (final IOException e) {
+ throw new IllegalStateException("Font not set.", e);
+ }
+ } else {
+ return getTextHeight() + getTopPadding() + getBottomPadding()
+ + (getTopBorderStyle() == null ? 0 : getTopBorderStyle().getWidth())
+ + (getBottomBorderStyle() == null ? 0 : getBottomBorderStyle().getWidth());
+ }
+ }
+
+ /**
+ * Returns {@link Paragraph}'s height
+ */
+ public float getTextHeight() {
+ return getParagraph().getHeight();
+ }
+
+ /**
+ * Returns {@link Paragraph}'s width
+ */
+ public float getTextWidth() {
+ return getParagraph().getWidth();
+ }
+
+ /**
+ * Returns free vertical space of cell.
+ *
+ * If cell has rotated value then free vertical space is equal inner cell's
+ * height ({@link #getInnerHeight()}) subtracted to the longest line of
+ * rotated {@link Paragraph} otherwise it's just cell's inner height (
+ * {@link #getInnerHeight()}) subtracted with width of the normal
+ * {@link Paragraph}.
+ */
+ public float getVerticalFreeSpace() {
+ if (isTextRotated()) {
+ // need to calculate max line width so we just iterating through all lines
+ getParagraph().getLines().forEach(line->{});
+ return getInnerHeight() - getParagraph().getMaxLineWidth();
+ } else {
+ return getInnerHeight() - getTextHeight();
+ }
+ }
+
+ /**
+ * Returns free horizontal space of cell.
+ *
+ * If cell has rotated value then free horizontal space is equal cell's
+ * inner width ({@link #getInnerWidth()}) subtracted to the
+ * {@link Paragraph}'s height otherwise it's just cell's
+ * {@link #getInnerWidth()} subtracted with width of longest line in normal
+ * {@link Paragraph}.
+ */
+ public float getHorizontalFreeSpace() {
+ if (isTextRotated()) {
+ return getInnerWidth() - getTextHeight();
+ } else {
+ return getInnerWidth() - getParagraph().getMaxLineWidth();
+ }
+ }
+
+
+ public WrappingFunction getWrappingFunction() {
+ return getParagraph().getWrappingFunction();
+ }
+
+ public void setWrappingFunction(final WrappingFunction wrappingFunction) {
+ this.wrappingFunction = wrappingFunction;
+
+ // paragraph invalidated
+ paragraph = null;
+ }
+
+ /**
+ * Easy setting for cell border style.
+ * @param border
+ * It is {@link LineStyle} for all borders
+ * @see LineStyle Rendering line attributes
+ */
+ public void setBorderStyle(final LineStyle border) {
+ this.leftBorderStyle = border;
+ this.rightBorderStyle = border;
+ this.topBorderStyle = border;
+ this.bottomBorderStyle = border;
+ }
+
+ /**
+ * Copies the style of an existing cell to this cell
+ * @param sourceCell Source {@link Cell} from which cell style will be copied.
+ */
+ public void copyCellStyle(final Cell sourceCell) {
+ Boolean leftBorder = this.leftBorderStyle == null;
+ setBorderStyle(sourceCell.getTopBorderStyle());
+ if (leftBorder) {
+ this.leftBorderStyle = null;// if left border wasn't set, don't set
+ // it now
+ }
+ this.font = sourceCell.getFont();// otherwise paragraph gets invalidated
+ this.fontBold = sourceCell.getFontBold();
+ this.fontSize = sourceCell.getFontSize();
+ setFillColor(sourceCell.getFillColor());
+ setTextColor(sourceCell.getTextColor());
+ setAlign(sourceCell.getAlign());
+ setValign(sourceCell.getValign());
+ }
+
+ /**
+ * Returns whether source cell has the same style
+ * @param sourceCell Source {@link Cell} which will be used for style comparison
+ */
+ public Boolean hasSameStyle(final Cell sourceCell) {
+ if (!sourceCell.getTopBorderStyle().equals(getTopBorderStyle())) {
+ return false;
+ }
+ if (!sourceCell.getFont().equals(getFont())) {
+ return false;
+ }
+ if (!sourceCell.getFontBold().equals(getFontBold())) {
+ return false;
+ }
+ if (!sourceCell.getFillColor().equals(getFillColor())) {
+ return false;
+ }
+ if (!sourceCell.getTextColor().equals(getTextColor())) {
+ return false;
+ }
+ if (!sourceCell.getAlign().equals(getAlign())) {
+ return false;
+ }
+ if (!sourceCell.getValign().equals(getValign())) {
+ return false;
+ }
+ return true;
+ }
+
+ public void addContentDrawnListener(final CellContentDrawnListener listener) {
+ contentDrawnListenerList.add(listener);
+ }
+
+ public List getCellContentDrawnListeners() {
+ return contentDrawnListenerList;
+ }
+
+ public void notifyContentDrawnListeners(final PDDocument document, final PDPage page, final PDRectangle rectangle) {
+ for(var listener : getCellContentDrawnListeners()) {
+ listener.onContentDrawn(this, document, page, rectangle);
+ }
+ }
+
+}
diff --git a/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/CellContentDrawnListener.java b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/CellContentDrawnListener.java
new file mode 100644
index 00000000000..c409313d7d0
--- /dev/null
+++ b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/CellContentDrawnListener.java
@@ -0,0 +1,27 @@
+/*
+ * 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.causeway.extensions.tabular.pdf.factory.internal;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+
+interface CellContentDrawnListener {
+ void onContentDrawn(Cell cell, PDDocument document, PDPage page, PDRectangle rectangle);
+}
diff --git a/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/Image.java b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/Image.java
new file mode 100644
index 00000000000..9332285ff76
--- /dev/null
+++ b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/Image.java
@@ -0,0 +1,187 @@
+/*
+ * 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.causeway.extensions.tabular.pdf.factory.internal;
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.graphics.image.JPEGFactory;
+import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
+import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
+
+final class Image {
+
+ private final BufferedImage image;
+ private float width;
+ private float height;
+ private PDImageXObject imageXObject = null;
+
+ // standard DPI
+ private float[] dpi = { 72, 72 };
+ private float quality = 1f;
+
+ /**
+ * Constructor for default images
+ * @param image
+ * {@link BufferedImage}
+ */
+ public Image(final BufferedImage image) {
+ this.image = image;
+ this.width = image.getWidth();
+ this.height = image.getHeight();
+ }
+
+ public Image(final BufferedImage image, final float dpi) {
+ this(image, dpi, dpi);
+ }
+
+ public Image(final BufferedImage image, final float dpiX, final float dpiY) {
+ this.image = image;
+ this.width = image.getWidth();
+ this.height = image.getHeight();
+ this.dpi[0] = dpiX;
+ this.dpi[1] = dpiY;
+ scaleImageFromPixelToPoints();
+ }
+
+ /**
+ * Drawing simple {@link Image} in {@link PDPageContentStream}.
+ * @param doc
+ * {@link PDDocument} where drawing will be applied
+ * @param stream
+ * {@link PDPageContentStream} where drawing will be applied
+ * @param x
+ * X coordinate for image drawing
+ * @param y
+ * Y coordinate for image drawing
+ * @throws IOException if loading image fails
+ */
+ public void draw(final PDDocument doc, final PageContentStreamOptimized stream, final float x, final float y) throws IOException
+ {
+ if (imageXObject == null) {
+ if(quality == 1f) {
+ imageXObject = LosslessFactory.createFromImage(doc, image);
+ } else {
+ imageXObject = JPEGFactory.createFromImage(doc, image, quality);
+ }
+ }
+ stream.drawImage(imageXObject, x, y - height, width, height);
+ }
+
+ /**
+ * Method which scale {@link Image} with designated width
+ * @param width
+ * Maximal width where {@link Image} needs to be scaled
+ * @return Scaled {@link Image}
+ */
+ public Image scaleByWidth(final float width) {
+ float factorWidth = width / this.width;
+ return scale(width, this.height * factorWidth);
+ }
+
+ /**
+ * Method which scale {@link Image} with designated height
+ * @param height
+ * Maximal height where {@link Image} needs to be scaled
+ * @return Scaled {@link Image}
+ */
+ public Image scaleByHeight(final float height) {
+ float factorHeight = height / this.height;
+ return scale(this.width * factorHeight, height);
+ }
+
+ public float getImageWidthInPoints(final float dpiX) {
+ return this.width * 72f / dpiX;
+ }
+
+ public float getImageHeightInPoints(final float dpiY) {
+ return this.height * 72f / dpiY;
+ }
+
+ /**
+ * Method which scale {@link Image} with designated width und height
+ * @param boundWidth
+ * Maximal width where {@link Image} needs to be scaled
+ * @param boundHeight
+ * Maximal height where {@link Image} needs to be scaled
+ * @return scaled {@link Image}
+ */
+ public Image scale(final float boundWidth, final float boundHeight) {
+ float[] imageDimension = getScaledDimension(this.width, this.height, boundWidth, boundHeight);
+ this.width = imageDimension[0];
+ this.height = imageDimension[1];
+ return this;
+ }
+
+ public float getHeight() {
+ return height;
+ }
+
+ public float getWidth() {
+ return width;
+ }
+
+ public void setQuality(final float quality) throws IllegalArgumentException {
+ if(quality <= 0 || quality > 1) {
+ throw new IllegalArgumentException(
+ "The quality value must be configured greater than zero and less than or equal to 1");
+ }
+ this.quality = quality;
+ }
+
+ // -- HELPER
+
+ private void scaleImageFromPixelToPoints() {
+ float dpiX = dpi[0];
+ float dpiY = dpi[1];
+ scale(getImageWidthInPoints(dpiX), getImageHeightInPoints(dpiY));
+ }
+
+ /**
+ * Scales {@link Image} to desired dimensions
+ * @param imageWidth Original image width
+ * @param imageHeight Original image height
+ * @param boundWidth Desired image width
+ * @param boundHeight Desired image height
+ * @return {@code Array} with image dimension. First value is width and second is height.
+ */
+ private static float[] getScaledDimension(final float imageWidth, final float imageHeight, final float boundWidth, final float boundHeight) {
+ float newImageWidth = imageWidth;
+ float newImageHeight = imageHeight;
+
+ // first check if we need to scale width
+ if (imageWidth > boundWidth) {
+ newImageWidth = boundWidth;
+ // scale height to maintain aspect ratio
+ newImageHeight = (newImageWidth * imageHeight) / imageWidth;
+ }
+
+ // then check if the new height is also bigger than expected
+ if (newImageHeight > boundHeight) {
+ newImageHeight = boundHeight;
+ // scale width to maintain aspect ratio
+ newImageWidth = (newImageHeight * imageWidth) / imageHeight;
+ }
+
+ float[] imageDimension = { newImageWidth, newImageHeight };
+ return imageDimension;
+ }
+
+}
diff --git a/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/ImageCell.java b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/ImageCell.java
new file mode 100644
index 00000000000..af4c832c840
--- /dev/null
+++ b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/ImageCell.java
@@ -0,0 +1,66 @@
+/*
+ * 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.causeway.extensions.tabular.pdf.factory.internal;
+
+import org.apache.causeway.extensions.tabular.pdf.factory.HorizontalAlignment;
+import org.apache.causeway.extensions.tabular.pdf.factory.VerticalAlignment;
+
+import lombok.Getter;
+
+class ImageCell extends Cell {
+
+ @Getter private Image image;
+
+ ImageCell(final Row row, final float width, final Image image, final boolean isCalculated) {
+ super(row, width, null, isCalculated);
+ this.image = image;
+ if(image.getWidth() > getInnerWidth()){
+ scaleToFit();
+ }
+ }
+
+ public void scaleToFit() {
+ this.image = image.scaleByWidth(getInnerWidth());
+ }
+
+ ImageCell(final Row row, final float width, final Image image, final boolean isCalculated,
+ final HorizontalAlignment align, final VerticalAlignment valign) {
+ super(row, width, null, isCalculated, align, valign);
+ this.image = image;
+ if(image.getWidth() > getInnerWidth()) {
+ scaleToFit();
+ }
+ }
+
+ @Override
+ public float getTextHeight() {
+ return image.getHeight();
+ }
+
+ @Override
+ public float getHorizontalFreeSpace() {
+ return getInnerWidth() - image.getWidth();
+ }
+
+ @Override
+ public float getVerticalFreeSpace() {
+ return getInnerHeight() - image.getHeight();
+ }
+
+}
diff --git a/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/PDStreamUtils.java b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/PDStreamUtils.java
new file mode 100644
index 00000000000..c8f7916c316
--- /dev/null
+++ b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/PDStreamUtils.java
@@ -0,0 +1,144 @@
+/*
+ * 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.causeway.extensions.tabular.pdf.factory.internal;
+
+import java.awt.Color;
+import java.io.IOException;
+
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.font.PDFont;
+
+import org.apache.causeway.extensions.tabular.pdf.factory.FontUtils;
+import org.apache.causeway.extensions.tabular.pdf.factory.LineStyle;
+
+import lombok.experimental.UtilityClass;
+
+/**
+ * Utility methods for {@link PDPageContentStream}
+ */
+@UtilityClass
+class PDStreamUtils {
+
+ /**
+ * Provides ability to write on a {@link PDPageContentStream}. The text will
+ * be written above Y coordinate.
+ *
+ * @param stream
+ * The {@link PDPageContentStream} where writing will be applied.
+ * @param text
+ * The text which will be displayed.
+ * @param font
+ * The font of the text
+ * @param fontSize
+ * The font size of the text
+ * @param x
+ * Start X coordinate for text.
+ * @param y
+ * Start Y coordinate for text.
+ * @param color
+ * Color of the text
+ */
+ public static void write(final PageContentStreamOptimized stream, final String text, final PDFont font,
+ final float fontSize, final float x, final float y, final Color color) {
+ try {
+ stream.setFont(font, fontSize);
+ // we want to position our text on his baseline
+ stream.newLineAt(x, y - FontUtils.getDescent(font, fontSize) - FontUtils.getHeight(font, fontSize));
+ stream.setNonStrokingColor(color);
+ stream.showText(text);
+ } catch (final IOException e) {
+ throw new IllegalStateException("Unable to write text", e);
+ }
+ }
+
+ /**
+ * Provides ability to draw rectangle for debugging purposes.
+ *
+ * @param stream
+ * The {@link PDPageContentStream} where drawing will be applied.
+ * @param x
+ * Start X coordinate for rectangle.
+ * @param y
+ * Start Y coordinate for rectangle.
+ * @param width
+ * Width of rectangle
+ * @param height
+ * Height of rectangle
+ * @param color
+ * Color of the text
+ */
+ public static void rect(final PageContentStreamOptimized stream, final float x, final float y, final float width,
+ final float height, final Color color) {
+ try {
+ stream.setNonStrokingColor(color);
+ // negative height because we want to draw down (not up!)
+ stream.addRect(x, y, width, -height);
+ stream.fill();
+ } catch (final IOException e) {
+ throw new IllegalStateException("Unable to draw rectangle", e);
+ }
+ }
+
+ /**
+ * Provides ability to draw font metrics (font height, font ascent, font
+ * descent).
+ *
+ * @param stream
+ * The {@link PDPageContentStream} where drawing will be applied.
+ * @param x
+ * Start X coordinate for rectangle.
+ * @param y
+ * Start Y coordinate for rectangle.
+ * @param font
+ * {@link PDFont} from which will be obtained font metrics
+ * @param fontSize
+ * Font size
+ */
+ public static void rectFontMetrics(final PageContentStreamOptimized stream, final float x, final float y,
+ final PDFont font, final float fontSize) {
+ // height
+ PDStreamUtils.rect(stream, x, y, 3, FontUtils.getHeight(font, fontSize), Color.BLUE);
+ // ascent
+ PDStreamUtils.rect(stream, x + 3, y, 3, FontUtils.getAscent(font, fontSize), Color.CYAN);
+ // descent
+ PDStreamUtils.rect(stream, x + 3, y - FontUtils.getHeight(font, fontSize), 3, FontUtils.getDescent(font, 14),
+ Color.GREEN);
+ }
+
+ /**
+ * Provides ability to set different line styles (line width, dotted line,
+ * dashed line)
+ *
+ * @param stream
+ * The {@link PDPageContentStream} where drawing will be applied.
+ * @param line
+ * The {@link LineStyle} that would be applied
+ * @throws IOException If the content stream could not be written or the line color cannot be retrieved.
+ */
+ public static void setLineStyles(final PageContentStreamOptimized stream, final LineStyle line) throws IOException {
+ stream.setStrokingColor(line.getColor());
+ stream.setLineWidth(line.getWidth());
+ stream.setLineCapStyle(0);
+ if (line.getDashArray() != null) {
+ stream.setLineDashPattern(line.getDashArray(), line.getDashPhase());
+ } else {
+ stream.setLineDashPattern(new float[] {}, 0.0f);
+ }
+ }
+}
diff --git a/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/PageContentStreamOptimized.java b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/PageContentStreamOptimized.java
new file mode 100644
index 00000000000..c3c0ab256f6
--- /dev/null
+++ b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/PageContentStreamOptimized.java
@@ -0,0 +1,197 @@
+/*
+ * 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.causeway.extensions.tabular.pdf.factory.internal;
+
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.font.PDFont;
+import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
+import org.apache.pdfbox.util.Matrix;
+
+import lombok.extern.log4j.Log4j2;
+
+import java.awt.Color;
+import java.io.IOException;
+import java.util.Arrays;
+
+@Log4j2
+final class PageContentStreamOptimized {
+ private static final Matrix ROTATION = Matrix.getRotateInstance(Math.PI * 0.5, 0, 0);
+
+ private final PDPageContentStream pageContentStream;
+ private boolean textMode;
+ private float textCursorAbsoluteX;
+ private float textCursorAbsoluteY;
+ private boolean rotated;
+
+ public PageContentStreamOptimized(final PDPageContentStream pageContentStream) {
+ this.pageContentStream = pageContentStream;
+ }
+
+ public void setRotated(final boolean rotated) throws IOException {
+ if (this.rotated == rotated) return;
+ if (rotated) {
+ if (textMode) {
+ pageContentStream.setTextMatrix(ROTATION);
+ textCursorAbsoluteX = 0;
+ textCursorAbsoluteY = 0;
+ }
+ } else {
+ endText();
+ }
+ this.rotated = rotated;
+ }
+
+ public void beginText() throws IOException {
+ if (!textMode) {
+ pageContentStream.beginText();
+ if (rotated) {
+ pageContentStream.setTextMatrix(ROTATION);
+ }
+ textMode = true;
+ textCursorAbsoluteX = 0;
+ textCursorAbsoluteY = 0;
+ }
+ }
+
+ public void endText() throws IOException {
+ if (textMode) {
+ pageContentStream.endText();
+ textMode = false;
+ }
+ }
+
+ private PDFont currentFont;
+ private float currentFontSize;
+
+ public void setFont(final PDFont font, final float fontSize) throws IOException {
+ if (font != currentFont || fontSize != currentFontSize) {
+ pageContentStream.setFont(font, fontSize);
+ currentFont = font;
+ currentFontSize = fontSize;
+ }
+ }
+
+ public void showText(final String text) throws IOException {
+ beginText();
+ try {
+ pageContentStream.showText(text);
+ } catch (IllegalArgumentException e) {
+ log.warn("cannot render text text '{}' -> {}", text, e.getMessage());
+ }
+ }
+
+ public void newLineAt(final float tx, final float ty) throws IOException {
+ beginText();
+ float dx = tx - textCursorAbsoluteX;
+ float dy = ty - textCursorAbsoluteY;
+ if (rotated) {
+ pageContentStream.newLineAtOffset(dy, -dx);
+ } else {
+ pageContentStream.newLineAtOffset(dx, dy);
+ }
+ textCursorAbsoluteX = tx;
+ textCursorAbsoluteY = ty;
+ }
+
+ public void drawImage(final PDImageXObject image, final float x, final float y, final float width, final float height) throws IOException {
+ endText();
+ pageContentStream.drawImage(image, x, y, width, height);
+ }
+
+ private Color currentStrokingColor;
+
+ public void setStrokingColor(final Color color) throws IOException {
+ if (color != currentStrokingColor) {
+ pageContentStream.setStrokingColor(color);
+ currentStrokingColor = color;
+ }
+ }
+
+ private Color currentNonStrokingColor;
+
+ public void setNonStrokingColor(final Color color) throws IOException {
+ if (color != currentNonStrokingColor) {
+ pageContentStream.setNonStrokingColor(color);
+ currentNonStrokingColor = color;
+ }
+ }
+
+ public void addRect(final float x, final float y, final float width, final float height) throws IOException {
+ endText();
+ pageContentStream.addRect(x, y, width, height);
+ }
+
+ public void moveTo(final float x, final float y) throws IOException {
+ endText();
+ pageContentStream.moveTo(x, y);
+ }
+
+ public void lineTo(final float x, final float y) throws IOException {
+ endText();
+ pageContentStream.lineTo(x, y);
+ }
+
+ public void stroke() throws IOException {
+ endText();
+ pageContentStream.stroke();
+ }
+
+ public void fill() throws IOException {
+ endText();
+ pageContentStream.fill();
+ }
+
+ private float currentLineWidth = -1;
+
+ public void setLineWidth(final float lineWidth) throws IOException {
+ if (lineWidth != currentLineWidth) {
+ endText();
+ pageContentStream.setLineWidth(lineWidth);
+ currentLineWidth = lineWidth;
+ }
+ }
+
+ private int currentLineCapStyle = -1;
+
+ public void setLineCapStyle(final int lineCapStyle) throws IOException {
+ if (lineCapStyle != currentLineCapStyle) {
+ endText();
+ pageContentStream.setLineCapStyle(lineCapStyle);
+ currentLineCapStyle = lineCapStyle;
+ }
+ }
+
+ private float[] currentLineDashPattern;
+ private float currentLineDashPhase;
+
+ public void setLineDashPattern(final float[] pattern, final float phase) throws IOException {
+ if ((pattern != currentLineDashPattern &&
+ !Arrays.equals(pattern, currentLineDashPattern)) || phase != currentLineDashPhase) {
+ endText();
+ pageContentStream.setLineDashPattern(pattern, phase);
+ currentLineDashPattern = pattern;
+ currentLineDashPhase = phase;
+ }
+ }
+
+ public void close() throws IOException {
+ endText();
+ pageContentStream.close();
+ }
+}
diff --git a/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/PageProvider.java b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/PageProvider.java
new file mode 100644
index 00000000000..35cac6d8375
--- /dev/null
+++ b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/PageProvider.java
@@ -0,0 +1,69 @@
+/*
+ * 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.causeway.extensions.tabular.pdf.factory.internal;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+
+import lombok.Getter;
+
+final class PageProvider {
+
+ @Getter private final PDDocument document;
+ private final PDRectangle size;
+ private int currentPageIndex = -1;
+
+ PageProvider(final PDDocument document, final PDRectangle size) {
+ this.document = document;
+ this.size = size;
+ }
+
+ public PDPage createPage() {
+ currentPageIndex = document.getNumberOfPages();
+ return getCurrentPage();
+ }
+
+ public PDPage nextPage() {
+ if (currentPageIndex == -1) {
+ currentPageIndex = document.getNumberOfPages();
+ } else {
+ currentPageIndex++;
+ }
+ return getCurrentPage();
+ }
+
+ public PDPage previousPage() {
+ currentPageIndex--;
+ if (currentPageIndex < 0) {
+ currentPageIndex = 0;
+ }
+ return getCurrentPage();
+ }
+
+ private PDPage getCurrentPage() {
+ if (currentPageIndex >= document.getNumberOfPages()) {
+ final PDPage newPage = new PDPage(size);
+ document.addPage(newPage);
+ return newPage;
+ }
+ return document.getPage(currentPageIndex);
+ }
+
+}
diff --git a/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/Paragraph.java b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/Paragraph.java
new file mode 100644
index 00000000000..e95b7342941
--- /dev/null
+++ b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/Paragraph.java
@@ -0,0 +1,715 @@
+/*
+ * 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.causeway.extensions.tabular.pdf.factory.internal;
+
+import java.awt.Color;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Stack;
+
+import org.apache.pdfbox.pdmodel.font.PDFont;
+import org.apache.pdfbox.pdmodel.font.PDType1Font;
+import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
+
+import org.apache.causeway.extensions.tabular.pdf.factory.FontUtils;
+import org.apache.causeway.extensions.tabular.pdf.factory.HorizontalAlignment;
+import org.apache.causeway.extensions.tabular.pdf.factory.internal.Token.TokenType;
+
+final class Paragraph {
+
+ enum TextType {
+ HIGHLIGHT,UNDERLINE,SQUIGGLY,STRIKEOUT;
+ }
+
+ /**
+ * Data container for HTML ordered list elements.
+ */
+ record HTMLListNode(
+ /** Element's current ordering number (e.g third element in the current list) */
+ int orderingNumber,
+ /** Element's whole ordering number value (e.g 1.1.2.1) */
+ String value) {
+ }
+
+ private float width;
+ private final String text;
+ private float fontSize;
+ private PDFont font;
+ private final PDFont fontBold;
+ private final PDFont fontItalic;
+ private final PDFont fontBoldItalic;
+ private final WrappingFunction wrappingFunction;
+ private HorizontalAlignment align;
+ private TextType textType;
+ private Color color;
+ private float lineSpacing;
+
+ private final static int DEFAULT_TAB = 4;
+ private final static int DEFAULT_TAB_AND_BULLET = 6;
+ private final static int BULLET_SPACE = 2;
+
+ private boolean drawDebug;
+ private final Map lineWidths = new HashMap<>();
+ private Map> mapLineTokens = new LinkedHashMap<>();
+ private float maxLineWidth = Integer.MIN_VALUE;
+ private List tokens;
+ private List lines;
+ private Float spaceWidth;
+
+ public Paragraph(final String text, final PDFont font, final float fontSize, final float width, final HorizontalAlignment align) {
+ this(text, font, fontSize, width, align, null);
+ }
+
+ // This function exists only to preserve backwards compatibility for
+ // the getWrappingFunction() method; it has been replaced with a faster implementation in the Tokenizer
+ private static final WrappingFunction DEFAULT_WRAP_FUNC = t -> t.split("(?<=\\s|-|@|,|\\.|:|;)");
+
+ public Paragraph(final String text, final PDFont font, final int fontSize, final int width) {
+ this(text, font, fontSize, width, HorizontalAlignment.LEFT, null);
+ }
+
+ public Paragraph(final String text, final PDFont font, final float fontSize, final float width, final HorizontalAlignment align,
+ final WrappingFunction wrappingFunction) {
+ this(text, font, fontSize, width, align, Color.BLACK, (TextType) null, wrappingFunction);
+ }
+
+ public Paragraph(final String text, final PDFont font, final float fontSize, final float width, final HorizontalAlignment align,
+ final Color color, final TextType textType, final WrappingFunction wrappingFunction) {
+ this(text, font, fontSize, width, align, color, textType, wrappingFunction, 1);
+ }
+
+ public Paragraph(final String text, final PDFont font, final float fontSize, final float width, final HorizontalAlignment align,
+ final Color color, final TextType textType, final WrappingFunction wrappingFunction, final float lineSpacing) {
+ this.color = color;
+ this.text = text;
+ this.font = font;
+ // check if we have different default font for italic and bold text
+ if (FontUtils.getDefaultfonts().isEmpty()) {
+ fontBold = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD);
+ fontItalic = new PDType1Font(Standard14Fonts.FontName.HELVETICA_OBLIQUE);
+ fontBoldItalic = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD_OBLIQUE);
+ } else {
+ fontBold = FontUtils.getDefaultfonts().get("fontBold");
+ fontBoldItalic = FontUtils.getDefaultfonts().get("fontBoldItalic");
+ fontItalic = FontUtils.getDefaultfonts().get("fontItalic");
+ }
+ this.fontSize = fontSize;
+ this.width = width;
+ this.textType = textType;
+ this.setAlign(align);
+ this.wrappingFunction = wrappingFunction;
+ this.lineSpacing = lineSpacing;
+ }
+
+ public List getLines() {
+ // memoize this function because it is very expensive
+ if (lines != null) {
+ return lines;
+ }
+
+ final List result = new ArrayList<>();
+
+ // text and wrappingFunction are immutable, so we only ever need to compute tokens once
+ if (tokens == null) {
+ tokens = Tokenizer.tokenize(text, wrappingFunction);
+ }
+
+ int lineCounter = 0;
+ boolean italic = false;
+ boolean bold = false;
+ boolean listElement = false;
+ PDFont currentFont = font;
+ int orderListElement = 1;
+ int numberOfOrderedLists = 0;
+ int listLevel = 0;
+ Stack stack = new Stack<>();
+
+ final PipelineLayer textInLine = new PipelineLayer();
+ final PipelineLayer sinceLastWrapPoint = new PipelineLayer();
+
+ for (final Token token : tokens) {
+ switch (token.type()) {
+ case OPEN_TAG:
+ if (isBold(token)) {
+ bold = true;
+ currentFont = getFont(bold, italic);
+ } else if (isItalic(token)) {
+ italic = true;
+ currentFont = getFont(bold, italic);
+ } else if (isList(token)) {
+ listLevel++;
+ if (token.text().equals("ol")) {
+ numberOfOrderedLists++;
+ if(listLevel > 1){
+ stack.add(new HTMLListNode(orderListElement-1, stack.isEmpty() ? String.valueOf(orderListElement-1)+"." : stack.peek().value() + String.valueOf(orderListElement-1) + "."));
+ }
+ orderListElement = 1;
+
+ textInLine.push(sinceLastWrapPoint);
+ // check if you have some text before this list, if you don't then you really don't need extra line break for that
+ if (textInLine.trimmedWidth() > 0) {
+ // this is our line
+ result.add(textInLine.trimmedText());
+ lineWidths.put(lineCounter, textInLine.trimmedWidth());
+ mapLineTokens.put(lineCounter, textInLine.tokens());
+ maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
+ textInLine.reset();
+ lineCounter++;
+ }
+ } else if (token.text().equals("ul")) {
+ textInLine.push(sinceLastWrapPoint);
+ // check if you have some text before this list, if you don't then you really don't need extra line break for that
+ if (textInLine.trimmedWidth() > 0) {
+ // this is our line
+ result.add(textInLine.trimmedText());
+ lineWidths.put(lineCounter, textInLine.trimmedWidth());
+ mapLineTokens.put(lineCounter, textInLine.tokens());
+ maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
+ textInLine.reset();
+ lineCounter++;
+ }
+ }
+ }
+ sinceLastWrapPoint.push(token);
+ break;
+ case CLOSE_TAG:
+ if (isBold(token)) {
+ bold = false;
+ currentFont = getFont(bold, italic);
+ sinceLastWrapPoint.push(token);
+ } else if (isItalic(token)) {
+ italic = false;
+ currentFont = getFont(bold, italic);
+ sinceLastWrapPoint.push(token);
+ } else if (isList(token)) {
+ listLevel--;
+ if (token.text().equals("ol")) {
+ numberOfOrderedLists--;
+ // reset elements
+ if(numberOfOrderedLists>0){
+ orderListElement = stack.peek().orderingNumber()+1;
+ stack.pop();
+ }
+ }
+ // ensure extra space after each lists
+ // no need to worry about current line text because last closing tag already done that
+ if(listLevel == 0){
+ result.add(" ");
+ lineWidths.put(lineCounter, 0.0f);
+ mapLineTokens.put(lineCounter, new ArrayList());
+ lineCounter++;
+ }
+ } else if (isListElement(token)) {
+ // wrap at last wrap point?
+ if (textInLine.width() + sinceLastWrapPoint.trimmedWidth() > width) {
+ // this is our line
+ result.add(textInLine.trimmedText());
+ lineWidths.put(lineCounter, textInLine.trimmedWidth());
+ mapLineTokens.put(lineCounter, textInLine.tokens());
+ maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
+ textInLine.reset();
+ lineCounter++;
+ // wrapping at last wrap point
+ if (numberOfOrderedLists>0) {
+ String orderingNumber = stack.isEmpty() ? String.valueOf(orderListElement) + "." : stack.pop().value() + ".";
+ stack.add(new HTMLListNode(orderListElement, orderingNumber));
+ try {
+ float tab = indentLevel(DEFAULT_TAB);
+ float orderingNumberAndTab = font.getStringWidth(orderingNumber) + tab;
+ textInLine.push(currentFont, fontSize, new Token(TokenType.PADDING, String
+ .valueOf(orderingNumberAndTab / 1000 * getFontSize())));
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ orderListElement++;
+ } else {
+ try {
+ // if it's not left aligned then ignore list and list element and deal with it as normal text where mimic
behaviour
+ float tabBullet = getAlign().equals(HorizontalAlignment.LEFT) ? indentLevel(DEFAULT_TAB*Math.max(listLevel - 1, 0) + DEFAULT_TAB_AND_BULLET) : indentLevel(DEFAULT_TAB);
+ textInLine.push(currentFont, fontSize, new Token(TokenType.PADDING,
+ String.valueOf(tabBullet / 1000 * getFontSize())));
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ textInLine.push(sinceLastWrapPoint);
+ }
+ // wrapping at this must-have wrap point
+ textInLine.push(sinceLastWrapPoint);
+ // this is our line
+ result.add(textInLine.trimmedText());
+ lineWidths.put(lineCounter, textInLine.trimmedWidth());
+ mapLineTokens.put(lineCounter, textInLine.tokens());
+ maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
+ textInLine.reset();
+ lineCounter++;
+ listElement = false;
+ }
+ if (isParagraph(token)) {
+ if (textInLine.width() + sinceLastWrapPoint.trimmedWidth() > width) {
+ // this is our line
+ result.add(textInLine.trimmedText());
+ lineWidths.put(lineCounter, textInLine.trimmedWidth());
+ maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
+ mapLineTokens.put(lineCounter, textInLine.tokens());
+ lineCounter++;
+ textInLine.reset();
+ }
+ // wrapping at this must-have wrap point
+ textInLine.push(sinceLastWrapPoint);
+ // this is our line
+ result.add(textInLine.trimmedText());
+ lineWidths.put(lineCounter, textInLine.trimmedWidth());
+ mapLineTokens.put(lineCounter, textInLine.tokens());
+ maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
+ textInLine.reset();
+ lineCounter++;
+
+ // extra spacing because it's a paragraph
+ result.add(" ");
+ lineWidths.put(lineCounter, 0.0f);
+ mapLineTokens.put(lineCounter, new ArrayList());
+ lineCounter++;
+ }
+ break;
+ case POSSIBLE_WRAP_POINT:
+ if (textInLine.width() + sinceLastWrapPoint.trimmedWidth() > width) {
+ // this is our line
+ if (!textInLine.isEmpty()) {
+ result.add(textInLine.trimmedText());
+ lineWidths.put(lineCounter, textInLine.trimmedWidth());
+ maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
+ mapLineTokens.put(lineCounter, textInLine.tokens());
+ lineCounter++;
+ textInLine.reset();
+ }
+ // wrapping at last wrap point
+ if (listElement) {
+ if (numberOfOrderedLists>0) {
+ try {
+ float tab = getAlign().equals(HorizontalAlignment.LEFT) ? indentLevel(DEFAULT_TAB*Math.max(listLevel - 1, 0) + DEFAULT_TAB) : indentLevel(DEFAULT_TAB);
+ String orderingNumber = stack.isEmpty() ? String.valueOf(orderListElement) + "." : stack.peek().value() + "." + String.valueOf(orderListElement-1) + ".";
+ textInLine.push(currentFont, fontSize, new Token(TokenType.PADDING,
+ String.valueOf((tab + font.getStringWidth(orderingNumber)) / 1000 * getFontSize())));
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ } else {
+ try {
+ // if it's not left aligned then ignore list and list element and deal with it as normal text where mimic
behavior
+ float tabBullet = getAlign().equals(HorizontalAlignment.LEFT) ? indentLevel(DEFAULT_TAB*Math.max(listLevel - 1, 0) + DEFAULT_TAB_AND_BULLET) : indentLevel(DEFAULT_TAB);
+ textInLine.push(currentFont, fontSize, new Token(TokenType.PADDING,
+ String.valueOf(tabBullet / 1000 * getFontSize())));
+ } catch (IOException e) {
+ // TODO Auto-generated catch block
+ e.printStackTrace();
+ }
+ }
+ }
+ textInLine.push(sinceLastWrapPoint);
+ } else {
+ textInLine.push(sinceLastWrapPoint);
+ }
+ break;
+ case WRAP_POINT:
+ // wrap at last wrap point?
+ if (textInLine.width() + sinceLastWrapPoint.trimmedWidth() > width) {
+ // this is our line
+ result.add(textInLine.trimmedText());
+ lineWidths.put(lineCounter, textInLine.trimmedWidth());
+ mapLineTokens.put(lineCounter, textInLine.tokens());
+ maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
+ textInLine.reset();
+ lineCounter++;
+ // wrapping at last wrap point
+ if (listElement) {
+ if(!getAlign().equals(HorizontalAlignment.LEFT)) {
+ listLevel = 0;
+ }
+ if (numberOfOrderedLists>0) {
+// String orderingNumber = String.valueOf(orderListElement) + ". ";
+ String orderingNumber = stack.isEmpty() ? String.valueOf("1") + "." : stack.pop().value() + ". ";
+ try {
+ float tab = indentLevel(DEFAULT_TAB);
+ float orderingNumberAndTab = font.getStringWidth(orderingNumber) + tab;
+ textInLine.push(currentFont, fontSize, new Token(TokenType.PADDING, String
+ .valueOf(orderingNumberAndTab / 1000 * getFontSize())));
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ } else {
+ try {
+ // if it's not left aligned then ignore list and list element and deal with it as normal text where mimic
behaviour
+ float tabBullet = getAlign().equals(HorizontalAlignment.LEFT) ? indentLevel(DEFAULT_TAB*Math.max(listLevel - 1, 0) + DEFAULT_TAB_AND_BULLET) : indentLevel(DEFAULT_TAB);
+ textInLine.push(currentFont, fontSize, new Token(TokenType.PADDING,
+ String.valueOf(tabBullet / 1000 * getFontSize())));
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+ textInLine.push(sinceLastWrapPoint);
+ }
+ if (isParagraph(token)) {
+ // check if you have some text before this paragraph, if you don't then you really don't need extra line break for that
+ if (textInLine.trimmedWidth() > 0) {
+ // extra spacing because it's a paragraph
+ result.add(" ");
+ lineWidths.put(lineCounter, 0.0f);
+ mapLineTokens.put(lineCounter, new ArrayList());
+ lineCounter++;
+ }
+ } else if (isListElement(token)) {
+ listElement = true;
+ // token padding, token bullet
+ try {
+ // if it's not left aligned then ignore list and list element and deal with it as normal text where mimic
behaviour
+ float tab = getAlign().equals(HorizontalAlignment.LEFT) ? indentLevel(DEFAULT_TAB*Math.max(listLevel - 1, 0) + DEFAULT_TAB) : indentLevel(DEFAULT_TAB);
+ textInLine.push(currentFont, fontSize, new Token(TokenType.PADDING,
+ String.valueOf(tab / 1000 * getFontSize())));
+ if (numberOfOrderedLists>0) {
+ // if it's ordering list then move depending on your: ordering number + ". "
+ String orderingNumber;
+ if(listLevel > 1){
+ orderingNumber = stack.peek().value() + String.valueOf(orderListElement) + ". ";
+ } else {
+ orderingNumber = String.valueOf(orderListElement) + ". ";
+ }
+ textInLine.push(currentFont, fontSize, new Token(TokenType.ORDERING, orderingNumber));
+ orderListElement++;
+ } else {
+ // if it's unordered list then just move by bullet character (take care of alignment!)
+ textInLine.push(currentFont, fontSize, new Token(TokenType.BULLET, " "));
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ } else {
+ // wrapping at this must-have wrap point
+ textInLine.push(sinceLastWrapPoint);
+ result.add(textInLine.trimmedText());
+ lineWidths.put(lineCounter, textInLine.trimmedWidth());
+ mapLineTokens.put(lineCounter, textInLine.tokens());
+ maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
+ textInLine.reset();
+ lineCounter++;
+ if(listLevel>0){
+ // preserve current indent
+ try {
+ if (numberOfOrderedLists>0) {
+ float tab = getAlign().equals(HorizontalAlignment.LEFT) ? indentLevel(DEFAULT_TAB*Math.max(listLevel - 1, 0)) : indentLevel(DEFAULT_TAB);
+ // if it's ordering list then move depending on your: ordering number + ". "
+ String orderingNumber;
+ if(listLevel > 1){
+ orderingNumber = stack.peek().value() + String.valueOf(orderListElement) + ". ";
+ } else {
+ orderingNumber = String.valueOf(orderListElement) + ". ";
+ }
+ float tabAndOrderingNumber = tab + font.getStringWidth(orderingNumber);
+ textInLine.push(currentFont, fontSize, new Token(TokenType.PADDING, String.valueOf(tabAndOrderingNumber / 1000 * getFontSize())));
+ orderListElement++;
+ } else {
+ if(getAlign().equals(HorizontalAlignment.LEFT)){
+ float tab = indentLevel(DEFAULT_TAB*Math.max(listLevel - 1, 0) + DEFAULT_TAB + BULLET_SPACE);
+ textInLine.push(currentFont, fontSize, new Token(TokenType.PADDING,
+ String.valueOf(tab / 1000 * getFontSize())));
+ }
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+ break;
+ case TEXT:
+ try {
+ String word = token.text();
+ float wordWidth = token.getWidth(currentFont);
+ if(wordWidth / 1000f * fontSize > width && width > font.getAverageFontWidth() / 1000f * fontSize) {
+ // you need to check if you have already something in your line
+ boolean alreadyTextInLine = false;
+ if(textInLine.trimmedWidth()>0){
+ alreadyTextInLine = true;
+ }
+ while (wordWidth / 1000f * fontSize > width) {
+ float width = 0;
+ float firstPartWordWidth = 0;
+ float restOfTheWordWidth = 0;
+ String lastTextToken = word;
+ StringBuilder firstPartOfWord = new StringBuilder();
+ StringBuilder restOfTheWord = new StringBuilder();
+ for (int i = 0; i < lastTextToken.length(); i++) {
+ char c = lastTextToken.charAt(i);
+ try {
+ width += (currentFont.getStringWidth(String.valueOf(c)) / 1000f * fontSize);
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ if(alreadyTextInLine){
+ if (width < this.width - textInLine.trimmedWidth()) {
+ firstPartOfWord.append(c);
+ firstPartWordWidth = Math.max(width, firstPartWordWidth);
+ } else {
+ restOfTheWord.append(c);
+ restOfTheWordWidth = Math.max(width, restOfTheWordWidth);
+ }
+ } else {
+ if (width < this.width) {
+ firstPartOfWord.append(c);
+ firstPartWordWidth = Math.max(width, firstPartWordWidth);
+ } else {
+ if(i==0){
+ firstPartOfWord.append(c);
+ for (int j = 1; j< lastTextToken.length(); j++){
+ restOfTheWord.append(lastTextToken.charAt(j));
+ }
+ break;
+ } else {
+ restOfTheWord.append(c);
+ restOfTheWordWidth = Math.max(width, restOfTheWordWidth);
+
+ }
+ }
+ }
+ }
+ // reset
+ alreadyTextInLine = false;
+ sinceLastWrapPoint.push(currentFont, fontSize,
+ Token.text(firstPartOfWord.toString()));
+ textInLine.push(sinceLastWrapPoint);
+ // this is our line
+ result.add(textInLine.trimmedText());
+ lineWidths.put(lineCounter, textInLine.trimmedWidth());
+ mapLineTokens.put(lineCounter, textInLine.tokens());
+ maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
+ textInLine.reset();
+ lineCounter++;
+ word = restOfTheWord.toString();
+ wordWidth = currentFont.getStringWidth(word);
+ }
+ sinceLastWrapPoint.push(currentFont, fontSize, Token.text(word));
+ } else {
+ sinceLastWrapPoint.push(currentFont, fontSize, token);
+ }
+
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ break;
+ case BULLET, ORDERING, PADDING:
+ break;
+ }
+ }
+ if (sinceLastWrapPoint.trimmedWidth() + textInLine.trimmedWidth() > 0) {
+ textInLine.push(sinceLastWrapPoint);
+ result.add(textInLine.trimmedText());
+ lineWidths.put(lineCounter, textInLine.trimmedWidth());
+ mapLineTokens.put(lineCounter, textInLine.tokens());
+ maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
+ }
+
+ lines = result;
+ return result;
+ }
+
+ private static boolean isItalic(final Token token) {
+ return "i".equals(token.text());
+ }
+
+ private static boolean isBold(final Token token) {
+ return "b".equals(token.text());
+ }
+
+ private static boolean isParagraph(final Token token) {
+ return "p".equals(token.text());
+ }
+
+ private static boolean isListElement(final Token token) {
+ return "li".equals(token.text());
+ }
+
+ private static boolean isList(final Token token) {
+ return "ul".equals(token.text()) || "ol".equals(token.text());
+ }
+
+ private float indentLevel(final int numberOfSpaces) throws IOException {
+ if (spaceWidth == null) {
+ spaceWidth = font.getSpaceWidth();
+ }
+ return numberOfSpaces * spaceWidth;
+ }
+
+ public PDFont getFont(final boolean isBold, final boolean isItalic) {
+ if (isBold) {
+ if (isItalic) {
+ return fontBoldItalic;
+ } else {
+ return fontBold;
+ }
+ } else if (isItalic) {
+ return fontItalic;
+ } else {
+ return font;
+ }
+ }
+
+ public float write(final PageContentStreamOptimized stream, final float cursorX, float cursorY) {
+ if (drawDebug) {
+ PDStreamUtils.rectFontMetrics(stream, cursorX, cursorY, font, fontSize);
+
+ // width
+ PDStreamUtils.rect(stream, cursorX, cursorY, width, 1, Color.RED);
+ }
+
+ for (String line : getLines()) {
+ line = line.trim();
+
+ float textX = cursorX;
+ switch (align) {
+ case CENTER:
+ textX += getHorizontalFreeSpace(line) / 2;
+ break;
+ case LEFT:
+ break;
+ case RIGHT:
+ textX += getHorizontalFreeSpace(line);
+ break;
+ }
+
+ PDStreamUtils.write(stream, line, font, fontSize, textX, cursorY, color);
+
+ if (textType != null) {
+ switch (textType) {
+ case HIGHLIGHT:
+ case SQUIGGLY:
+ case STRIKEOUT:
+ throw new UnsupportedOperationException("Not implemented.");
+ case UNDERLINE:
+ float y = (float) (cursorY - FontUtils.getHeight(font, fontSize)
+ - FontUtils.getDescent(font, fontSize) - 1.5);
+ try {
+ float titleWidth = font.getStringWidth(line) / 1000 * fontSize;
+ stream.moveTo(textX, y);
+ stream.lineTo(textX + titleWidth, y);
+ stream.stroke();
+ } catch (final IOException e) {
+ throw new IllegalStateException("Unable to underline text", e);
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ // move one "line" down
+ cursorY -= getFontHeight();
+ }
+
+ return cursorY;
+ }
+
+ public float getHeight() {
+ if (getLines().size() == 0) {
+ return 0;
+ } else {
+ return (getLines().size() - 1) * getLineSpacing() * getFontHeight() + getFontHeight();
+ }
+ }
+
+ public float getFontHeight() {
+ return FontUtils.getHeight(font, fontSize);
+ }
+
+ // font, fontSize, width, and align are non-final and used in getLines(),
+ // so if they are mutated, getLines() needs to be recomputed
+ private void invalidateLineWrapping() {
+ lines = null;
+ }
+
+ private float getHorizontalFreeSpace(final String text) {
+ try {
+ final float tw = font.getStringWidth(text.trim()) / 1000 * fontSize;
+ return width - tw;
+ } catch (IOException e) {
+ throw new IllegalStateException("Unable to calculate text width", e);
+ }
+ }
+
+ public float getWidth() {
+ return width;
+ }
+
+ public String getText() {
+ return text;
+ }
+
+ public float getFontSize() {
+ return fontSize;
+ }
+
+ public PDFont getFont() {
+ return font;
+ }
+
+ public HorizontalAlignment getAlign() {
+ return align;
+ }
+
+ public void setAlign(final HorizontalAlignment align) {
+ invalidateLineWrapping();
+ this.align = align;
+ }
+
+ public boolean isDrawDebug() {
+ return drawDebug;
+ }
+
+ public void setDrawDebug(final boolean drawDebug) {
+ this.drawDebug = drawDebug;
+ }
+
+ public WrappingFunction getWrappingFunction() {
+ return wrappingFunction == null ? DEFAULT_WRAP_FUNC : wrappingFunction;
+ }
+
+ public float getMaxLineWidth() {
+ return maxLineWidth;
+ }
+
+ public float getLineWidth(final int key) {
+ return lineWidths.get(key);
+ }
+
+ public Map> getMapLineTokens() {
+ return mapLineTokens;
+ }
+
+ public float getLineSpacing() {
+ return lineSpacing;
+ }
+
+ public void setLineSpacing(final float lineSpacing) {
+ this.lineSpacing = lineSpacing;
+ }
+
+}
diff --git a/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/PipelineLayer.java b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/PipelineLayer.java
new file mode 100644
index 00000000000..2acecbace4a
--- /dev/null
+++ b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/PipelineLayer.java
@@ -0,0 +1,146 @@
+/*
+ * 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.causeway.extensions.tabular.pdf.factory.internal;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.pdfbox.pdmodel.font.PDFont;
+
+import org.apache.causeway.extensions.tabular.pdf.factory.internal.Token.TokenType;
+
+final class PipelineLayer {
+
+ private final StringBuilder text = new StringBuilder();
+ private String lastTextToken = "";
+ private List tokens = new ArrayList<>();
+ private String trimmedLastTextToken = "";
+ private float width;
+ private float widthLastToken;
+ private float widthTrimmedLastToken;
+ private float widthCurrentText;
+
+ public boolean isEmpty() {
+ return tokens.isEmpty();
+ }
+
+ public void push(final Token token) {
+ tokens.add(token);
+ }
+
+ public void push(final PDFont font, final float fontSize, final Token token) throws IOException {
+ if (token.type().equals(TokenType.PADDING)) {
+ width += Float.parseFloat(token.text());
+ }
+ if (token.type().equals(TokenType.BULLET)) {
+ // just appending one space because our bullet width will be wide as one character of current font
+ text.append(token.text());
+ width += (token.getWidth(font) / 1000f * fontSize);
+ }
+
+ if (token.type().equals(TokenType.ORDERING)) {
+ // just appending one space because our bullet width will be wide as one character of current font
+ text.append(token.text());
+ width += (token.getWidth(font) / 1000f * fontSize);
+ }
+
+ if (token.type().equals(TokenType.TEXT)) {
+ text.append(lastTextToken);
+ width += widthLastToken;
+ lastTextToken = token.text();
+ trimmedLastTextToken = rtrim(lastTextToken);
+ widthLastToken = token.getWidth(font) / 1000f * fontSize;
+
+ if (trimmedLastTextToken.length() == lastTextToken.length()) {
+ widthTrimmedLastToken = widthLastToken;
+ } else {
+ widthTrimmedLastToken = (font.getStringWidth(trimmedLastTextToken) / 1000f * fontSize);
+ }
+
+ widthCurrentText = text.length() == 0 ? 0 :
+ (font.getStringWidth(text.toString()) / 1000f * fontSize);
+ }
+
+ push(token);
+ }
+
+ public void push(final PipelineLayer pipeline) {
+ text.append(lastTextToken);
+ width += widthLastToken;
+ text.append(pipeline.text);
+ if (pipeline.text.length() > 0) {
+ width += pipeline.widthCurrentText;
+ }
+ lastTextToken = pipeline.lastTextToken;
+ trimmedLastTextToken = pipeline.trimmedLastTextToken;
+ widthLastToken = pipeline.widthLastToken;
+ widthTrimmedLastToken = pipeline.widthTrimmedLastToken;
+ tokens.addAll(pipeline.tokens);
+
+ pipeline.reset();
+ }
+
+ public void reset() {
+ text.delete(0, text.length());
+ width = 0.0f;
+ lastTextToken = "";
+ trimmedLastTextToken = "";
+ widthLastToken = 0.0f;
+ widthTrimmedLastToken = 0.0f;
+ tokens.clear();
+ }
+
+ public String trimmedText() {
+ return text.toString() + trimmedLastTextToken;
+ }
+
+ public float width() {
+ return width + widthLastToken;
+ }
+
+ public float trimmedWidth() {
+ return width + widthTrimmedLastToken;
+ }
+
+ public List tokens() {
+ return new ArrayList<>(tokens);
+ }
+
+ @Override
+ public String toString() {
+ return text.toString() + "(" + lastTextToken + ") [width: " + width() + ", trimmed: " + trimmedWidth() + "]";
+ }
+
+ // -- HELPER
+
+ private static String rtrim(final String s) {
+ int len = s.length();
+ while ((len > 0) && (s.charAt(len - 1) <= ' ')) {
+ len--;
+ }
+ if (len == s.length()) {
+ return s;
+ }
+ if (len == 0) {
+ return "";
+ }
+ return s.substring(0, len);
+ }
+}
diff --git a/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/Row.java b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/Row.java
new file mode 100644
index 00000000000..d4431ed31de
--- /dev/null
+++ b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/Row.java
@@ -0,0 +1,303 @@
+/*
+ * 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.causeway.extensions.tabular.pdf.factory.internal;
+
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
+
+import org.apache.causeway.extensions.tabular.pdf.factory.HorizontalAlignment;
+import org.apache.causeway.extensions.tabular.pdf.factory.VerticalAlignment;
+
+public class Row {
+
+ private final Table table;
+ PDOutlineItem bookmark;
+ List cells;
+ private boolean headerRow = false;
+ float height;
+ private float lineSpacing = 1;
+
+ Row(final Table table, final List cells, final float height) {
+ this.table = table;
+ this.cells = cells;
+ this.height = height;
+ }
+
+ Row(final Table table, final float height) {
+ this.table = table;
+ this.cells = new ArrayList<>();
+ this.height = height;
+ }
+
+ /**
+ *
+ * Creates a cell with provided width, cell value and default left top
+ * alignment
+ *
+ *
+ * @param width
+ * Absolute width in points or in % of table width
+ * @param value
+ * Cell's value (content)
+ * @return New {@link Cell}
+ */
+ public Cell createCell(final float width, final String value) {
+ var cell = new Cell(this, width, value, true);
+ if (headerRow) {
+ // set all cell as header cell
+ cell.setHeaderCell(true);
+ }
+ setBorders(cell, cells.isEmpty());
+ cell.setLineSpacing(lineSpacing);
+ cells.add(cell);
+ return cell;
+ }
+
+ /**
+ *
+ * Creates an image cell with provided width and {@link Image}
+ *
+ *
+ * @param width
+ * Cell's width
+ * @param img
+ * {@link Image} in the cell
+ * @return {@link ImageCell}
+ */
+ public ImageCell createImageCell(final float width, final BufferedImage img) {
+ var cell = new ImageCell(this, width, new Image(img), true);
+ setBorders(cell, cells.isEmpty());
+ cells.add(cell);
+ return cell;
+ }
+
+ public ImageCell createImageCell(final float width, final Image img, final HorizontalAlignment align, final VerticalAlignment valign) {
+ var cell = new ImageCell(this, width, img, true, align, valign);
+ setBorders(cell, cells.isEmpty());
+ cells.add(cell);
+ return cell;
+ }
+
+ /**
+ *
+ * Creates a table cell with provided width and table data
+ *
+ *
+ * @param width
+ * Table width
+ * @param tableData
+ * Table's data (HTML table tags)
+ * @param doc
+ * {@link PDDocument} where this table will be drawn
+ * @param page
+ * {@link PDPage} where this table cell will be drawn
+ * @param yStart
+ * Y position from which table will be drawn
+ * @param pageTopMargin
+ * {@link TableCell}'s top margin
+ * @param pageBottomMargin
+ * {@link TableCell}'s bottom margin
+ * @return {@link TableCell} with provided width and table data
+ */
+ public TableCell createTableCell(final float width, final String tableData, final PDDocument doc, final PDPage page, final float yStart,
+ final float pageTopMargin, final float pageBottomMargin) {
+ var cell = new TableCell(this, width, tableData, true, doc, page, yStart, pageTopMargin,
+ pageBottomMargin);
+ setBorders(cell, cells.isEmpty());
+ cells.add(cell);
+ return cell;
+ }
+
+ /**
+ *
+ * Creates a cell with provided width, cell value, horizontal and vertical
+ * alignment
+ *
+ *
+ * @param width
+ * Absolute width in points or in % of table width
+ * @param value
+ * Cell's value (content)
+ * @param align
+ * Cell's {@link HorizontalAlignment}
+ * @param valign
+ * Cell's {@link VerticalAlignment}
+ * @return New {@link Cell}
+ */
+ public Cell createCell(final float width, final String value, final HorizontalAlignment align, final VerticalAlignment valign) {
+ var cell = new Cell(this, width, value, true, align, valign);
+ if (headerRow) {
+ // set all cell as header cell
+ cell.setHeaderCell(true);
+ }
+ setBorders(cell, cells.isEmpty());
+ cell.setLineSpacing(lineSpacing);
+ cells.add(cell);
+ return cell;
+ }
+
+ /**
+ *
+ * Creates a cell with the same width as the corresponding header cell
+ *
+ *
+ * @param value
+ * Cell's value (content)
+ * @return new {@link Cell}
+ */
+ public Cell createCell(final String value) {
+ float headerCellWidth = table.getHeader().getCells().get(cells.size()).getWidth();
+ var cell = new Cell(this, headerCellWidth, value, false);
+ setBorders(cell, cells.isEmpty());
+ cells.add(cell);
+ return cell;
+ }
+
+ /**
+ *
+ * Remove left border to avoid double borders from previous cell's right
+ * border. In most cases left border will be removed.
+ *
+ *
+ * @param cell
+ * {@link Cell}
+ * @param leftBorder
+ * boolean for drawing cell's left border. If {@code true} then
+ * the left cell's border will be drawn.
+ */
+ private void setBorders(final Cell cell, final boolean leftBorder) {
+ if (!leftBorder) {
+ cell.setLeftBorderStyle(null);
+ }
+ }
+
+ /**
+ *
+ * remove top borders of cells to avoid double borders from cells in
+ * previous row
+ *
+ */
+ void removeTopBorders() {
+ for (var cell : cells) {
+ cell.setTopBorderStyle(null);
+ }
+ }
+
+ /**
+ *
+ * Remove all borders of cells.
+ *
+ */
+ void removeAllBorders() {
+ for (var cell : cells) {
+ cell.setBorderStyle(null);
+ }
+ }
+
+ /**
+ *
+ * Gets maximal height of the cells in current row therefore row's height.
+ *
+ *
+ * @return Row's height
+ */
+ public float getHeight() {
+ float maxheight = 0.0f;
+ for (var cell : this.cells) {
+ float cellHeight = cell.getCellHeight();
+
+ if (cellHeight > maxheight) {
+ maxheight = cellHeight;
+ }
+ }
+
+ if (maxheight > height) {
+ this.height = maxheight;
+ }
+ return height;
+ }
+
+ public float getLineHeight() throws IOException {
+ return height;
+ }
+
+ public void setHeight(final float height) {
+ this.height = height;
+ }
+
+ public List getCells() {
+ return cells;
+ }
+
+ public int getColCount() {
+ return cells.size();
+ }
+
+ public void setCells(final List cells) {
+ this.cells = cells;
+ }
+
+ public float getWidth() {
+ return table.getWidth();
+ }
+
+ public PDOutlineItem getBookmark() {
+ return bookmark;
+ }
+
+ public void setBookmark(final PDOutlineItem bookmark) {
+ this.bookmark = bookmark;
+ }
+
+ protected float getLastCellExtraWidth() {
+ float cellWidth = 0;
+ for (var cell : cells) {
+ cellWidth += cell.getWidth();
+ }
+
+ float lastCellExtraWidth = this.getWidth() - cellWidth;
+ return lastCellExtraWidth;
+ }
+
+ public float xEnd() {
+ return table.getMargin() + getWidth();
+ }
+
+ public boolean isHeaderRow() {
+ return headerRow;
+ }
+
+ public void setHeaderRow(final boolean headerRow) {
+ this.headerRow = headerRow;
+ }
+
+ public float getLineSpacing() {
+ return lineSpacing;
+ }
+
+ public void setLineSpacing(final float lineSpacing) {
+ this.lineSpacing = lineSpacing;
+ }
+}
diff --git a/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/Table.java b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/Table.java
new file mode 100644
index 00000000000..fb9961e48dd
--- /dev/null
+++ b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/Table.java
@@ -0,0 +1,911 @@
+/*
+ * 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.causeway.extensions.tabular.pdf.factory.internal;
+
+import java.awt.Color;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+import org.apache.pdfbox.pdmodel.font.PDFont;
+import org.apache.pdfbox.pdmodel.font.PDType0Font;
+import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDBorderStyleDictionary;
+import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageXYZDestination;
+import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
+
+import org.apache.causeway.extensions.tabular.pdf.factory.FontUtils;
+import org.apache.causeway.extensions.tabular.pdf.factory.HorizontalAlignment;
+import org.apache.causeway.extensions.tabular.pdf.factory.LineStyle;
+
+public final class Table {
+
+ record Options(
+ float yStartNewPage,
+ float pageTopMargin,
+ float pageBottomMargin,
+ float width,
+ float margin,
+ PageProvider pageProvider,
+ boolean isDummy) {
+ }
+
+ public static Table create(final float yStart, final float yStartNewPage, final float pageTopMargin, final float bottomMargin,
+ final float width, final float margin, final PDDocument document, final PDPage currentPage) throws IOException {
+ var opts = new Table.Options(yStartNewPage, pageTopMargin, bottomMargin, width, margin, new PageProvider(document, currentPage.getMediaBox()), false);
+ return new Table(yStart, opts, currentPage);
+ }
+
+ public static Table dummy(final float yStart, final float yStartNewPage, final float pageTopMargin, final float bottomMargin,
+ final float width, final float margin, final PDDocument document, final PDPage currentPage) throws IOException {
+ var opts = new Table.Options(yStartNewPage, pageTopMargin, bottomMargin, width, margin, new PageProvider(document, currentPage.getMediaBox()), true);
+ return new Table(yStart, opts, currentPage);
+ }
+
+
+ private final PDDocument document;
+ private float margin;
+
+ private PDPage currentPage;
+ private PageContentStreamOptimized tableContentStream;
+ private List bookmarks;
+ private List header = new ArrayList<>();
+ private List rows = new ArrayList<>();
+
+ private final float yStartNewPage;
+ private float yStart;
+ private final float width;
+ private final boolean drawLines;
+ private final boolean drawContent;
+ private float headerBottomMargin = 4f;
+ private float lineSpacing = 1f;
+
+ private boolean tableIsBroken = false;
+ private boolean tableStartedAtNewPage = false;
+ private boolean removeTopBorders = false;
+ private boolean removeAllBorders = false;
+
+ private PageProvider pageProvider;
+
+ // page margins
+ private final float pageTopMargin;
+ private final float pageBottomMargin;
+
+ private boolean drawDebug;
+
+ private Table(
+ final float yStart,
+ final Options opts,
+ final PDPage currentPage) throws IOException {
+ this.pageTopMargin = opts.pageTopMargin();
+ this.pageBottomMargin = opts.pageBottomMargin();
+ this.document = opts.pageProvider().getDocument();
+ this.drawLines = !opts.isDummy();
+ this.drawContent = !opts.isDummy();
+ // Initialize table
+ this.yStartNewPage = opts.yStartNewPage();
+ this.margin = opts.margin();
+ this.width = opts.width();
+ this.pageProvider = opts.pageProvider();
+
+ this.currentPage = currentPage;
+ this.yStart = yStart;
+ }
+
+ protected PDType0Font loadFont(final String fontPath) throws IOException {
+ return FontUtils.loadFont(getDocument(), fontPath);
+ }
+
+ protected PDDocument getDocument() {
+ return document;
+ }
+
+ public void drawTitle(final String title, final PDFont font, final int fontSize, final float tableWidth, final float height,
+ final String alignment,
+ final float freeSpaceForPageBreak, final boolean drawHeaderMargin) throws IOException {
+ drawTitle(title, font, fontSize, tableWidth, height, alignment, freeSpaceForPageBreak, null, drawHeaderMargin);
+ }
+
+ public void drawTitle(final String title, final PDFont font, final int fontSize, final float tableWidth, final float height,
+ final String alignment,
+ final float freeSpaceForPageBreak, final WrappingFunction wrappingFunction, final boolean drawHeaderMargin)
+ throws IOException {
+
+ ensureStreamIsOpen();
+
+ if (isEndOfPage(freeSpaceForPageBreak)) {
+ this.tableContentStream.close();
+ pageBreak();
+ tableStartedAtNewPage = true;
+ }
+
+ if (title == null) {
+ // if you don't have title just use the height of maxTextBox in your
+ // "row"
+ yStart -= height;
+ } else {
+ PageContentStreamOptimized articleTitle = createPdPageContentStream();
+ Paragraph paragraph = new Paragraph(title, font, fontSize, tableWidth, HorizontalAlignment.get(alignment),
+ wrappingFunction);
+ paragraph.setDrawDebug(drawDebug);
+ yStart = paragraph.write(articleTitle, margin, yStart);
+ if (paragraph.getHeight() < height) {
+ yStart -= (height - paragraph.getHeight());
+ }
+
+ articleTitle.close();
+
+ if (drawDebug) {
+ // margin
+ PDStreamUtils.rect(tableContentStream, margin, yStart, width, headerBottomMargin, Color.CYAN);
+ }
+ }
+
+ if (drawHeaderMargin) {
+ yStart -= headerBottomMargin;
+ }
+ }
+
+ public float getWidth() {
+ return width;
+ }
+
+ public Row createRow(final float height) {
+ var row = new Row(this, height);
+ row.setLineSpacing(lineSpacing);
+ this.rows.add(row);
+ return row;
+ }
+
+ public Row createRow(final List cells, final float height) {
+ var row = new Row(this, cells, height);
+ row.setLineSpacing(lineSpacing);
+ this.rows.add(row);
+ return row;
+ }
+
+ /**
+ *
+ * Draws table
+ *
+ *
+ * @return Y position of the table
+ * @throws IOException if underlying stream has problem being written to.
+ *
+ */
+ public float draw() throws IOException {
+ ensureStreamIsOpen();
+
+ for (var row : rows) {
+ if (header.contains(row)) {
+ // check if header row height and first data row height can fit
+ // the page
+ // if not draw them on another side
+ if (isEndOfPage(getMinimumHeight())) {
+ pageBreak();
+ tableStartedAtNewPage = true;
+ }
+ }
+ drawRow(row);
+ }
+
+ endTable();
+ return yStart;
+ }
+
+ private void drawRow(final Row row) throws IOException {
+ // row.getHeight is currently an extremely expensive function so get the value
+ // once during drawing and reuse it, since it will not change during drawing
+ float rowHeight = row.getHeight();
+
+ // if it is not header row or first row in the table then remove row's
+ // top border
+ if (row != header && row != rows.get(0)) {
+ if (!isEndOfPage(rowHeight)) {
+ row.removeTopBorders();
+ }
+ }
+
+ // draw the bookmark
+ if (row.getBookmark() != null) {
+ PDPageXYZDestination bookmarkDestination = new PDPageXYZDestination();
+ bookmarkDestination.setPage(currentPage);
+ bookmarkDestination.setTop((int) yStart);
+ row.getBookmark().setDestination(bookmarkDestination);
+ this.addBookmark(row.getBookmark());
+ }
+
+ // we want to remove the borders as often as possible
+ removeTopBorders = true;
+
+ // check also if we want all borders removed
+ if (allBordersRemoved()) {
+ row.removeAllBorders();
+ }
+
+ if (isEndOfPage(rowHeight) && !header.contains(row)) {
+
+ // Draw line at bottom of table
+ endTable();
+
+ // insert page break
+ pageBreak();
+
+ // redraw all headers on each currentPage
+ if (!header.isEmpty()) {
+ for (var headerRow : header) {
+ drawRow(headerRow);
+ }
+ // after you draw all header rows on next page please keep
+ // removing top borders to avoid double border drawing
+ removeTopBorders = true;
+ } else {
+ // after a page break, we have to ensure that top borders get
+ // drawn
+ removeTopBorders = false;
+ }
+ }
+ // if it is first row in the table, we have to draw the top border
+ if (row == rows.get(0)) {
+ removeTopBorders = false;
+ }
+
+ if (removeTopBorders) {
+ row.removeTopBorders();
+ }
+
+ // if it is header row or first row in the table, we have to draw the
+ // top border
+ if (row == rows.get(0)) {
+ removeTopBorders = false;
+ }
+
+ if (removeTopBorders) {
+ row.removeTopBorders();
+ }
+
+ if (drawLines) {
+ drawVerticalLines(row, rowHeight);
+ }
+
+ if (drawContent) {
+ drawCellContent(row, rowHeight);
+ }
+ }
+
+ /**
+ * Method to switch between the {@link PageProvider} and the abstract method
+ * {@link Table#createPage()}, preferring the {@link PageProvider}.
+ *
+ * Will be removed once {@link #createPage()} is removed.
+ */
+ private PDPage createNewPage() {
+ return pageProvider.nextPage();
+ }
+
+ private PageContentStreamOptimized createPdPageContentStream() throws IOException {
+ return new PageContentStreamOptimized(
+ new PDPageContentStream(getDocument(), getCurrentPage(),
+ PDPageContentStream.AppendMode.APPEND, true));
+ }
+
+ private void drawCellContent(final Row row, final float rowHeight) throws IOException {
+
+ // position into first cell (horizontal)
+ float cursorX = margin;
+ float cursorY;
+
+ for (var cell : row.getCells()) {
+ // remember horizontal cursor position, so we can advance to the
+ // next cell easily later
+ float cellStartX = cursorX;
+ if (cell instanceof ImageCell imageCell) {
+
+ cursorY = yStart - cell.getTopPadding()
+ - (cell.getTopBorderStyle() == null ? 0 : cell.getTopBorderStyle().getWidth());
+
+ // image cell vertical alignment
+ switch (cell.getValign()) {
+ case TOP:
+ break;
+ case MIDDLE:
+ cursorY -= cell.getVerticalFreeSpace() / 2;
+ break;
+ case BOTTOM:
+ cursorY -= cell.getVerticalFreeSpace();
+ break;
+ }
+
+ cursorX += cell.getLeftPadding() + (cell.getLeftBorderStyle() == null ? 0 : cell.getLeftBorderStyle().getWidth());
+
+ // image cell horizontal alignment
+ switch (cell.getAlign()) {
+ case CENTER:
+ cursorX += cell.getHorizontalFreeSpace() / 2;
+ break;
+ case LEFT:
+ break;
+ case RIGHT:
+ cursorX += cell.getHorizontalFreeSpace();
+ break;
+ }
+ imageCell.getImage().draw(document, tableContentStream, cursorX, cursorY);
+
+ if (imageCell.getUrl() != null) {
+ List annotations = currentPage.getAnnotations();
+
+ PDBorderStyleDictionary borderULine = new PDBorderStyleDictionary();
+ borderULine.setStyle(PDBorderStyleDictionary.STYLE_UNDERLINE);
+ borderULine.setWidth(1); // 1 point
+
+ PDAnnotationLink txtLink = new PDAnnotationLink();
+ txtLink.setBorderStyle(borderULine);
+
+ // Set the rectangle containing the link
+ // PDRectangle sets a the x,y and the width and height extend upwards from that!
+ PDRectangle position = new PDRectangle(cursorX, cursorY, (imageCell.getImage().getWidth()), -(imageCell.getImage().getHeight()));
+ txtLink.setRectangle(position);
+
+ // add an action
+ PDActionURI action = new PDActionURI();
+ action.setURI(imageCell.getUrl().toString());
+ txtLink.setAction(action);
+ annotations.add(txtLink);
+ }
+
+ } else if (cell instanceof TableCell tableCell) {
+
+ cursorY = yStart - cell.getTopPadding()
+ - (cell.getTopBorderStyle() != null ? cell.getTopBorderStyle().getWidth() : 0);
+
+ // table cell vertical alignment
+ switch (cell.getValign()) {
+ case TOP:
+ break;
+ case MIDDLE:
+ cursorY -= cell.getVerticalFreeSpace() / 2;
+ break;
+ case BOTTOM:
+ cursorY -= cell.getVerticalFreeSpace();
+ break;
+ }
+
+ cursorX += cell.getLeftPadding() + (cell.getLeftBorderStyle() == null ? 0 : cell.getLeftBorderStyle().getWidth());
+ tableCell.setXPosition(cursorX);
+ tableCell.setYPosition(cursorY);
+ this.tableContentStream.endText();
+ tableCell.draw(currentPage);
+ } else {
+ // no text without font
+ if (cell.getFont() == null) {
+ throw new IllegalArgumentException("Font is null on Cell=" + cell.getText());
+ }
+
+ if (cell.isTextRotated()) {
+ // debugging mode - drawing (default!) padding of rotated
+ // cells
+ // left
+ // PDStreamUtils.rect(tableContentStream, cursorX, yStart,
+ // 5, cell.getHeight(), Color.GREEN);
+ // top
+ // PDStreamUtils.rect(tableContentStream, cursorX, yStart,
+ // cell.getWidth(), 5 , Color.GREEN);
+ // bottom
+ // PDStreamUtils.rect(tableContentStream, cursorX, yStart -
+ // cell.getHeight(), cell.getWidth(), -5 , Color.GREEN);
+ // right
+ // PDStreamUtils.rect(tableContentStream, cursorX +
+ // cell.getWidth() - 5, yStart, 5, cell.getHeight(),
+ // Color.GREEN);
+
+ cursorY = yStart - cell.getInnerHeight() - cell.getTopPadding()
+ - (cell.getTopBorderStyle() != null ? cell.getTopBorderStyle().getWidth() : 0);
+
+ switch (cell.getAlign()) {
+ case CENTER:
+ cursorY += cell.getVerticalFreeSpace() / 2;
+ break;
+ case LEFT:
+ break;
+ case RIGHT:
+ cursorY += cell.getVerticalFreeSpace();
+ break;
+ }
+ // respect left padding and descend by font height to get
+ // position of the base line
+ cursorX += cell.getLeftPadding()
+ + (cell.getLeftBorderStyle() == null ? 0 : cell.getLeftBorderStyle().getWidth())
+ + FontUtils.getHeight(cell.getFont(), cell.getFontSize())
+ + FontUtils.getDescent(cell.getFont(), cell.getFontSize());
+
+ switch (cell.getValign()) {
+ case TOP:
+ break;
+ case MIDDLE:
+ cursorX += cell.getHorizontalFreeSpace() / 2;
+ break;
+ case BOTTOM:
+ cursorX += cell.getHorizontalFreeSpace();
+ break;
+ }
+ // make tokenize method just in case
+ cell.getParagraph().getLines();
+ } else {
+ // debugging mode - drawing (default!) padding of rotated
+ // cells
+ // left
+ // PDStreamUtils.rect(tableContentStream, cursorX, yStart,
+ // 5, cell.getHeight(), Color.RED);
+ // top
+ // PDStreamUtils.rect(tableContentStream, cursorX, yStart,
+ // cell.getWidth(), 5 , Color.RED);
+ // bottom
+ // PDStreamUtils.rect(tableContentStream, cursorX, yStart -
+ // cell.getHeight(), cell.getWidth(), -5 , Color.RED);
+ // right
+ // PDStreamUtils.rect(tableContentStream, cursorX +
+ // cell.getWidth() - 5, yStart, 5, cell.getHeight(),
+ // Color.RED);
+
+ // position at top of current cell descending by font height
+ // - font descent, because we are
+ // positioning the base line here
+ cursorY = yStart - cell.getTopPadding() - FontUtils.getHeight(cell.getFont(), cell.getFontSize())
+ - FontUtils.getDescent(cell.getFont(), cell.getFontSize())
+ - (cell.getTopBorderStyle() == null ? 0 : cell.getTopBorderStyle().getWidth());
+
+ if (drawDebug) {
+ // @formatter:off
+ // top padding
+ PDStreamUtils.rect(tableContentStream, cursorX + (cell.getLeftBorderStyle() == null ? 0 : cell.getLeftBorderStyle().getWidth()), yStart - (cell.getTopBorderStyle() == null ? 0 : cell.getTopBorderStyle().getWidth()), cell.getWidth() - (cell.getLeftBorderStyle() == null ? 0 : cell.getLeftBorderStyle().getWidth()) - (cell.getRightBorderStyle() == null ? 0 : cell.getRightBorderStyle().getWidth()), cell.getTopPadding(), Color.RED);
+ // bottom padding
+ PDStreamUtils.rect(tableContentStream, cursorX + (cell.getLeftBorderStyle() == null ? 0 : cell.getLeftBorderStyle().getWidth()), yStart - cell.getHeight() + (cell.getBottomBorderStyle() == null ? 0 : cell.getBottomBorderStyle().getWidth()) + cell.getBottomPadding(), cell.getWidth() - (cell.getLeftBorderStyle() == null ? 0 : cell.getLeftBorderStyle().getWidth()) - (cell.getRightBorderStyle() == null ? 0 : cell.getRightBorderStyle().getWidth()), cell.getBottomPadding(), Color.RED);
+ // left padding
+ PDStreamUtils.rect(tableContentStream, cursorX + (cell.getLeftBorderStyle() == null ? 0 : cell.getLeftBorderStyle().getWidth()), yStart - (cell.getTopBorderStyle() == null ? 0 : cell.getTopBorderStyle().getWidth()), cell.getLeftPadding(), cell.getHeight() - (cell.getTopBorderStyle() == null ? 0 : cell.getTopBorderStyle().getWidth()) - (cell.getBottomBorderStyle() == null ? 0 : cell.getBottomBorderStyle().getWidth()), Color.RED);
+ // right padding
+ PDStreamUtils.rect(tableContentStream, cursorX + cell.getWidth() - (cell.getRightBorderStyle() == null ? 0 : cell.getRightBorderStyle().getWidth()), yStart - (cell.getTopBorderStyle() == null ? 0 : cell.getTopBorderStyle().getWidth()), -cell.getRightPadding(), cell.getHeight() - (cell.getTopBorderStyle() == null ? 0 : cell.getTopBorderStyle().getWidth()) - (cell.getBottomBorderStyle() == null ? 0 : cell.getBottomBorderStyle().getWidth()), Color.RED);
+ // @formatter:on
+ }
+
+ // respect left padding
+ cursorX += cell.getLeftPadding()
+ + (cell.getLeftBorderStyle() == null ? 0 : cell.getLeftBorderStyle().getWidth());
+
+ // the widest text does not fill the inner width of the
+ // cell? no
+ // problem, just add it ;)
+ switch (cell.getAlign()) {
+ case CENTER:
+ cursorX += cell.getHorizontalFreeSpace() / 2;
+ break;
+ case LEFT:
+ break;
+ case RIGHT:
+ cursorX += cell.getHorizontalFreeSpace();
+ break;
+ }
+
+ switch (cell.getValign()) {
+ case TOP:
+ break;
+ case MIDDLE:
+ cursorY -= cell.getVerticalFreeSpace() / 2;
+ break;
+ case BOTTOM:
+ cursorY -= cell.getVerticalFreeSpace();
+ break;
+ }
+
+ if (cell.getUrl() != null) {
+ List annotations = currentPage.getAnnotations();
+ PDAnnotationLink txtLink = new PDAnnotationLink();
+
+ // Set the rectangle containing the link
+ // PDRectangle sets a the x,y and the width and height extend upwards from that!
+ PDRectangle position = new PDRectangle(cursorX - 5, cursorY + 10, (cell.getWidth()), -(cell.getHeight()));
+ txtLink.setRectangle(position);
+
+ // add an action
+ PDActionURI action = new PDActionURI();
+ action.setURI(cell.getUrl().toString());
+ txtLink.setAction(action);
+ annotations.add(txtLink);
+ }
+
+ }
+
+ // remember this horizontal position, as it is the anchor for
+ // each
+ // new line
+ float lineStartX = cursorX;
+ float lineStartY = cursorY;
+
+ this.tableContentStream.setNonStrokingColor(cell.getTextColor());
+
+ int italicCounter = 0;
+ int boldCounter = 0;
+
+ this.tableContentStream.setRotated(cell.isTextRotated());
+
+ // print all lines of the cell
+ for (Map.Entry> entry : cell.getParagraph().getMapLineTokens().entrySet()) {
+
+ // calculate the width of this line
+ float freeSpaceWithinLine = cell.getParagraph().getMaxLineWidth()
+ - cell.getParagraph().getLineWidth(entry.getKey());
+ // TODO: need to implemented rotated text yo!
+ if (cell.isTextRotated()) {
+ cursorY = lineStartY;
+ switch (cell.getAlign()) {
+ case CENTER:
+ cursorY += freeSpaceWithinLine / 2;
+ break;
+ case LEFT:
+ break;
+ case RIGHT:
+ cursorY += freeSpaceWithinLine;
+ break;
+ }
+ } else {
+ cursorX = lineStartX;
+ switch (cell.getAlign()) {
+ case CENTER:
+ cursorX += freeSpaceWithinLine / 2;
+ break;
+ case LEFT:
+ // it doesn't matter because X position is always
+ // the same
+ // as row above
+ break;
+ case RIGHT:
+ cursorX += freeSpaceWithinLine;
+ break;
+ }
+ }
+
+ // iterate through tokens in current line
+ PDFont currentFont = cell.getParagraph().getFont(false, false);
+ for (Token token : entry.getValue()) {
+ switch (token.type()) {
+ case OPEN_TAG:
+ if ("b".equals(token.text())) {
+ boldCounter++;
+ } else if ("i".equals(token.text())) {
+ italicCounter++;
+ }
+ break;
+ case CLOSE_TAG:
+ if ("b".equals(token.text())) {
+ boldCounter = Math.max(boldCounter - 1, 0);
+ } else if ("i".equals(token.text())) {
+ italicCounter = Math.max(italicCounter - 1, 0);
+ }
+ break;
+ case PADDING:
+ cursorX += Float.parseFloat(token.text());
+ break;
+ case ORDERING:
+ currentFont = cell.getParagraph().getFont(boldCounter > 0, italicCounter > 0);
+ this.tableContentStream.setFont(currentFont, cell.getFontSize());
+ if (cell.isTextRotated()) {
+ tableContentStream.newLineAt(cursorX, cursorY);
+ this.tableContentStream.showText(token.text());
+ cursorY += token.getWidth(currentFont) / 1000 * cell.getFontSize();
+ } else {
+ this.tableContentStream.newLineAt(cursorX, cursorY);
+ this.tableContentStream.showText(token.text());
+ cursorX += token.getWidth(currentFont) / 1000 * cell.getFontSize();
+ }
+ break;
+ case BULLET:
+ float widthOfSpace = currentFont.getSpaceWidth();
+ float halfHeight = FontUtils.getHeight(currentFont, cell.getFontSize()) / 2;
+ if (cell.isTextRotated()) {
+ PDStreamUtils.rect(tableContentStream, cursorX + halfHeight, cursorY,
+ token.getWidth(currentFont) / 1000 * cell.getFontSize(),
+ widthOfSpace / 1000 * cell.getFontSize(),
+ cell.getTextColor());
+ // move cursorY for two characters (one for
+ // bullet, one for space after bullet)
+ cursorY += 2 * widthOfSpace / 1000 * cell.getFontSize();
+ } else {
+ PDStreamUtils.rect(tableContentStream, cursorX, cursorY + halfHeight,
+ token.getWidth(currentFont) / 1000 * cell.getFontSize(),
+ widthOfSpace / 1000 * cell.getFontSize(),
+ cell.getTextColor());
+ // move cursorX for two characters (one for
+ // bullet, one for space after bullet)
+ cursorX += 2 * widthOfSpace / 1000 * cell.getFontSize();
+ }
+ break;
+ case TEXT:
+ currentFont = cell.getParagraph().getFont(boldCounter > 0, italicCounter > 0);
+ this.tableContentStream.setFont(currentFont, cell.getFontSize());
+ if (cell.isTextRotated()) {
+ tableContentStream.newLineAt(cursorX, cursorY);
+ this.tableContentStream.showText(token.text());
+ cursorY += token.getWidth(currentFont) / 1000 * cell.getFontSize();
+ } else {
+ try {
+ this.tableContentStream.newLineAt(cursorX, cursorY);
+ this.tableContentStream.showText(token.text());
+ cursorX += token.getWidth(currentFont) / 1000 * cell.getFontSize();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ break;
+ case POSSIBLE_WRAP_POINT, WRAP_POINT:
+ break;
+ }
+ }
+ if (cell.isTextRotated()) {
+ cursorX = cursorX + cell.getParagraph().getFontHeight() * cell.getLineSpacing();
+ } else {
+ cursorY = cursorY - cell.getParagraph().getFontHeight() * cell.getLineSpacing();
+ }
+ }
+ }
+
+ PDRectangle rectangle = new PDRectangle(cellStartX, yStart - cell.getHeight(), cell.getWidth(), cell.getHeight());
+ cell.notifyContentDrawnListeners(getDocument(), getCurrentPage(), rectangle);
+
+ // set cursor to the start of this cell plus its width to advance to
+ // the next cell
+ cursorX = cellStartX + cell.getWidth();
+ }
+ // Set Y position for next row
+ yStart = yStart - rowHeight;
+ }
+
+ private void drawVerticalLines(final Row row, final float rowHeight) throws IOException {
+ float xStart = margin;
+
+ Iterator cellIterator = row.getCells().iterator();
+ while (cellIterator.hasNext()) {
+ var cell = cellIterator.next();
+
+ float cellWidth = cellIterator.hasNext()
+ ? cell.getWidth()
+ : this.width - (xStart - margin);
+ fillCellColor(cell, yStart, xStart, rowHeight, cellWidth);
+
+ drawCellBorders(rowHeight, cell, xStart);
+
+ xStart += cellWidth;
+ }
+
+ }
+
+ private void drawCellBorders(final float rowHeight, final Cell cell, final float xStart) throws IOException {
+
+ float yEnd = yStart - rowHeight;
+
+ // top
+ LineStyle topBorder = cell.getTopBorderStyle();
+ if (topBorder != null) {
+ float y = yStart - topBorder.getWidth() / 2;
+ drawLine(xStart, y, xStart + cell.getWidth(), y, topBorder);
+ }
+
+ // right
+ LineStyle rightBorder = cell.getRightBorderStyle();
+ if (rightBorder != null) {
+ float x = xStart + cell.getWidth() - rightBorder.getWidth() / 2;
+ drawLine(x, yStart - (topBorder == null ? 0 : topBorder.getWidth()), x, yEnd, rightBorder);
+ }
+
+ // bottom
+ LineStyle bottomBorder = cell.getBottomBorderStyle();
+ if (bottomBorder != null) {
+ float y = yEnd + bottomBorder.getWidth() / 2;
+ drawLine(xStart, y, xStart + cell.getWidth() - (rightBorder == null ? 0 : rightBorder.getWidth()), y,
+ bottomBorder);
+ }
+
+ // left
+ LineStyle leftBorder = cell.getLeftBorderStyle();
+ if (leftBorder != null) {
+ float x = xStart + leftBorder.getWidth() / 2;
+ drawLine(x, yStart, x, yEnd + (bottomBorder == null ? 0 : bottomBorder.getWidth()), leftBorder);
+ }
+
+ }
+
+ private void drawLine(final float xStart, final float yStart, final float xEnd, final float yEnd, final LineStyle border) throws IOException {
+ PDStreamUtils.setLineStyles(tableContentStream, border);
+ tableContentStream.moveTo(xStart, yStart);
+ tableContentStream.lineTo(xEnd, yEnd);
+ tableContentStream.stroke();
+ }
+
+ private void fillCellColor(final Cell cell, float yStart, final float xStart, final float rowHeight, final float cellWidth)
+ throws IOException {
+
+ if (cell.getFillColor() != null) {
+ this.tableContentStream.setNonStrokingColor(cell.getFillColor());
+
+ // y start is bottom pos
+ yStart = yStart - rowHeight;
+ float height = rowHeight - (cell.getTopBorderStyle() == null ? 0 : cell.getTopBorderStyle().getWidth());
+
+ this.tableContentStream.addRect(xStart, yStart, cellWidth, height);
+ this.tableContentStream.fill();
+ }
+ }
+
+ private void ensureStreamIsOpen() throws IOException {
+ if (tableContentStream == null) {
+ tableContentStream = createPdPageContentStream();
+ }
+ }
+
+ private void endTable() throws IOException {
+ this.tableContentStream.close();
+ }
+
+ public PDPage getCurrentPage() {
+ if (this.currentPage == null) {
+ throw new NullPointerException("No current page defined.");
+ }
+ return this.currentPage;
+ }
+
+ private boolean isEndOfPage(final float freeSpaceForPageBreak) {
+ float currentY = yStart - freeSpaceForPageBreak;
+ boolean isEndOfPage = currentY <= pageBottomMargin;
+ if (isEndOfPage) {
+ setTableIsBroken(true);
+ }
+ return isEndOfPage;
+ }
+
+ private void pageBreak() throws IOException {
+ tableContentStream.close();
+ this.yStart = yStartNewPage - pageTopMargin;
+ this.currentPage = createNewPage();
+ this.tableContentStream = createPdPageContentStream();
+ }
+
+ private void addBookmark(final PDOutlineItem bookmark) {
+ if (bookmarks == null) {
+ bookmarks = new ArrayList<>();
+ }
+ bookmarks.add(bookmark);
+ }
+
+ public List getBookmarks() {
+ return bookmarks;
+ }
+
+ /**
+ * Calculate height of all table cells (essentially, table height).
+ *
+ * IMPORTANT: Doesn't acknowledge possible page break. Use with caution.
+ * @return {@link Table}'s height
+ */
+ public float getHeaderAndDataHeight() {
+ float height = 0;
+ for (var row : rows) {
+ height += row.getHeight();
+ }
+ return height;
+ }
+
+ /**
+ * Calculates minimum table height that needs to be drawn (all header rows +
+ * first data row heights).
+ * @return height
+ */
+ public float getMinimumHeight() {
+ float height = 0.0f;
+ int firstDataRowIndex = 0;
+ if (!header.isEmpty()) {
+ for (var headerRow : header) {
+ // count all header rows height
+ height += headerRow.getHeight();
+ firstDataRowIndex++;
+ }
+ }
+
+ if (rows.size() > firstDataRowIndex) {
+ height += rows.get(firstDataRowIndex).getHeight();
+ }
+
+ return height;
+ }
+
+ /**
+ * Setting current row as table header row
+ * @param row The row that would be added as table's header row
+ */
+ public void addHeaderRow(final Row row) {
+ this.header.add(row);
+ row.setHeaderRow(true);
+ }
+
+ /**
+ * Retrieves last table's header row
+ * @return header row
+ */
+ public Row getHeader() {
+ if (header == null) {
+ throw new IllegalArgumentException("Header Row not set on table");
+ }
+
+ return header.get(header.size() - 1);
+ }
+
+ public float getMargin() {
+ return margin;
+ }
+
+ protected void setYStart(final float yStart) {
+ this.yStart = yStart;
+ }
+
+ public boolean isDrawDebug() {
+ return drawDebug;
+ }
+
+ public void setDrawDebug(final boolean drawDebug) {
+ this.drawDebug = drawDebug;
+ }
+
+ public boolean tableIsBroken() {
+ return tableIsBroken;
+ }
+
+ public void setTableIsBroken(final boolean tableIsBroken) {
+ this.tableIsBroken = tableIsBroken;
+ }
+
+ public List getRows() {
+ return rows;
+ }
+
+ public boolean tableStartedAtNewPage() {
+ return tableStartedAtNewPage;
+ }
+
+ public float getLineSpacing() {
+ return lineSpacing;
+ }
+
+ public void setLineSpacing(final float lineSpacing) {
+ this.lineSpacing = lineSpacing;
+ }
+
+ public boolean allBordersRemoved() {
+ return removeAllBorders;
+ }
+
+ public void removeAllBorders(final boolean removeAllBorders) {
+ this.removeAllBorders = removeAllBorders;
+ }
+
+}
diff --git a/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/TableCell.java b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/TableCell.java
new file mode 100644
index 00000000000..76e916ebd65
--- /dev/null
+++ b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/TableCell.java
@@ -0,0 +1,443 @@
+/*
+ * 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.causeway.extensions.tabular.pdf.factory.internal;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+import org.apache.pdfbox.pdmodel.font.PDFont;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+
+import org.apache.causeway.extensions.tabular.pdf.factory.FontUtils;
+import org.apache.causeway.extensions.tabular.pdf.factory.HorizontalAlignment;
+import org.apache.causeway.extensions.tabular.pdf.factory.VerticalAlignment;
+
+import lombok.extern.log4j.Log4j2;
+
+@Log4j2
+class TableCell extends Cell {
+
+ private final String tableData;
+ private final float width;
+ private float yStart;
+ private float xStart;
+ private float height = 0;
+ private final PDDocument doc;
+ private final PDPage page;
+ private float marginBetweenElementsY = FontUtils.getHeight(getFont(), getFontSize());
+ private final HorizontalAlignment align;
+ private PageContentStreamOptimized tableCellContentStream;
+
+ // page margins
+ private final float pageTopMargin;
+ private final float pageBottomMargin;
+ // default title fonts
+ private int tableTitleFontSize = 8;
+
+ TableCell(final Row row, final float width, final String tableData, final boolean isCalculated, final PDDocument document,
+ final PDPage page,
+ final float yStart, final float pageTopMargin, final float pageBottomMargin) {
+ this(row, width, tableData, isCalculated, document, page, yStart, pageTopMargin, pageBottomMargin,
+ HorizontalAlignment.LEFT, VerticalAlignment.TOP);
+ }
+
+ TableCell(final Row row, final float width, final String tableData, final boolean isCalculated, final PDDocument document,
+ final PDPage page,
+ final float yStart, final float pageTopMargin, final float pageBottomMargin, final HorizontalAlignment align,
+ final VerticalAlignment valign) {
+ super(row, width, tableData, isCalculated);
+ this.tableData = tableData;
+ this.width = width * row.getWidth() / 100;
+ this.doc = document;
+ this.page = page;
+ this.yStart = yStart;
+ this.pageTopMargin = pageTopMargin;
+ this.pageBottomMargin = pageBottomMargin;
+ this.align = align;
+ fillTable();
+ }
+
+ /**
+ * This method just fills up the table's with her content for proper table
+ * cell height calculation. Position of the table (x,y) is not relevant
+ * here.
+ *
+ * NOTE: if entire row is not header row then use bold instead header cell (
+ * {@code
+ *
+ })
+ */
+ public void fillTable() {
+ try {
+ // please consider the cell's paddings
+ float tableWidth = this.width - getLeftPadding() - getRightPadding();
+ tableCellContentStream = new PageContentStreamOptimized(new PDPageContentStream(doc, page,
+ PDPageContentStream.AppendMode.APPEND, true));
+ // check if there is some additional text outside inner table
+ String[] outerTableText = tableData.split("");
+ for (String chunkie : chunks) {
+ if (chunkie.contains(")
+ row.setHeaderRow(true);
+ }
+ int columnsSize = tableHasHeaderColumns ? tableHeaderCols.size() : tableCols.size();
+ // calculate how much really columns do you have (including
+ // colspans!)
+ for (Element col : tableHasHeaderColumns ? tableHeaderCols : tableCols) {
+ if (col.attr("colspan") != null && !col.attr("colspan").isEmpty()) {
+ columnsSize += Integer.parseInt(col.attr("colspan")) - 1;
+ }
+ }
+ for (Element col : tableHasHeaderColumns ? tableHeaderCols : tableCols) {
+ if (col.attr("colspan") != null && !col.attr("colspan").isEmpty()) {
+ row.createCell(
+ tableWidth / columnsSize * Integer.parseInt(col.attr("colspan")) / row.getWidth() * 100,
+ col.html().replace("&", "&"));
+ } else {
+ row.createCell(tableWidth / columnsSize / row.getWidth() * 100,
+ col.html().replace("&", "&"));
+ }
+ }
+ yStart -= row.getHeight();
+ }
+ if (drawTable) {
+ table.draw();
+ }
+
+ height += table.getHeaderAndDataHeight() + marginBetweenElementsY;
+ }
+
+ /**
+ * Method provides writing or height calculation of possible outer text
+ * @param paragraph
+ * Paragraph that needs to be written or whose height needs to be
+ * calculated
+ * @param onlyCalculateHeight
+ * if true the given paragraph will not be drawn
+ * just his height will be calculated.
+ * @return Y position after calculating/writing given paragraph
+ */
+ private float writeOrCalculateParagraph(final Paragraph paragraph, final boolean onlyCalculateHeight) throws IOException {
+ int boldCounter = 0;
+ int italicCounter = 0;
+
+ if (!onlyCalculateHeight) {
+ tableCellContentStream.setRotated(isTextRotated());
+ }
+
+ // position at top of current cell descending by font height - font
+ // descent, because we are positioning the base line here
+ float cursorY = yStart - getTopPadding() - FontUtils.getHeight(getFont(), getFontSize())
+ - FontUtils.getDescent(getFont(), getFontSize()) - (getTopBorderStyle() == null ? 0 : getTopBorderStyle().getWidth());
+ float cursorX = xStart;
+
+ // loop through tokens
+ for (Map.Entry> entry : paragraph.getMapLineTokens().entrySet()) {
+
+ // calculate the width of this line
+ float freeSpaceWithinLine = paragraph.getMaxLineWidth() - paragraph.getLineWidth(entry.getKey());
+ if (isTextRotated()) {
+ switch (align) {
+ case CENTER:
+ cursorY += freeSpaceWithinLine / 2;
+ break;
+ case LEFT:
+ break;
+ case RIGHT:
+ cursorY += freeSpaceWithinLine;
+ break;
+ }
+ } else {
+ switch (align) {
+ case CENTER:
+ cursorX += freeSpaceWithinLine / 2;
+ break;
+ case LEFT:
+ // it doesn't matter because X position is always the same
+ // as row above
+ break;
+ case RIGHT:
+ cursorX += freeSpaceWithinLine;
+ break;
+ }
+ }
+
+ // iterate through tokens in current line
+ PDFont currentFont = paragraph.getFont(false, false);
+ for (Token token : entry.getValue()) {
+ switch (token.type()) {
+ case OPEN_TAG:
+ if ("b".equals(token.text())) {
+ boldCounter++;
+ } else if ("i".equals(token.text())) {
+ italicCounter++;
+ }
+ break;
+ case CLOSE_TAG:
+ if ("b".equals(token.text())) {
+ boldCounter = Math.max(boldCounter - 1, 0);
+ } else if ("i".equals(token.text())) {
+ italicCounter = Math.max(italicCounter - 1, 0);
+ }
+ break;
+ case PADDING:
+ cursorX += Float.parseFloat(token.text());
+ break;
+ case ORDERING:
+ currentFont = paragraph.getFont(boldCounter > 0, italicCounter > 0);
+ tableCellContentStream.setFont(currentFont, getFontSize());
+ if (isTextRotated()) {
+ // if it is not calculation then draw it
+ if (!onlyCalculateHeight) {
+ tableCellContentStream.newLineAt(cursorX, cursorY);
+ tableCellContentStream.showText(token.text());
+ }
+ cursorY += token.getWidth(currentFont) / 1000 * getFontSize();
+ } else {
+ // if it is not calculation then draw it
+ if (!onlyCalculateHeight) {
+ tableCellContentStream.newLineAt(cursorX, cursorY);
+ tableCellContentStream.showText(token.text());
+ }
+ cursorX += token.getWidth(currentFont) / 1000 * getFontSize();
+ }
+ break;
+ case BULLET:
+ float widthOfSpace = currentFont.getSpaceWidth();
+ float halfHeight = FontUtils.getHeight(currentFont, getFontSize()) / 2;
+ if (isTextRotated()) {
+ if (!onlyCalculateHeight) {
+ PDStreamUtils.rect(tableCellContentStream, cursorX + halfHeight, cursorY,
+ token.getWidth(currentFont) / 1000 * getFontSize(),
+ widthOfSpace / 1000 * getFontSize(), getTextColor());
+ }
+ // move cursorY for two characters (one for bullet, one
+ // for space after bullet)
+ cursorY += 2 * widthOfSpace / 1000 * getFontSize();
+ } else {
+ if (!onlyCalculateHeight) {
+ PDStreamUtils.rect(tableCellContentStream, cursorX, cursorY + halfHeight,
+ token.getWidth(currentFont) / 1000 * getFontSize(),
+ widthOfSpace / 1000 * getFontSize(), getTextColor());
+ }
+ // move cursorX for two characters (one for bullet, one
+ // for space after bullet)
+ cursorX += 2 * widthOfSpace / 1000 * getFontSize();
+ }
+ break;
+ case TEXT:
+ currentFont = paragraph.getFont(boldCounter > 0, italicCounter > 0);
+ tableCellContentStream.setFont(currentFont, getFontSize());
+ if (isTextRotated()) {
+ if (!onlyCalculateHeight) {
+ tableCellContentStream.newLineAt(cursorX, cursorY);
+ tableCellContentStream.showText(token.text());
+ }
+ cursorY += token.getWidth(currentFont) / 1000 * getFontSize();
+ } else {
+ if (!onlyCalculateHeight) {
+ tableCellContentStream.newLineAt(cursorX, cursorY);
+ tableCellContentStream.showText(token.text());
+ }
+ cursorX += token.getWidth(currentFont) / 1000 * getFontSize();
+ }
+ break;
+ case POSSIBLE_WRAP_POINT, WRAP_POINT:
+ break;
+ }
+ }
+ // reset
+ cursorX = xStart;
+ cursorY -= FontUtils.getHeight(getFont(), getFontSize());
+ }
+ return cursorY;
+ }
+
+ /**
+ * This method draw table cell with proper X,Y position which are determined
+ * in {@link Table#draw()} method
+ *
+ * NOTE: if entire row is not header row then use bold instead header cell (
+ * {@code
+ *
+ })
+ * @param page
+ * {@link PDPage} where table cell be written on
+ */
+ public void draw(final PDPage page) {
+ try {
+ // please consider the cell's paddings
+ float tableWidth = this.width - getLeftPadding() - getRightPadding();
+ tableCellContentStream = new PageContentStreamOptimized(new PDPageContentStream(doc, page,
+ PDPageContentStream.AppendMode.APPEND, true));
+ // check if there is some additional text outside inner table
+ String[] outerTableText = tableData.split("");
+ for (String chunkie : chunks) {
+ if (chunkie.contains(" cache) {
+
+ public enum TokenType {
+ TEXT, POSSIBLE_WRAP_POINT, WRAP_POINT, OPEN_TAG, CLOSE_TAG, PADDING, BULLET, ORDERING
+ }
+
+ public static Token text(final String text) {
+ return new Token(TokenType.TEXT, text, new ConcurrentHashMap<>());
+ }
+
+ public Token(final TokenType type, final String text) {
+ this(type, text, new ConcurrentHashMap<>());
+ }
+
+ public float getWidth(final PDFont font) throws IOException {
+ return cache.computeIfAbsent(font, this::computeWidth);
+ }
+
+ private float computeWidth(final PDFont font) {
+ try {
+ return font.getStringWidth(text);
+ } catch (Exception e) {
+ // if text contains characters that are unavailable with given font, fallback to an arbitrary placeholder character
+ try {
+ return font.getStringWidth("X") * text.length();
+ } catch (IOException e1) {
+ return 1.f;
+ }
+ }
+ }
+
+}
diff --git a/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/Tokenizer.java b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/Tokenizer.java
new file mode 100644
index 00000000000..ada68963720
--- /dev/null
+++ b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/Tokenizer.java
@@ -0,0 +1,330 @@
+/*
+ * 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.causeway.extensions.tabular.pdf.factory.internal;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Stack;
+
+import org.apache.causeway.extensions.tabular.pdf.factory.internal.Token.TokenType;
+
+import lombok.experimental.UtilityClass;
+
+@UtilityClass
+class Tokenizer {
+
+ private static final Token OPEN_TAG_I = new Token(TokenType.OPEN_TAG, "i");
+ private static final Token OPEN_TAG_B = new Token(TokenType.OPEN_TAG, "b");
+ private static final Token OPEN_TAG_OL = new Token(TokenType.OPEN_TAG, "ol");
+ private static final Token OPEN_TAG_UL = new Token(TokenType.OPEN_TAG, "ul");
+ private static final Token CLOSE_TAG_I = new Token(TokenType.CLOSE_TAG, "i");
+ private static final Token CLOSE_TAG_B = new Token(TokenType.CLOSE_TAG, "b");
+ private static final Token CLOSE_TAG_OL = new Token(TokenType.CLOSE_TAG, "ol");
+ private static final Token CLOSE_TAG_UL = new Token(TokenType.CLOSE_TAG, "ul");
+ private static final Token CLOSE_TAG_P = new Token(TokenType.CLOSE_TAG, "p");
+ private static final Token CLOSE_TAG_LI = new Token(TokenType.CLOSE_TAG, "li");
+ private static final Token POSSIBLE_WRAP_POINT = new Token(TokenType.POSSIBLE_WRAP_POINT, "");
+ private static final Token WRAP_POINT_P = new Token(TokenType.WRAP_POINT, "p");
+ private static final Token WRAP_POINT_LI = new Token(TokenType.WRAP_POINT, "li");
+ private static final Token WRAP_POINT_BR = new Token(TokenType.WRAP_POINT, "br");
+
+ public static List tokenize(final String text, final WrappingFunction wrappingFunction) {
+ if(text == null) return Collections.emptyList();
+
+ final List tokens = new ArrayList<>();
+ final Stack possibleWrapPoints = wrappingFunction == null
+ ? findWrapPoints(text)
+ : findWrapPointsWithFunction(text, wrappingFunction);
+ int textIndex = 0;
+ final StringBuilder sb = new StringBuilder();
+ // taking first wrap point
+ Integer currentWrapPoint = possibleWrapPoints.pop();
+ while (textIndex < text.length()) {
+ if (textIndex == currentWrapPoint) {
+ if (sb.length() > 0) {
+ tokens.add(Token.text(sb.toString()));
+ sb.delete(0, sb.length());
+ }
+ tokens.add(POSSIBLE_WRAP_POINT);
+ currentWrapPoint = possibleWrapPoints.pop();
+ }
+ final char c = text.charAt(textIndex);
+ switch (c) {
+ case '<':
+ boolean consumed = false;
+ if (textIndex < text.length() - 2) {
+ final char lookahead1 = text.charAt(textIndex + 1);
+ final char lookahead2 = text.charAt(textIndex + 2);
+ if ('i' == lookahead1 && '>' == lookahead2) {
+ //
+ if (sb.length() > 0) {
+ tokens.add(Token.text(sb.toString()));
+ // clean string builder
+ sb.delete(0, sb.length());
+ }
+ tokens.add(OPEN_TAG_I);
+ textIndex += 2;
+ consumed = true;
+ } else if ('b' == lookahead1 && '>' == lookahead2) {
+ //
+ if (sb.length() > 0) {
+ tokens.add(Token.text(sb.toString()));
+ // clean string builder
+ sb.delete(0, sb.length());
+ }
+ tokens.add(OPEN_TAG_B);
+ textIndex += 2;
+ consumed = true;
+ } else if ('b' == lookahead1 && 'r' == lookahead2) {
+ if (textIndex < text.length() - 3) {
+ //
+ final char lookahead3 = text.charAt(textIndex + 3);
+ if (lookahead3 == '>') {
+ if (sb.length() > 0) {
+ tokens.add(Token.text(sb.toString()));
+ // clean string builder
+ sb.delete(0, sb.length());
+ }
+ tokens.add(WRAP_POINT_BR);
+ // normal notation
+ textIndex += 3;
+ consumed = true;
+ } else if (textIndex < text.length() - 4) {
+ //
+ final char lookahead4 = text.charAt(textIndex + 4);
+ if (lookahead3 == '/' && lookahead4 == '>') {
+ if (sb.length() > 0) {
+ tokens.add(Token.text(sb.toString()));
+ // clean string builder
+ sb.delete(0, sb.length());
+ }
+ tokens.add(WRAP_POINT_BR);
+ // normal notation
+ textIndex += 4;
+ consumed = true;
+ } else if (textIndex < text.length() - 5) {
+ final char lookahead5 = text.charAt(textIndex + 5);
+ if (lookahead3 == ' ' && lookahead4 == '/' && lookahead5 == '>') {
+ if (sb.length() > 0) {
+ tokens.add(Token.text(sb.toString()));
+ // clean string builder
+ sb.delete(0, sb.length());
+ }
+ tokens.add(WRAP_POINT_BR);
+ // in case it is notation
+ textIndex += 5;
+ consumed = true;
+ }
+ }
+ }
+ }
+ } else if ('p' == lookahead1 && '>' == lookahead2) {
+ //
+ if (sb.length() > 0) {
+ tokens.add(Token.text(sb.toString()));
+ // clean string builder
+ sb.delete(0, sb.length());
+ }
+ tokens.add(WRAP_POINT_P);
+ textIndex += 2;
+ consumed = true;
+ } else if ('o' == lookahead1 && 'l' == lookahead2) {
+ //
+ if (textIndex < text.length() - 3) {
+ final char lookahead3 = text.charAt(textIndex + 3);
+ if (lookahead3 == '>') {
+ if (sb.length() > 0) {
+ tokens.add(Token.text(sb.toString()));
+ // clean string builder
+ sb.delete(0, sb.length());
+ }
+ tokens.add(OPEN_TAG_OL);
+ textIndex += 3;
+ consumed = true;
+ }
+ }
+ } else if ('u' == lookahead1 && 'l' == lookahead2) {
+ //
+ if (textIndex < text.length() - 3) {
+ final char lookahead3 = text.charAt(textIndex + 3);
+ if (lookahead3 == '>') {
+ if (sb.length() > 0) {
+ tokens.add(Token.text(sb.toString()));
+ // clean string builder
+ sb.delete(0, sb.length());
+ }
+ tokens.add(OPEN_TAG_UL);
+ textIndex += 3;
+ consumed = true;
+ }
+ }
+ } else if ('l' == lookahead1 && 'i' == lookahead2) {
+ // -
+ if (textIndex < text.length() - 3) {
+ final char lookahead3 = text.charAt(textIndex + 3);
+ if (lookahead3 == '>') {
+ if (sb.length() > 0) {
+ tokens.add(Token.text(sb.toString()));
+ // clean string builder
+ sb.delete(0, sb.length());
+ }
+ tokens.add(WRAP_POINT_LI);
+ textIndex += 3;
+ consumed = true;
+ }
+ }
+ } else if ('/' == lookahead1) {
+ // one character tags
+ if (textIndex < text.length() - 3) {
+ final char lookahead3 = text.charAt(textIndex + 3);
+ if ('>' == lookahead3) {
+ if ('i' == lookahead2) {
+ //
+ if (sb.length() > 0) {
+ tokens.add(Token.text(sb.toString()));
+ sb.delete(0, sb.length());
+ }
+ tokens.add(CLOSE_TAG_I);
+ textIndex += 3;
+ consumed = true;
+ } else if ('b' == lookahead2) {
+ //
+ if (sb.length() > 0) {
+ tokens.add(Token.text(sb.toString()));
+ sb.delete(0, sb.length());
+ }
+ tokens.add(CLOSE_TAG_B);
+ textIndex += 3;
+ consumed = true;
+ } else if ('p' == lookahead2) {
+ //
+ if (sb.length() > 0) {
+ tokens.add(Token.text(sb.toString()));
+ sb.delete(0, sb.length());
+ }
+ tokens.add(CLOSE_TAG_P);
+ textIndex += 3;
+ consumed = true;
+ }
+ }
+ }
+ if (textIndex < text.length() - 4) {
+ // lists
+ final char lookahead3 = text.charAt(textIndex + 3);
+ final char lookahead4 = text.charAt(textIndex + 4);
+ if ('l' == lookahead3) {
+ if ('o' == lookahead2 && '>' == lookahead4) {
+ //
+ if (sb.length() > 0) {
+ tokens.add(Token.text(sb.toString()));
+ sb.delete(0, sb.length());
+ }
+ tokens.add(CLOSE_TAG_OL);
+ textIndex += 4;
+ consumed = true;
+ } else if ('u' == lookahead2 && '>' == lookahead4) {
+ //
+ if (sb.length() > 0) {
+ tokens.add(Token.text(sb.toString()));
+ sb.delete(0, sb.length());
+ }
+ tokens.add(CLOSE_TAG_UL);
+ textIndex += 4;
+ consumed = true;
+ }
+ } else if ('l' == lookahead2 && 'i' == lookahead3) {
+ //
+ if ('>' == lookahead4) {
+ if (sb.length() > 0) {
+ tokens.add(Token.text(sb.toString()));
+ sb.delete(0, sb.length());
+ }
+ tokens.add(CLOSE_TAG_LI);
+ textIndex += 4;
+ consumed = true;
+ }
+ }
+ }
+ }
+
+ }
+ if (!consumed) {
+ sb.append('<');
+ }
+ break;
+ default:
+ sb.append(c);
+ break;
+ }
+ textIndex++;
+ }
+
+ if (sb.length() > 0) {
+ tokens.add(Token.text(sb.toString()));
+ sb.delete(0, sb.length());
+ }
+ tokens.add(POSSIBLE_WRAP_POINT);
+
+ return tokens;
+ }
+
+ // -- HELPER
+
+ private static boolean isWrapPointChar(final char ch) {
+ return
+ ch == ' ' ||
+ ch == ',' ||
+ ch == '.' ||
+ ch == '-' ||
+ ch == '@' ||
+ ch == ':' ||
+ ch == ';' ||
+ ch == '\n' ||
+ ch == '\t' ||
+ ch == '\r' ||
+ ch == '\f' ||
+ ch == '\u000B';
+ }
+
+ private static Stack findWrapPoints(final String text) {
+ Stack result = new Stack<>();
+ result.push(text.length());
+ for (int i = text.length() - 2; i >= 0; i--) {
+ if (isWrapPointChar(text.charAt(i))) {
+ result.push(i + 1);
+ }
+ }
+ return result;
+ }
+
+ private static Stack findWrapPointsWithFunction(final String text, final WrappingFunction wrappingFunction) {
+ final String[] split = wrappingFunction.getLines(text);
+ int textIndex = text.length();
+ final Stack possibleWrapPoints = new Stack<>();
+ possibleWrapPoints.push(textIndex);
+ for (int i = split.length - 1; i > 0; i--) {
+ final int splitLength = split[i].length();
+ possibleWrapPoints.push(textIndex - splitLength);
+ textIndex -= splitLength;
+ }
+ return possibleWrapPoints;
+ }
+
+}
diff --git a/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/WrappingFunction.java b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/WrappingFunction.java
new file mode 100644
index 00000000000..a379d9e7691
--- /dev/null
+++ b/extensions/vw/tabular/pdf/src/main/java/org/apache/causeway/extensions/tabular/pdf/factory/internal/WrappingFunction.java
@@ -0,0 +1,24 @@
+/*
+ * 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.causeway.extensions.tabular.pdf.factory.internal;
+
+interface WrappingFunction {
+
+ String[] getLines(String text);
+}
| | | | | | | |