
org.sejda.sambox.rendering.PageDrawer Maven / Gradle / Ivy
Show all versions of sambox Show documentation
/*
* 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.sejda.sambox.rendering;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GraphicsConfiguration;
import java.awt.GraphicsDevice;
import java.awt.Image;
import java.awt.Paint;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.Shape;
import java.awt.Stroke;
import java.awt.TexturePaint;
import java.awt.Transparency;
import java.awt.color.ColorSpace;
import java.awt.geom.AffineTransform;
import java.awt.geom.Area;
import java.awt.geom.GeneralPath;
import java.awt.geom.Path2D;
import java.awt.geom.PathIterator;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.awt.image.*;
import java.io.IOException;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.sejda.sambox.contentstream.PDFGraphicsStreamEngine;
import org.sejda.sambox.cos.COSArray;
import org.sejda.sambox.cos.COSBase;
import org.sejda.sambox.cos.COSDictionary;
import org.sejda.sambox.cos.COSName;
import org.sejda.sambox.pdmodel.PDResources;
import org.sejda.sambox.pdmodel.common.PDRectangle;
import org.sejda.sambox.pdmodel.common.function.PDFunction;
import org.sejda.sambox.pdmodel.documentinterchange.markedcontent.PDPropertyList;
import org.sejda.sambox.pdmodel.font.PDCIDFontType0;
import org.sejda.sambox.pdmodel.font.PDCIDFontType2;
import org.sejda.sambox.pdmodel.font.PDFont;
import org.sejda.sambox.pdmodel.font.PDTrueTypeFont;
import org.sejda.sambox.pdmodel.font.PDType0Font;
import org.sejda.sambox.pdmodel.font.PDType1CFont;
import org.sejda.sambox.pdmodel.font.PDType1Font;
import org.sejda.sambox.pdmodel.font.PDType3Font;
import org.sejda.sambox.pdmodel.graphics.PDLineDashPattern;
import org.sejda.sambox.pdmodel.graphics.PDXObject;
import org.sejda.sambox.pdmodel.graphics.blend.BlendMode;
import org.sejda.sambox.pdmodel.graphics.color.PDColor;
import org.sejda.sambox.pdmodel.graphics.color.PDColorSpace;
import org.sejda.sambox.pdmodel.graphics.color.PDDeviceGray;
import org.sejda.sambox.pdmodel.graphics.color.PDDeviceRGB;
import org.sejda.sambox.pdmodel.graphics.color.PDICCBased;
import org.sejda.sambox.pdmodel.graphics.color.PDPattern;
import org.sejda.sambox.pdmodel.graphics.color.PDSeparation;
import org.sejda.sambox.pdmodel.graphics.form.PDFormXObject;
import org.sejda.sambox.pdmodel.graphics.form.PDTransparencyGroup;
import org.sejda.sambox.pdmodel.graphics.image.PDImage;
import org.sejda.sambox.pdmodel.graphics.image.PDImageXObject;
import org.sejda.sambox.pdmodel.graphics.optionalcontent.PDOptionalContentGroup;
import org.sejda.sambox.pdmodel.graphics.optionalcontent.PDOptionalContentGroup.RenderState;
import org.sejda.sambox.pdmodel.graphics.optionalcontent.PDOptionalContentMembershipDictionary;
import org.sejda.sambox.pdmodel.graphics.pattern.PDAbstractPattern;
import org.sejda.sambox.pdmodel.graphics.pattern.PDShadingPattern;
import org.sejda.sambox.pdmodel.graphics.pattern.PDTilingPattern;
import org.sejda.sambox.pdmodel.graphics.shading.PDShading;
import org.sejda.sambox.pdmodel.graphics.state.PDExtendedGraphicsState;
import org.sejda.sambox.pdmodel.graphics.state.PDGraphicsState;
import org.sejda.sambox.pdmodel.graphics.state.PDSoftMask;
import org.sejda.sambox.pdmodel.graphics.state.RenderingMode;
import org.sejda.sambox.pdmodel.interactive.annotation.AnnotationFilter;
import org.sejda.sambox.pdmodel.interactive.annotation.PDAnnotation;
import org.sejda.sambox.pdmodel.interactive.annotation.PDAnnotationUnknown;
import org.sejda.sambox.pdmodel.interactive.annotation.PDAppearanceDictionary;
import org.sejda.sambox.util.Matrix;
import org.sejda.sambox.util.Vector;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Paints a page in a PDF document to a Graphics context. May be subclassed to provide custom
* rendering.
*
*
* If you want to do custom graphics processing rather than Graphics2D rendering, then you should
* subclass {@link PDFGraphicsStreamEngine} instead. Subclassing PageDrawer is only suitable for
* cases where the goal is to render onto a {@link Graphics2D} surface. In that case you'll also
* have to subclass {@link PDFRenderer} and modify {@link PDFRenderer#createPageDrawer(PageDrawerParameters)}.
*
* @author Ben Litchfield
*/
public class PageDrawer extends PDFGraphicsStreamEngine
{
private static final Logger LOG = LoggerFactory.getLogger(PageDrawer.class);
// parent document renderer - note: this is needed for not-yet-implemented resource caching
private final PDFRenderer renderer;
// the graphics device to draw to, xform is the initial transform of the device (i.e. DPI)
private Graphics2D graphics;
private AffineTransform xform;
private float xformScalingFactorX;
private float xformScalingFactorY;
// the page box to draw (usually the crop box but may be another)
private PDRectangle pageSize;
// whether image of a transparency group must be flipped
// needed when in a tiling pattern
private boolean flipTG = false;
// clipping winding rule used for the clipping path
private int clipWindingRule = -1;
private GeneralPath linePath = new GeneralPath();
// last clipping path
private List lastClips;
// clip when drawPage() is called, can be null, must be intersected when clipping
private Shape initialClip;
// shapes of glyphs being drawn to be used for clipping
private List textClippings;
// glyph cache
private final Map fontGlyph2D = new HashMap<>();
private final TilingPaintFactory tilingPaintFactory = new TilingPaintFactory(this);
private final Deque transparencyGroupStack = new ArrayDeque<>();
// if greater zero the content is hidden and wil not be rendered
private int nestedHiddenOCGCount;
private final RenderDestination destination;
private final RenderingHints renderingHints;
private LookupTable invTable = null;
/**
* Default annotations filter, returns all annotations
*/
private AnnotationFilter annotationFilter = annotation -> true;
private boolean textContentRendered = true;
private boolean imageContentRendered = true;
/**
* Constructor.
*
* @param parameters Parameters for page drawing.
* @throws IOException If there is an error loading properties from the file.
*/
public PageDrawer(PageDrawerParameters parameters) throws IOException
{
super(parameters.getPage());
this.renderer = parameters.getRenderer();
this.destination = parameters.getDestination();
this.renderingHints = parameters.getRenderingHints();
}
/**
* Return the AnnotationFilter.
*
* @return the AnnotationFilter
*/
public AnnotationFilter getAnnotationFilter()
{
return annotationFilter;
}
/**
* Set the AnnotationFilter.
*
*
* Allows to only render annotation accepted by the filter.
*
* @param annotationFilter the AnnotationFilter
*/
public void setAnnotationFilter(AnnotationFilter annotationFilter)
{
this.annotationFilter = annotationFilter;
}
/**
* Returns the parent renderer.
*/
public final PDFRenderer getRenderer()
{
return renderer;
}
/**
* Returns the underlying Graphics2D. May be null if drawPage has not yet been called.
*/
protected final Graphics2D getGraphics()
{
return graphics;
}
/**
* Returns the current line path. This is reset to empty after each fill/stroke.
*/
protected final GeneralPath getLinePath()
{
return linePath;
}
/**
* Sets high-quality rendering hints on the current Graphics2D.
*/
private void setRenderingHints()
{
graphics.addRenderingHints(renderingHints);
}
/**
* Draws the page to the requested context.
*
* @param g The graphics context to draw onto.
* @param pageSize The size of the page to draw.
* @throws IOException If there is an IO error while drawing the page.
*/
public void drawPage(Graphics g, PDRectangle pageSize) throws IOException
{
graphics = (Graphics2D) g;
xform = graphics.getTransform();
Matrix m = new Matrix(xform);
xformScalingFactorX = Math.abs(m.getScalingFactorX());
xformScalingFactorY = Math.abs(m.getScalingFactorY());
initialClip = graphics.getClip();
this.pageSize = pageSize;
setRenderingHints();
graphics.translate(0, pageSize.getHeight());
graphics.scale(1, -1);
// adjust for non-(0,0) crop box
graphics.translate(-pageSize.getLowerLeftX(), -pageSize.getLowerLeftY());
processPage(getPage());
for (PDAnnotation annotation : getPage().getAnnotations(annotationFilter))
{
showAnnotation(annotation);
}
graphics = null;
}
/**
* Draws the pattern stream to the requested context.
*
* @param g The graphics context to draw onto.
* @param pattern The tiling pattern to be used.
* @param colorSpace color space for this tiling.
* @param color color for this tiling.
* @param patternMatrix the pattern matrix
* @throws IOException If there is an IO error while drawing the page.
*/
void drawTilingPattern(Graphics2D g, PDTilingPattern pattern, PDColorSpace colorSpace,
PDColor color, Matrix patternMatrix) throws IOException
{
Graphics2D savedGraphics = graphics;
graphics = g;
GeneralPath savedLinePath = linePath;
linePath = new GeneralPath();
int savedClipWindingRule = clipWindingRule;
clipWindingRule = -1;
List savedLastClips = lastClips;
lastClips = null;
Shape savedInitialClip = initialClip;
initialClip = null;
boolean savedFlipTG = flipTG;
flipTG = true;
setRenderingHints();
processTilingPattern(pattern, color, colorSpace, patternMatrix);
flipTG = savedFlipTG;
graphics = savedGraphics;
linePath = savedLinePath;
lastClips = savedLastClips;
initialClip = savedInitialClip;
clipWindingRule = savedClipWindingRule;
}
private float clampColor(float color)
{
return color < 0 ? 0 : (color > 1 ? 1 : color);
}
/**
* Returns an AWT paint for the given PDColor.
*
* @param color The color to get a paint for. This can be an actual color or a pattern.
* @throws IOException
*/
protected Paint getPaint(PDColor color) throws IOException
{
PDColorSpace colorSpace = color.getColorSpace();
if (colorSpace == null)
{
colorSpace = PDDeviceRGB.INSTANCE;
}
if (colorSpace instanceof PDSeparation && "None".equals(
((PDSeparation) colorSpace).getColorantName()))
{
// PDFBOX-4900: "The special colorant name None shall not produce any visible output"
//TODO better solution needs to be found for all occurences where toRGB is called
return new Color(0, 0, 0, 0);
}
if (!(colorSpace instanceof PDPattern patternSpace))
{
float[] rgb = colorSpace.toRGB(color.getComponents());
return new Color(clampColor(rgb[0]), clampColor(rgb[1]), clampColor(rgb[2]));
}
else
{
PDAbstractPattern pattern = patternSpace.getPattern(color);
if (pattern instanceof PDTilingPattern tilingPattern)
{
if (tilingPattern.getPaintType() == PDTilingPattern.PAINT_COLORED)
{
// colored tiling pattern
return tilingPaintFactory.create(tilingPattern, null, null, xform);
}
else
{
// uncolored tiling pattern
return tilingPaintFactory.create(tilingPattern,
patternSpace.getUnderlyingColorSpace(), color, xform);
}
}
else
{
PDShadingPattern shadingPattern = (PDShadingPattern) pattern;
PDShading shading = shadingPattern.getShading();
if (shading == null)
{
LOG.error("shadingPattern is null, will be filled with transparency");
return new Color(0, 0, 0, 0);
}
return shading.toPaint(
Matrix.concatenate(getInitialMatrix(), shadingPattern.getMatrix()));
}
}
}
/**
* Sets the clipping path using caching for performance. We track lastClip manually because
* {@link Graphics2D#getClip()} returns a new object instead of the same one passed to {@link
* Graphics2D#setClip(java.awt.Shape) setClip()}. You may need to call this if you override
* {@link #showGlyph(Matrix, PDFont, int, Vector) showGlyph()}. See
* PDFBOX-5093 for more.
*/
protected final void setClip()
{
List clippingPaths = getGraphicsState().getCurrentClippingPaths();
if (clippingPaths != lastClips)
{
transferClip(graphics);
if (initialClip != null)
{
// apply the remembered initial clip, but transform it first
//TODO see PDFBOX-4583
}
lastClips = clippingPaths;
}
}
/**
* Transfer clip to the destination device. Override this if you want to avoid to do slow
* intersecting operations but want the destination device to do this (e.g. SVG). You can get
* the individual clippings via {@link PDGraphicsState#getCurrentClippingPaths()}. See
* PDFBOX-5258 for sample code.
*
* @param graphics graphics device
*/
protected void transferClip(Graphics2D graphics)
{
Area clippingPath = getGraphicsState().getCurrentClippingPath();
if (clippingPath.getPathIterator(null).isDone())
{
// PDFBOX-4821: avoid bug with java printing that empty clipping path is ignored by
// replacing with empty rectangle, works because this is not an empty path
graphics.setClip(new Rectangle());
}
else
{
graphics.setClip(clippingPath);
}
}
@Override
public void beginText() throws IOException
{
setClip();
beginTextClip();
}
@Override
public void endText() throws IOException
{
endTextClip();
}
/**
* Begin buffering the text clipping path, if any.
*/
private void beginTextClip()
{
// buffer the text clippings because they represents a single clipping area
textClippings = new ArrayList<>();
}
/**
* End buffering the text clipping path, if any.
*/
private void endTextClip()
{
PDGraphicsState state = getGraphicsState();
RenderingMode renderingMode = state.getTextState().getRenderingMode();
// apply the buffered clip as one area
if (renderingMode.isClip() && !textClippings.isEmpty())
{
// PDFBOX-4150: this is much faster than using textClippingArea.add(new Area(glyph))
// https://stackoverflow.com/questions/21519007/fast-union-of-shapes-in-java
GeneralPath path = new GeneralPath(Path2D.WIND_NON_ZERO, textClippings.size());
for (Shape shape : textClippings)
{
path.append(shape, false);
}
state.intersectClippingPath(path);
textClippings = new ArrayList<>();
// PDFBOX-3681: lastClip needs to be reset, because after intersection it is still the same
// object, thus setClip() would believe that it is cached.
lastClips = null;
}
}
@Override
protected void showFontGlyph(Matrix textRenderingMatrix, PDFont font, int code,
Vector displacement) throws IOException
{
AffineTransform at = textRenderingMatrix.createAffineTransform();
at.concatenate(font.getFontMatrix().createAffineTransform());
Glyph2D glyph2D = createGlyph2D(font);
// SAMBOX specific: we don't want to swallow exceptions here and just print an error message to the log
drawGlyph2D(glyph2D, font, code, displacement, at);
}
/**
* Render the font using the Glyph2D interface.
*
* @param glyph2D the Glyph2D implementation provided a GeneralPath for each glyph
* @param font the font
* @param code character code
* @param displacement the glyph's displacement (advance)
* @param at the transformation
* @throws IOException if something went wrong
*/
private void drawGlyph2D(Glyph2D glyph2D, PDFont font, int code, Vector displacement,
AffineTransform at) throws IOException
{
PDGraphicsState state = getGraphicsState();
RenderingMode renderingMode = state.getTextState().getRenderingMode();
GeneralPath path = glyph2D.getPathForCharacterCode(code);
if (path != null)
{
// Stretch non-embedded glyph if it does not match the height/width contained in the PDF.
// Vertical fonts have zero X displacement, so the following code scales to 0 if we don't skip it.
// TODO: How should vertical fonts be handled?
if (!font.isEmbedded() && !font.isVertical() && !font.isStandard14()
&& font.hasExplicitWidth(code))
{
float fontWidth = font.getWidthFromFont(code);
if (fontWidth > 0 && // ignore spaces
Math.abs(fontWidth - displacement.getX() * 1000) > 0.0001)
{
float pdfWidth = displacement.getX() * 1000;
at.scale(pdfWidth / fontWidth, 1);
}
}
// render glyph
Shape glyph = at.createTransformedShape(path);
// WARNING SAMBOX SPECIFIC
// we also check if text content rendering is enabled
if (isContentRendered() && isTextContentRendered())
{
if (renderingMode.isFill())
{
graphics.setComposite(state.getNonStrokingJavaComposite());
graphics.setPaint(getNonStrokingPaint());
setClip();
graphics.fill(glyph);
}
if (renderingMode.isStroke())
{
graphics.setComposite(state.getStrokingJavaComposite());
graphics.setPaint(getStrokingPaint());
graphics.setStroke(getStroke());
setClip();
graphics.draw(glyph);
}
}
if (renderingMode.isClip())
{
textClippings.add(glyph);
}
}
}
@Override
protected void showType3Glyph(Matrix textRenderingMatrix, PDType3Font font, int code,
Vector displacement) throws IOException
{
PDGraphicsState state = getGraphicsState();
RenderingMode renderingMode = state.getTextState().getRenderingMode();
if (!RenderingMode.NEITHER.equals(renderingMode))
{
super.showType3Glyph(textRenderingMatrix, font, code, displacement);
}
}
/**
* Provide a Glyph2D for the given font.
*
* @param font the font
* @return the implementation of the Glyph2D interface for the given font
* @throws IOException if something went wrong
*/
private Glyph2D createGlyph2D(PDFont font) throws IOException
{
Glyph2D glyph2D = fontGlyph2D.get(font);
// Is there already a Glyph2D for the given font?
if (glyph2D != null)
{
return glyph2D;
}
if (font instanceof PDTrueTypeFont ttfFont)
{
glyph2D = new TTFGlyph2D(ttfFont); // TTF is never null
}
else if (font instanceof PDType1Font pdType1Font)
{
glyph2D = new Type1Glyph2D(pdType1Font); // T1 is never null
}
else if (font instanceof PDType1CFont type1CFont)
{
glyph2D = new Type1Glyph2D(type1CFont);
}
else if (font instanceof PDType0Font type0Font)
{
if (type0Font.getDescendantFont() instanceof PDCIDFontType2)
{
glyph2D = new TTFGlyph2D(type0Font); // TTF is never null
}
else if (type0Font.getDescendantFont() instanceof PDCIDFontType0 cidType0Font)
{
// a Type0 CIDFont contains CFF font
glyph2D = new CIDType0Glyph2D(
cidType0Font); // todo: could be null (need incorporate fallback)
}
}
else
{
throw new IllegalStateException("Bad font type: " + font.getClass().getSimpleName());
}
// cache the Glyph2D instance
if (glyph2D != null)
{
fontGlyph2D.put(font, glyph2D);
}
if (glyph2D == null)
{
// todo: make sure this never happens
throw new UnsupportedOperationException("No font for " + font.getName());
}
return glyph2D;
}
@Override
public void appendRectangle(Point2D p0, Point2D p1, Point2D p2, Point2D p3)
{
// to ensure that the path is created in the right direction, we have to create
// it by combining single lines instead of creating a simple rectangle
linePath.moveTo((float) p0.getX(), (float) p0.getY());
linePath.lineTo((float) p1.getX(), (float) p1.getY());
linePath.lineTo((float) p2.getX(), (float) p2.getY());
linePath.lineTo((float) p3.getX(), (float) p3.getY());
// close the subpath instead of adding the last line so that a possible set line
// cap style isn't taken into account at the "beginning" of the rectangle
linePath.closePath();
}
private Paint applySoftMaskToPaint(Paint parentPaint, PDSoftMask softMask) throws IOException
{
if (softMask == null || softMask.getGroup() == null)
{
return parentPaint;
}
PDColor backdropColor = null;
if (COSName.LUMINOSITY.equals(softMask.getSubType()))
{
COSArray backdropColorArray = softMask.getBackdropColor();
if (backdropColorArray != null)
{
PDTransparencyGroup form = softMask.getGroup();
PDColorSpace colorSpace = form.getGroup().getColorSpace(form.getResources());
if (colorSpace != null)
{
backdropColor = new PDColor(backdropColorArray, colorSpace);
}
}
}
TransparencyGroup transparencyGroup = new TransparencyGroup(softMask.getGroup(), true,
softMask.getInitialTransformationMatrix(), backdropColor);
BufferedImage image = transparencyGroup.getImage();
if (image == null)
{
// Adobe Reader ignores empty softmasks instead of using bc color
// sample file: PDFJS-6967_reduced_outside_softmask.pdf
return parentPaint;
}
BufferedImage gray = new BufferedImage(image.getWidth(), image.getHeight(),
BufferedImage.TYPE_BYTE_GRAY);
if (COSName.ALPHA.equals(softMask.getSubType()))
{
gray.setData(image.getAlphaRaster());
}
else if (COSName.LUMINOSITY.equals(softMask.getSubType()))
{
Graphics g = gray.getGraphics();
g.drawImage(image, 0, 0, null);
g.dispose();
}
else
{
throw new IOException("Invalid soft mask subtype.");
}
gray = adjustImage(gray);
Rectangle2D tpgBounds = transparencyGroup.getBounds();
return new SoftMask(parentPaint, gray, tpgBounds, backdropColor,
softMask.getTransferFunction());
}
// returns the image adjusted for applySoftMaskToPaint().
private BufferedImage adjustImage(BufferedImage gray)
{
AffineTransform at = new AffineTransform(xform);
at.scale(1.0 / xformScalingFactorX, 1.0 / xformScalingFactorY);
Rectangle originalBounds = new Rectangle(gray.getWidth(), gray.getHeight());
Rectangle2D transformedBounds = at.createTransformedShape(originalBounds).getBounds2D();
at.preConcatenate(AffineTransform.getTranslateInstance(-transformedBounds.getMinX(),
-transformedBounds.getMinY()));
int width = (int) Math.ceil(transformedBounds.getWidth());
int height = (int) Math.ceil(transformedBounds.getHeight());
if (width == gray.getWidth() && height == gray.getHeight() && at.isIdentity())
{
return gray;
}
BufferedImage transformedGray = new BufferedImage(width, height,
BufferedImage.TYPE_BYTE_GRAY);
Graphics2D g2 = (Graphics2D) transformedGray.getGraphics();
g2.drawImage(gray, at, null);
g2.dispose();
return transformedGray;
}
// returns the stroking AWT Paint
private Paint getStrokingPaint() throws IOException
{
return applySoftMaskToPaint(getPaint(getGraphicsState().getStrokingColor()),
getGraphicsState().getSoftMask());
}
/**
* Returns the non-stroking AWT Paint. You may need to call this if you override {@link
* #showGlyph(Matrix, PDFont, int, Vector) showGlyph()}. See
* PDFBOX-5093 for more.
*
* @return The non-stroking AWT Paint.
* @throws IOException
*/
protected final Paint getNonStrokingPaint() throws IOException
{
return applySoftMaskToPaint(getPaint(getGraphicsState().getNonStrokingColor()),
getGraphicsState().getSoftMask());
}
// create a new stroke based on the current CTM and the current stroke
private Stroke getStroke()
{
PDGraphicsState state = getGraphicsState();
// apply the CTM
float lineWidth = transformWidth(state.getLineWidth());
// minimum line width as used by Adobe Reader
if (lineWidth < 0.25)
{
lineWidth = 0.25f;
}
PDLineDashPattern dashPattern = state.getLineDashPattern();
// PDFBOX-5168: show an all-zero dash array line invisible like Adobe does
// must do it here because getDashArray() sets minimum width because of JVM bugs
float[] dashArray = dashPattern.getDashArray();
if (isAllZeroDash(dashArray))
{
return p -> new Area();
}
float phaseStart = dashPattern.getPhase();
dashArray = getDashArray(dashPattern);
phaseStart = transformWidth(phaseStart);
int lineCap = Math.min(2, Math.max(0, state.getLineCap()));
int lineJoin = Math.min(2, Math.max(0, state.getLineJoin()));
float miterLimit = state.getMiterLimit();
if (miterLimit < 1)
{
LOG.warn("Miter limit must be >= 1, value " + miterLimit + " is ignored");
miterLimit = 10;
}
return new BasicStroke(lineWidth, lineCap, lineJoin, miterLimit, dashArray, phaseStart);
}
private boolean isAllZeroDash(float[] dashArray)
{
if (dashArray.length > 0)
{
for (int i = 0; i < dashArray.length; ++i)
{
if (dashArray[i] != 0)
{
return false;
}
}
return true;
}
return false;
}
private float[] getDashArray(PDLineDashPattern dashPattern)
{
float[] dashArray = dashPattern.getDashArray();
int phase = dashPattern.getPhase();
// avoid empty, infinite and NaN values (PDFBOX-3360)
if (dashArray.length == 0 || Float.isInfinite(phase) || Float.isNaN(phase))
{
return null;
}
for (int i = 0; i < dashArray.length; ++i)
{
if (Float.isInfinite(dashArray[i]) || Float.isNaN(dashArray[i]))
{
return null;
}
}
for (int i = 0; i < dashArray.length; ++i)
{
// apply the CTM
float w = transformWidth(dashArray[i]);
// minimum line dash width avoids JVM crash,
// see PDFBOX-2373, PDFBOX-2929, PDFBOX-3204, PDFBOX-3813
// also avoid 0 in array like "[ 0 1000 ] 0 d", see PDFBOX-3724
if (xformScalingFactorX < 0.5f)
{
// PDFBOX-4492
dashArray[i] = Math.max(w, 0.2f);
}
else
{
dashArray[i] = Math.max(w, 0.062f);
}
}
return dashArray;
}
@Override
public void strokePath() throws IOException
{
if (isContentRendered())
{
graphics.setComposite(getGraphicsState().getStrokingJavaComposite());
graphics.setPaint(getStrokingPaint());
graphics.setStroke(getStroke());
setClip();
graphics.draw(linePath);
}
linePath.reset();
}
@Override
public void fillPath(int windingRule) throws IOException
{
PDGraphicsState graphicsState = getGraphicsState();
graphics.setComposite(graphicsState.getNonStrokingJavaComposite());
setClip();
linePath.setWindingRule(windingRule);
// disable anti-aliasing for rectangular paths, this is a workaround to avoid small stripes
// which occur when solid fills are used to simulate piecewise gradients, see PDFBOX-2302
// note that we ignore paths with a width/height under 1 as these are fills used as strokes,
// see PDFBOX-1658 for an example
Rectangle2D bounds = linePath.getBounds2D();
boolean noAntiAlias =
isRectangular(linePath) && bounds.getWidth() > 1 && bounds.getHeight() > 1;
if (noAntiAlias)
{
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_OFF);
}
Shape shape;
if (graphicsState.getNonStrokingColorSpace() instanceof PDPattern)
{
// apply clip to path to avoid oversized device bounds in shading contexts (PDFBOX-2901)
Area area = new Area(linePath);
Shape clip = graphics.getClip();
if (clip != null)
{
area.intersect(new Area(clip));
}
intersectShadingBBox(graphicsState.getNonStrokingColor(), area);
shape = area;
}
else
{
shape = linePath;
}
if (isContentRendered() && !shape.getPathIterator(null).isDone())
{
// creating Paint is sometimes a costly operation, so avoid if possible
graphics.setPaint(getNonStrokingPaint());
graphics.fill(shape);
}
linePath.reset();
if (noAntiAlias)
{
// JDK 1.7 has a bug where rendering hints are reset by the above call to
// the setRenderingHint method, so we re-set all hints, see PDFBOX-2302
setRenderingHints();
}
}
// checks whether this is a shading pattern and if yes,
// get the transformed BBox and intersect with current paint area
// need to do it here and not in shading getRaster() because it may have been rotated
private void intersectShadingBBox(PDColor color, Area area) throws IOException
{
if (color.getColorSpace() instanceof PDPattern)
{
PDColorSpace colorSpace = color.getColorSpace();
PDAbstractPattern pat = ((PDPattern) colorSpace).getPattern(color);
if (pat instanceof PDShadingPattern)
{
PDShading shading = ((PDShadingPattern) pat).getShading();
PDRectangle bbox = shading.getBBox();
if (bbox != null)
{
Matrix m = Matrix.concatenate(getInitialMatrix(), pat.getMatrix());
Area bboxArea = new Area(bbox.transform(m));
area.intersect(bboxArea);
}
}
}
}
/**
* Returns true if the given path is rectangular.
*/
private boolean isRectangular(GeneralPath path)
{
PathIterator iter = path.getPathIterator(null);
double[] coords = new double[6];
int count = 0;
int[] xs = new int[4];
int[] ys = new int[4];
while (!iter.isDone())
{
switch (iter.currentSegment(coords))
{
case PathIterator.SEG_MOVETO:
if (count == 0)
{
xs[count] = (int) Math.floor(coords[0]);
ys[count] = (int) Math.floor(coords[1]);
}
else
{
return false;
}
count++;
break;
case PathIterator.SEG_LINETO:
if (count < 4)
{
xs[count] = (int) Math.floor(coords[0]);
ys[count] = (int) Math.floor(coords[1]);
}
else
{
return false;
}
count++;
break;
case PathIterator.SEG_CUBICTO:
return false;
default:
break;
}
iter.next();
}
if (count == 4)
{
return xs[0] == xs[1] || xs[0] == xs[2] || ys[0] == ys[1] || ys[0] == ys[3];
}
return false;
}
/**
* Fills and then strokes the path.
*
* @param windingRule The winding rule this path will use.
* @throws IOException If there is an IO error while filling the path.
*/
@Override
public void fillAndStrokePath(int windingRule) throws IOException
{
// Cloning needed because fillPath() resets linePath
GeneralPath path = (GeneralPath) linePath.clone();
fillPath(windingRule);
linePath = path;
strokePath();
}
@Override
public void clip(int windingRule)
{
// the clipping path will not be updated until the succeeding painting operator is called
clipWindingRule = windingRule;
}
@Override
public void moveTo(float x, float y)
{
linePath.moveTo(x, y);
}
@Override
public void lineTo(float x, float y)
{
linePath.lineTo(x, y);
}
@Override
public void curveTo(float x1, float y1, float x2, float y2, float x3, float y3)
{
linePath.curveTo(x1, y1, x2, y2, x3, y3);
}
@Override
public Point2D getCurrentPoint()
{
return linePath.getCurrentPoint();
}
@Override
public void closePath()
{
linePath.closePath();
}
@Override
public void endPath()
{
if (clipWindingRule != -1)
{
linePath.setWindingRule(clipWindingRule);
if (!linePath.getPathIterator(null).isDone())
{
// PDFBOX-4949 / PDF.js 12306: don't clip if "W n" only
getGraphicsState().intersectClippingPath(linePath);
}
// PDFBOX-3836: lastClip needs to be reset, because after intersection it is still the same
// object, thus setClip() would believe that it is cached.
lastClips = null;
clipWindingRule = -1;
}
linePath.reset();
}
@Override
public void drawImage(PDImage pdImage) throws IOException
{
if (pdImage instanceof PDImageXObject && isHiddenOCG(
((PDImageXObject) pdImage).getOptionalContent()))
{
return;
}
if (!isContentRendered() || !isImageContentRendered())
{
return;
}
Matrix ctm = getGraphicsState().getCurrentTransformationMatrix();
AffineTransform at = ctm.createAffineTransform();
if (!pdImage.getInterpolate())
{
// if the image is scaled down, we use smooth interpolation, eg PDFBOX-2364
// only when scaled up do we use nearest neighbour, eg PDFBOX-2302 / mori-cvpr01.pdf
// PDFBOX-4930: we use the sizes of the ARGB image. These can be different
// than the original sizes of the base image, when the mask is bigger.
BufferedImage bim = pdImage.getImage();
Matrix m = new Matrix(at);
boolean isScaledUp = bim.getWidth() < Math.abs(Math.round(m.getScalingFactorX()))
|| bim.getHeight() < Math.abs(Math.round(m.getScalingFactorY()));
if (isScaledUp)
{
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
}
}
graphics.setComposite(getGraphicsState().getNonStrokingJavaComposite());
setClip();
if (pdImage.isStencil())
{
if (getGraphicsState().getNonStrokingColor().getColorSpace() instanceof PDPattern)
{
// The earlier code for stencils (see "else") doesn't work with patterns because the
// CTM is not taken into consideration.
// this code is based on the fact that it is easily possible to draw the mask and
// the paint at the correct place with the existing code, but not in one step.
// Thus what we do is to draw both in separate images, then combine the two and draw
// the result.
// Note that the device scale is not used. In theory, some patterns can get better
// at higher resolutions but the stencil would become more and more "blocky".
// If anybody wants to do this, have a look at the code in showTransparencyGroup().
// draw the paint
Paint paint = getNonStrokingPaint();
Rectangle2D unitRect = new Rectangle2D.Float(0, 0, 1, 1);
Rectangle2D bounds = at.createTransformedShape(unitRect).getBounds2D();
int w = (int) Math.ceil(bounds.getWidth());
int h = (int) Math.ceil(bounds.getHeight());
BufferedImage renderedPaint = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = (Graphics2D) renderedPaint.getGraphics();
g.translate(-bounds.getMinX(), -bounds.getMinY());
g.setPaint(paint);
g.setRenderingHints(graphics.getRenderingHints());
g.fill(bounds);
g.dispose();
// draw the mask
BufferedImage mask = pdImage.getImage();
AffineTransform imageTransform = new AffineTransform(at);
imageTransform.scale(1.0 / mask.getWidth(), -1.0 / mask.getHeight());
imageTransform.translate(0, -mask.getHeight());
AffineTransform full = new AffineTransform(g.getTransform());
full.concatenate(imageTransform);
Matrix m = new Matrix(full);
double scaleX = Math.abs(m.getScalingFactorX());
double scaleY = Math.abs(m.getScalingFactorY());
boolean smallMask = mask.getWidth() <= 8 && mask.getHeight() <= 8;
if (!smallMask)
{
// PDFBOX-5403:
// The mask is copied to RGB because this supports a smooth scaling, so we
// get a mask with 255 values instead of just 0 and 255.
// Inverting is done because when we don't do it, the getScaledInstance() call
// produces a black line in many masks. With the inversion we have a white line
// which is neutral. Because of the inversion we don't have to substract from 255
// in the "apply the mask" segment when rasterPixel[3] is assigned.
// The inversion is not done for very small ones, because of
// PDFBOX-2171-002-002710-p14.pdf where the "New Harmony Consolidated" and
// "Sailor Springs" patterns became almost invisible.
// (We may have to decide this differently in the future, e.g. on b/w relationship)
BufferedImage tmp = new BufferedImage(mask.getWidth(), mask.getHeight(), BufferedImage.TYPE_INT_RGB);
mask = new LookupOp(getInvLookupTable(), graphics.getRenderingHints()).filter(mask, tmp);
}
BufferedImage renderedMask = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
g = (Graphics2D) renderedMask.getGraphics();
g.translate(-bounds.getMinX(), -bounds.getMinY());
g.setRenderingHints(graphics.getRenderingHints());
if (smallMask)
{
g.drawImage(mask, imageTransform, null);
}
else
{
while (scaleX < 0.25)
{
scaleX *= 2.0;
}
while (scaleY < 0.25)
{
scaleY *= 2.0;
}
int w2 = (int) Math.round(mask.getWidth() * scaleX);
int h2 = (int) Math.round(mask.getHeight() * scaleY);
if(w2 == 0 || h2 == 0)
{
LOG.warn("Skipping mask image with 0 dimensions, width: " + w2 + ", height: "+ h2);
}
else
{
Image scaledMask = mask.getScaledInstance(w2, h2, Image.SCALE_SMOOTH);
imageTransform.scale(1f / Math.abs(scaleX), 1f / Math.abs(scaleY));
g.drawImage(scaledMask, imageTransform, null);
}
}
g.dispose();
// apply the mask
int[] alphaPixel = null;
int[] rasterPixel = null;
WritableRaster raster = renderedPaint.getRaster();
WritableRaster alpha = renderedMask.getRaster();
for (int y = 0; y < h; y++)
{
for (int x = 0; x < w; x++)
{
alphaPixel = alpha.getPixel(x, y, alphaPixel);
rasterPixel = raster.getPixel(x, y, rasterPixel);
rasterPixel[3] = alphaPixel[0];
raster.setPixel(x, y, rasterPixel);
}
}
// draw the image
graphics.drawImage(renderedPaint,
AffineTransform.getTranslateInstance(bounds.getMinX(), bounds.getMinY()),
null);
}
else
{
// fill the image with stenciled paint
BufferedImage image = pdImage.getStencilImage(getNonStrokingPaint());
// draw the image
drawBufferedImage(image, at);
}
}
else
{
// draw the image
drawBufferedImage(pdImage.getImage(), at);
}
if (!pdImage.getInterpolate())
{
// JDK 1.7 has a bug where rendering hints are reset by the above call to
// the setRenderingHint method, so we re-set all hints, see PDFBOX-2302
setRenderingHints();
}
}
private LookupTable getInvLookupTable()
{
if (invTable == null)
{
byte[] inv = new byte[256];
for (int i = 0; i < inv.length; i++)
{
inv[i] = (byte) (255 - i);
}
invTable = new ByteLookupTable(0, inv);
}
return invTable;
}
private void drawBufferedImage(BufferedImage image, AffineTransform at) throws IOException
{
AffineTransform originalTransform = graphics.getTransform();
AffineTransform imageTransform = new AffineTransform(at);
int width = image.getWidth();
int height = image.getHeight();
imageTransform.scale(1.0 / width, -1.0 / height);
imageTransform.translate(0, -height);
PDSoftMask softMask = getGraphicsState().getSoftMask();
if (softMask != null)
{
Rectangle2D rectangle = new Rectangle2D.Float(0, 0, width, height);
Paint awtPaint = new TexturePaint(image, rectangle);
awtPaint = applySoftMaskToPaint(awtPaint, softMask);
graphics.setPaint(awtPaint);
graphics.transform(imageTransform);
graphics.fill(rectangle);
graphics.setTransform(originalTransform);
}
else
{
COSBase transfer = getGraphicsState().getTransfer();
if (transfer instanceof COSArray || transfer instanceof COSDictionary)
{
image = applyTransferFunction(image, transfer);
}
// PDFBOX-4516, PDFBOX-4527, PDFBOX-4815, PDFBOX-4886, PDFBOX-4863:
// graphics.drawImage() has terrible quality when scaling down, even when
// RenderingHints.VALUE_INTERPOLATION_BICUBIC, VALUE_ALPHA_INTERPOLATION_QUALITY,
// VALUE_COLOR_RENDER_QUALITY and VALUE_RENDER_QUALITY are all set.
// A workaround is to get a pre-scaled image with Image.getScaledInstance()
// and then draw that one. To reduce differences in testing
// (partly because the method needs integer parameters), only smaller scalings
// will trigger the workaround. Because of the slowness we only do it if the user
// expects quality rendering and interpolation.
Matrix imageTransformMatrix = new Matrix(imageTransform);
Matrix graphicsTransformMatrix = new Matrix(originalTransform);
float scaleX = Math.abs(imageTransformMatrix.getScalingFactorX()
* graphicsTransformMatrix.getScalingFactorX());
float scaleY = Math.abs(imageTransformMatrix.getScalingFactorY()
* graphicsTransformMatrix.getScalingFactorY());
if ((scaleX < 0.5 || scaleY < 0.5) && RenderingHints.VALUE_RENDER_QUALITY.equals(
graphics.getRenderingHint(RenderingHints.KEY_RENDERING))
&& RenderingHints.VALUE_INTERPOLATION_BICUBIC.equals(
graphics.getRenderingHint(RenderingHints.KEY_INTERPOLATION)))
{
int w = Math.round(image.getWidth() * scaleX);
int h = Math.round(image.getHeight() * scaleY);
if (w < 1 || h < 1)
{
graphics.drawImage(image, imageTransform, null);
return;
}
Image imageToDraw = image.getScaledInstance(w, h, Image.SCALE_SMOOTH);
// remove the scale (extracted from w and h, to have it from the rounded values
// hoping to reverse the rounding: without this, we get an horizontal line
// when rendering PDFJS-8860-Pattern-Size1.pdf at 100% )
imageTransform.scale(1f / w * image.getWidth(), 1f / h * image.getHeight());
imageTransform.preConcatenate(originalTransform);
graphics.setTransform(new AffineTransform());
graphics.drawImage(imageToDraw, imageTransform, null);
graphics.setTransform(originalTransform);
}
else
{
graphics.drawImage(image, imageTransform, null);
}
}
}
private BufferedImage applyTransferFunction(BufferedImage image, COSBase transfer)
throws IOException
{
BufferedImage bim;
if (image.getColorModel().hasAlpha())
{
bim = new BufferedImage(image.getWidth(), image.getHeight(),
BufferedImage.TYPE_INT_ARGB);
}
else
{
bim = new BufferedImage(image.getWidth(), image.getHeight(),
BufferedImage.TYPE_INT_RGB);
}
// prepare transfer functions (either one per color or one for all)
// and maps (actually arrays[256] to be faster) to avoid calculating values several times
Integer[] rMap, gMap, bMap;
PDFunction rf, gf, bf;
if (transfer instanceof COSArray ar)
{
rf = PDFunction.create(ar.getObject(0));
gf = PDFunction.create(ar.getObject(1));
bf = PDFunction.create(ar.getObject(2));
rMap = new Integer[256];
gMap = new Integer[256];
bMap = new Integer[256];
}
else
{
rf = PDFunction.create(transfer);
gf = rf;
bf = rf;
rMap = new Integer[256];
gMap = rMap;
bMap = rMap;
}
// apply the transfer function to each color, but keep alpha
float[] input = new float[1];
for (int x = 0; x < image.getWidth(); ++x)
{
for (int y = 0; y < image.getHeight(); ++y)
{
int rgb = image.getRGB(x, y);
int ri = (rgb >> 16) & 0xFF;
int gi = (rgb >> 8) & 0xFF;
int bi = rgb & 0xFF;
int ro, go, bo;
if (rMap[ri] != null)
{
ro = rMap[ri];
}
else
{
input[0] = (ri & 0xFF) / 255f;
ro = (int) (rf.eval(input)[0] * 255);
rMap[ri] = ro;
}
if (gMap[gi] != null)
{
go = gMap[gi];
}
else
{
input[0] = (gi & 0xFF) / 255f;
go = (int) (gf.eval(input)[0] * 255);
gMap[gi] = go;
}
if (bMap[bi] != null)
{
bo = bMap[bi];
}
else
{
input[0] = (bi & 0xFF) / 255f;
bo = (int) (bf.eval(input)[0] * 255);
bMap[bi] = bo;
}
bim.setRGB(x, y, (rgb & 0xFF000000) | (ro << 16) | (go << 8) | bo);
}
}
return bim;
}
@Override
public void shadingFill(COSName shadingName) throws IOException
{
if (!isContentRendered())
{
return;
}
PDShading shading = getResources().getShading(shadingName);
if (shading == null)
{
LOG.error("shading " + shadingName + " does not exist in resources dictionary");
return;
}
Matrix ctm = getGraphicsState().getCurrentTransformationMatrix();
graphics.setComposite(getGraphicsState().getNonStrokingJavaComposite());
Shape savedClip = graphics.getClip();
graphics.setClip(null);
lastClips = null;
// get the transformed BBox and intersect with current clipping path
// need to do it here and not in shading getRaster() because it may have been rotated
PDRectangle bbox = shading.getBBox();
Area area;
if (bbox != null)
{
area = new Area(bbox.transform(ctm));
area.intersect(getGraphicsState().getCurrentClippingPath());
}
else
{
Rectangle2D bounds = shading.getBounds(new AffineTransform(), ctm);
if (bounds != null)
{
bounds.add(new Point2D.Double(Math.floor(bounds.getMinX() - 1),
Math.floor(bounds.getMinY() - 1)));
bounds.add(new Point2D.Double(Math.ceil(bounds.getMaxX() + 1),
Math.ceil(bounds.getMaxY() + 1)));
area = new Area(bounds);
area.intersect(getGraphicsState().getCurrentClippingPath());
}
else
{
area = getGraphicsState().getCurrentClippingPath();
}
}
if (!area.isEmpty())
{
// creating Paint is sometimes a costly operation, so avoid if possible
Paint paint = shading.toPaint(ctm);
paint = applySoftMaskToPaint(paint, getGraphicsState().getSoftMask());
graphics.setPaint(paint);
graphics.fill(area);
}
graphics.setClip(savedClip);
}
@Override
public void showAnnotation(PDAnnotation annotation) throws IOException
{
lastClips = null;
int deviceType = -1;
GraphicsConfiguration graphicsConfiguration = graphics.getDeviceConfiguration();
if (graphicsConfiguration != null && graphicsConfiguration.getDevice() != null)
{
deviceType = graphicsConfiguration.getDevice().getType();
}
if (deviceType == GraphicsDevice.TYPE_PRINTER && !annotation.isPrinted())
{
return;
}
if (deviceType == GraphicsDevice.TYPE_RASTER_SCREEN && annotation.isNoView())
{
return;
}
if (annotation.isHidden())
{
return;
}
if (annotation.isInvisible() && annotation instanceof PDAnnotationUnknown)
{
// "If set, do not display the annotation if it does not belong to one
// of the standard annotation types and no annotation handler is available."
return;
}
// TODO support NoZoom, example can be found in p5 of PDFBOX-2348
if (isHiddenOCG(annotation.getOptionalContent()))
{
return;
}
PDAppearanceDictionary appearance = annotation.getAppearance();
if (appearance == null || appearance.getNormalAppearance() == null)
{
// TODO: Improve memory consumption by passing a ScratchFile
annotation.constructAppearances();
}
if (annotation.isNoRotate() && getCurrentPage().getRotation() != 0)
{
PDRectangle rect = annotation.getRectangle();
AffineTransform savedTransform = graphics.getTransform();
// "The upper-left corner of the annotation remains at the same point in
// default user space; the annotation pivots around that point."
graphics.rotate(Math.toRadians(getCurrentPage().getRotation()), rect.getLowerLeftX(),
rect.getUpperRightY());
super.showAnnotation(annotation);
graphics.setTransform(savedTransform);
}
else
{
super.showAnnotation(annotation);
}
}
/**
* {@inheritDoc}
*/
@Override
public void showForm(PDFormXObject form) throws IOException
{
if (isHiddenOCG(form.getOptionalContent()))
{
return;
}
if (isContentRendered())
{
GeneralPath savedLinePath = linePath;
linePath = new GeneralPath();
super.showForm(form);
linePath = savedLinePath;
}
}
@Override
public void showTransparencyGroup(PDTransparencyGroup form) throws IOException
{
showTransparencyGroupOnGraphics(form, graphics);
}
/**
* For advanced users, to extract the transparency group into a separate graphics device.
*
* @param form
* @param graphics
* @throws IOException
*/
protected void showTransparencyGroupOnGraphics(PDTransparencyGroup form, Graphics2D graphics)
throws IOException
{
if (isHiddenOCG(form.getOptionalContent()))
{
return;
}
if (!isContentRendered())
{
return;
}
TransparencyGroup group = new TransparencyGroup(form, false,
getGraphicsState().getCurrentTransformationMatrix(), null);
BufferedImage image = group.getImage();
if (image == null)
{
// image is empty, don't bother
return;
}
graphics.setComposite(getGraphicsState().getNonStrokingJavaComposite());
setClip();
// both the DPI xform and the CTM were already applied to the group, so all we do
// here is draw it directly onto the Graphics2D device at the appropriate position
AffineTransform savedTransform = graphics.getTransform();
AffineTransform transform = new AffineTransform(xform);
transform.scale(1.0 / xformScalingFactorX, 1.0 / xformScalingFactorY);
graphics.setTransform(transform);
// adjust bbox (x,y) position at the initial scale + cropbox
PDRectangle bbox = group.getBBox();
float x = bbox.getLowerLeftX() - pageSize.getLowerLeftX();
float y = pageSize.getUpperRightY() - bbox.getUpperRightY();
if (flipTG)
{
graphics.translate(0, image.getHeight());
graphics.scale(1, -1);
}
else
{
graphics.translate(x * xformScalingFactorX, y * xformScalingFactorY);
}
PDSoftMask softMask = getGraphicsState().getSoftMask();
if (softMask != null)
{
Paint awtPaint = new TexturePaint(image,
new Rectangle2D.Float(0, 0, image.getWidth(), image.getHeight()));
awtPaint = applySoftMaskToPaint(awtPaint, softMask);
graphics.setPaint(awtPaint);
graphics.fill(new Rectangle2D.Float(0, 0, bbox.getWidth() * xformScalingFactorX,
bbox.getHeight() * xformScalingFactorY));
}
else
{
try
{
graphics.drawImage(image, null, null);
}
catch (InternalError ie)
{
LOG.error("Exception drawing image, see JDK-6689349, "
+ "try rendering into a BufferedImage instead", ie);
}
}
graphics.setTransform(savedTransform);
}
/**
* Transparency group.
**/
private final class TransparencyGroup
{
private final BufferedImage image;
private final PDRectangle bbox;
private final int minX;
private final int minY;
private final int maxX;
private final int maxY;
private final int width;
private final int height;
/**
* Creates a buffered image for a transparency group result.
*
* @param form the transparency group of the form or soft mask.
* @param isSoftMask true if this is a soft mask.
* @param ctm the relevant current transformation matrix. For soft masks, this is
* the CTM at the time the soft mask is set (not at the time the soft
* mask is used for fill/stroke!), for forms, this is the CTM at the
* time the form is invoked.
* @param backdropColor the color according to the /bc entry to be used for luminosity soft
* masks.
* @throws IOException
*/
private TransparencyGroup(PDTransparencyGroup form, boolean isSoftMask, Matrix ctm,
PDColor backdropColor) throws IOException
{
Graphics2D savedGraphics = graphics;
List savedLastClips = lastClips;
Shape savedInitialClip = initialClip;
// get the CTM x Form Matrix transform
Matrix transform = Matrix.concatenate(ctm, form.getMatrix());
// transform the bbox
PDRectangle formBBox = form.getBBox();
if (formBBox == null)
{
// PDFBOX-5471
// check done here and not in caller to avoid getBBox() creating rectangle twice
LOG.warn("transparency group ignored because BBox is null");
formBBox = new PDRectangle();
}
GeneralPath transformedBox = formBBox.transform(transform);
// clip the bbox to prevent giant bboxes from consuming all memory
Area transformed = new Area(transformedBox);
transformed.intersect(getGraphicsState().getCurrentClippingPath());
Rectangle2D clipRect = transformed.getBounds2D();
if (clipRect.isEmpty())
{
image = null;
bbox = null;
minX = 0;
minY = 0;
maxX = 0;
maxY = 0;
width = 0;
height = 0;
return;
}
this.bbox = new PDRectangle((float) clipRect.getX(), (float) clipRect.getY(),
(float) clipRect.getWidth(), (float) clipRect.getHeight());
// apply the underlying Graphics2D device's DPI transform
AffineTransform xformOriginal = xform;
xform = AffineTransform.getScaleInstance(xformScalingFactorX, xformScalingFactorY);
Rectangle2D bounds = xform.createTransformedShape(clipRect).getBounds2D();
minX = (int) Math.floor(bounds.getMinX());
minY = (int) Math.floor(bounds.getMinY());
maxX = (int) Math.floor(bounds.getMaxX()) + 1;
maxY = (int) Math.floor(bounds.getMaxY()) + 1;
width = maxX - minX;
height = maxY - minY;
// FIXME - color space
if (isGray(form.getGroup().getColorSpace(form.getResources())))
{
image = create2ByteGrayAlphaImage(width, height);
}
else
{
image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
}
boolean needsBackdrop =
!isSoftMask && !form.getGroup().isIsolated() && hasBlendMode(form,
new HashSet<>());
BufferedImage backdropImage = null;
// Position of this group in parent group's coordinates
int backdropX = 0;
int backdropY = 0;
if (needsBackdrop)
{
if (transparencyGroupStack.isEmpty())
{
// Use the current page as the parent group.
backdropImage = renderer.getPageImage();
if (backdropImage == null)
{
needsBackdrop = false;
}
else
{
backdropX = minX;
backdropY = backdropImage.getHeight() - maxY;
}
}
else
{
TransparencyGroup parentGroup = transparencyGroupStack.peek();
backdropImage = parentGroup.image;
backdropX = minX - parentGroup.minX;
backdropY = parentGroup.maxY - maxY;
}
}
Graphics2D g = image.createGraphics();
if (needsBackdrop)
{
// backdropImage must be included in group image but not in group alpha.
g.drawImage(backdropImage, 0, 0, width, height, backdropX, backdropY,
backdropX + width, backdropY + height, null);
g = new GroupGraphics(image, g);
}
if (isSoftMask && backdropColor != null)
{
// "If the subtype is Luminosity, the transparency group XObject G shall be
// composited with a fully opaque backdrop whose colour is everywhere defined
// by the soft-mask dictionary's BC entry."
g.setBackground(new Color(backdropColor.toRGB()));
g.clearRect(0, 0, width, height);
}
// flip y-axis
g.translate(0, image.getHeight());
g.scale(1, -1);
boolean savedFlipTG = flipTG;
flipTG = false;
// apply device transform (DPI)
// the initial translation is ignored, because we're not writing into the initial graphics device
g.transform(xform);
PDRectangle pageSizeOriginal = pageSize;
pageSize = new PDRectangle(minX / xformScalingFactorX, minY / xformScalingFactorY,
(float) (bounds.getWidth() / xformScalingFactorX),
(float) (bounds.getHeight() / xformScalingFactorY));
int clipWindingRuleOriginal = clipWindingRule;
clipWindingRule = -1;
GeneralPath linePathOriginal = linePath;
linePath = new GeneralPath();
// adjust the origin
g.translate(-clipRect.getX(), -clipRect.getY());
graphics = g;
setRenderingHints();
try
{
if (isSoftMask)
{
processSoftMask(form);
}
else
{
transparencyGroupStack.push(this);
processTransparencyGroup(form);
if (!transparencyGroupStack.isEmpty())
{
transparencyGroupStack.pop();
}
}
if (needsBackdrop)
{
((GroupGraphics) graphics).removeBackdrop(backdropImage, backdropX, backdropY);
}
}
finally
{
flipTG = savedFlipTG;
lastClips = savedLastClips;
graphics.dispose();
graphics = savedGraphics;
initialClip = savedInitialClip;
clipWindingRule = clipWindingRuleOriginal;
linePath = linePathOriginal;
pageSize = pageSizeOriginal;
xform = xformOriginal;
}
}
// http://stackoverflow.com/a/21181943/535646
private BufferedImage create2ByteGrayAlphaImage(int width, int height)
{
// gray + alpha
int[] bandOffsets = new int[] { 1, 0 };
int bands = bandOffsets.length;
// Color Model used for raw GRAY + ALPHA
final ColorModel CM_GRAY_ALPHA = new ComponentColorModel(
ColorSpace.getInstance(ColorSpace.CS_GRAY), true, false,
Transparency.TRANSLUCENT, DataBuffer.TYPE_BYTE);
// Init data buffer of type byte
DataBuffer buffer = new DataBufferByte(width * height * bands);
// Wrap the data buffer in a raster
WritableRaster raster = Raster.createInterleavedRaster(buffer, width, height,
width * bands, bands, bandOffsets, new Point(0, 0));
// Create a custom BufferedImage with the raster and a suitable color model
return new BufferedImage(CM_GRAY_ALPHA, raster, false, null);
}
private boolean isGray(PDColorSpace colorSpace)
{
if (colorSpace instanceof PDDeviceGray)
{
return true;
}
if (colorSpace instanceof PDICCBased)
{
try
{
return ((PDICCBased) colorSpace).getAlternateColorSpace() instanceof PDDeviceGray;
}
catch (IOException ex)
{
return false;
}
}
return false;
}
BufferedImage getImage()
{
return image;
}
PDRectangle getBBox()
{
return bbox;
}
Rectangle2D getBounds()
{
// apply the underlying Graphics2D device's DPI transform and y-axis flip
Rectangle2D r = new Rectangle2D.Double(
minX - pageSize.getLowerLeftX() * xformScalingFactorX,
(pageSize.getLowerLeftY() + pageSize.getHeight()) * xformScalingFactorY - minY
- height, width, height);
// this adjusts the rectangle to the rotated image to put the soft mask at the correct position
//TODO
// 1. change transparencyGroup.getBounds() to getOrigin(), because size isn't used in SoftMask,
// 2. Is it possible to create the softmask and transparency group in the correct rotation?
// (needs rendering identity testing before committing!)
AffineTransform adjustedTransform = new AffineTransform(xform);
adjustedTransform.scale(1.0 / xformScalingFactorX, 1.0 / xformScalingFactorY);
return adjustedTransform.createTransformedShape(r).getBounds2D();
}
}
private boolean hasBlendMode(PDTransparencyGroup group, Set groupsDone)
{
if (groupsDone.contains(group.getCOSObject()))
{
// The group was already processed. Avoid endless recursion.
return false;
}
groupsDone.add(group.getCOSObject());
PDResources resources = group.getResources();
if (resources == null)
{
return false;
}
for (COSName name : resources.getExtGStateNames())
{
PDExtendedGraphicsState extGState = resources.getExtGState(name);
if (extGState == null)
{
continue;
}
BlendMode blendMode = extGState.getBlendMode();
if (blendMode != BlendMode.NORMAL)
{
return true;
}
}
// Recursively process nested transparency groups
for (COSName name : resources.getXObjectNames())
{
PDXObject xObject;
try
{
xObject = resources.getXObject(name);
}
catch (IOException ex)
{
continue;
}
if (xObject instanceof PDTransparencyGroup && hasBlendMode(
(PDTransparencyGroup) xObject, groupsDone))
{
return true;
}
}
return false;
}
/**
* {@inheritDoc}
*/
@Override
public void beginMarkedContentSequence(COSName tag, COSDictionary properties)
{
if (nestedHiddenOCGCount > 0)
{
nestedHiddenOCGCount++;
return;
}
if (tag == null || getResources() == null)
{
return;
}
if (isHiddenOCG(getResources().getProperties(tag)))
{
nestedHiddenOCGCount = 1;
}
}
/**
* {@inheritDoc}
*/
@Override
public void endMarkedContentSequence()
{
if (nestedHiddenOCGCount > 0)
{
nestedHiddenOCGCount--;
}
}
private boolean isContentRendered()
{
return nestedHiddenOCGCount <= 0;
}
private boolean isHiddenOCG(PDPropertyList propertyList)
{
if (propertyList instanceof PDOptionalContentGroup group)
{
RenderState printState = group.getRenderState(destination);
if (printState == null)
{
return !getRenderer().isGroupEnabled(group);
}
if (RenderState.OFF.equals(printState))
{
return true;
}
}
else if (propertyList instanceof PDOptionalContentMembershipDictionary)
{
return isHiddenOCMD((PDOptionalContentMembershipDictionary) propertyList);
}
return false;
}
private boolean isHiddenOCMD(PDOptionalContentMembershipDictionary ocmd)
{
if (ocmd.getCOSObject().getCOSArray(COSName.VE) != null)
{
// support seems to be optional, and is approximated by /P and /OCGS
LOG.info("/VE entry ignored in Optional Content Membership Dictionary");
}
List oCGs = ocmd.getOCGs();
if (oCGs.isEmpty())
{
return false;
}
List visibles = new ArrayList<>();
for (PDPropertyList prop : oCGs)
{
visibles.add(!isHiddenOCG(prop));
}
COSName visibilityPolicy = ocmd.getVisibilityPolicy();
if (COSName.ANY_OFF.equals(visibilityPolicy))
{
for (boolean visible : visibles)
{
if (!visible)
{
return false;
}
}
return true;
}
if (COSName.ALL_ON.equals(visibilityPolicy))
{
for (boolean visible : visibles)
{
if (!visible)
{
return true;
}
}
return false;
}
if (COSName.ALL_OFF.equals(visibilityPolicy))
{
for (boolean visible : visibles)
{
if (visible)
{
return true;
}
}
return false;
}
// AnyOn is default
for (boolean visible : visibles)
{
if (visible)
{
return false;
}
}
return true;
}
public boolean isTextContentRendered()
{
return textContentRendered;
}
public void setTextContentRendered(boolean textContentRendered)
{
this.textContentRendered = textContentRendered;
}
public boolean isImageContentRendered() {
return imageContentRendered;
}
public void setImageContentRendered(boolean imageContentRendered) {
this.imageContentRendered = imageContentRendered;
}
}