diff --git a/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/DssSigningEngine.java b/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/DssSigningEngine.java index 2256023f..af732796 100644 --- a/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/DssSigningEngine.java +++ b/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/DssSigningEngine.java @@ -54,9 +54,13 @@ import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.common.PDRectangle; +import eu.europa.esig.dss.alert.LogOnStatusAlert; import eu.europa.esig.dss.enumerations.CertificationPermission; import eu.europa.esig.dss.enumerations.DigestAlgorithm; +import eu.europa.esig.dss.enumerations.ImageScaling; import eu.europa.esig.dss.enumerations.SignatureLevel; +import eu.europa.esig.dss.enumerations.TextWrapping; +import eu.europa.esig.dss.pdf.PdfSignatureFieldPositionChecker; import eu.europa.esig.dss.model.DSSDocument; import eu.europa.esig.dss.model.FileDocument; import eu.europa.esig.dss.model.SignatureValue; @@ -65,6 +69,8 @@ import eu.europa.esig.dss.pades.PAdESSignatureParameters; import eu.europa.esig.dss.pades.SignatureFieldParameters; import eu.europa.esig.dss.pades.SignatureImageParameters; +import net.sf.jsignpdf.engine.dss.pdfbox.JSignPdfPdfObjFactory; +import net.sf.jsignpdf.engine.dss.pdfbox.JSignPdfSignatureImageParameters; import eu.europa.esig.dss.pades.SignatureImageTextParameters; import eu.europa.esig.dss.pades.signature.PAdESService; import eu.europa.esig.dss.service.http.commons.TimestampDataLoader; @@ -101,6 +107,14 @@ public class DssSigningEngine implements SigningEngine { */ static final String KEY_RETRY_ON_UNDERSIZE = "retryOnUndersize"; + /** + * Config key ({@code engine.dss.relaxFieldOverlap}): when {@code true}, a visible signature rectangle + * that overlaps an existing PDF annotation logs a warning instead of throwing an error. Defaults to + * {@code false} — DSS's strict behaviour is safer and only users who knowingly sign over annotations + * should enable this. + */ + static final String KEY_RELAX_FIELD_OVERLAP = "relaxFieldOverlap"; + /** Lower bound for the reserved {@code /Contents} size, matching DSS's own default; never estimate below it. */ private static final int MIN_CONTENT_SIZE = 9472; @@ -305,6 +319,19 @@ public boolean sign(final BasicSignerOptions options, final EngineConfig engineC } final PAdESService service = new PAdESService(verifier); + // Use custom PDF object factory with background-image layering + JSignPdfPdfObjFactory pdfObjFactory = new JSignPdfPdfObjFactory(); + + // Opt-in overlap relaxation: when enabled, a visible signature that overlaps + // an existing PDF annotation logs a warning instead of throwing an error. + if (engineConfig.getBoolean(KEY_RELAX_FIELD_OVERLAP, false)) { + PdfSignatureFieldPositionChecker positionChecker = new PdfSignatureFieldPositionChecker(); + positionChecker.setAlertOnSignatureFieldOverlap(new LogOnStatusAlert()); + pdfObjFactory.setPdfSignatureFieldPositionChecker(positionChecker); + } + + service.setPdfObjFactory(pdfObjFactory); + if (useTsa) { LOGGER.info(RES.get("console.creatingTsaClient")); // Wrap the TSA source so the timestamp chain is captured for diagnostics: if DSS later @@ -597,7 +624,7 @@ private AccessPermission buildAccessPermission(BasicSignerOptions options) { private void configureVisibleSignature(PAdESSignatureParameters parameters, BasicSignerOptions options, Certificate[] chain, Calendar signingCal, File inFile) throws Exception { - final SignatureImageParameters imageParams = new SignatureImageParameters(); + final JSignPdfSignatureImageParameters imageParams = new JSignPdfSignatureImageParameters(); int page = options.getPage(); float pageWidth; @@ -636,19 +663,34 @@ private void configureVisibleSignature(PAdESSignatureParameters parameters, Basi final RenderMode renderMode = options.getRenderMode(); final boolean withGraphic = renderMode == RenderMode.GRAPHIC_AND_DESCRIPTION; - // DSS renders a single signature image. In GRAPHIC_AND_DESCRIPTION mode that image is the signature - // graphic (options.imgPath); in the other render modes it is the background image (options.bgImgPath). - // The two are mutually exclusive here (unlike OpenPDF, which layers them), and we never silently - // substitute one for the other: a graphic render mode with no graphic configured yields text only. - final String imagePath = withGraphic ? options.getImgPath() : options.getBgImgPath(); - if (imagePath != null) { - LOGGER.info(RES.get("console.createImage", imagePath)); - imageParams.setImage(new FileDocument(imagePath)); + // Background image (drawn first, behind everything) — available in all modes + final String bgImgPath = options.getBgImgPath(); + if (bgImgPath != null) { + LOGGER.info(RES.get("console.createImage", bgImgPath)); + imageParams.setBackgroundImage(new FileDocument(bgImgPath)); + imageParams.setBackgroundScale(options.getBgImgScale()); + } + + // Foreground signature graphic (drawn above background, below text) — used in GRAPHIC_AND_DESCRIPTION mode + if (withGraphic) { + final String imgPath = options.getImgPath(); + if (imgPath != null) { + LOGGER.info(RES.get("console.createImage", imgPath)); + imageParams.setImage(new FileDocument(imgPath)); + } } LOGGER.info(RES.get("console.setL2Text")); final SignatureImageTextParameters textParams = new SignatureImageTextParameters(); textParams.setText(buildSignatureText(options, chain, signingCal)); + // FILL_BOX_AND_LINEBREAK: DSS auto-calculates the largest font that fits + // the signature rectangle, wrapping lines as needed. The font-size field + // in the GUI is used as the starting reference but the actual size is + // driven entirely by the box dimensions in this mode. + textParams.setTextWrapping(TextWrapping.FILL_BOX_AND_LINEBREAK); + // Transparent text background so the background image shows through. + // (DSS defaults to solid white which would paint over the image.) + textParams.setBackgroundColor(null); final DSSFont font = DssFontUtils.getVisibleSignatureFont(); if (font != null) { float fontSize = options.getL2TextFontSize(); @@ -660,6 +702,11 @@ private void configureVisibleSignature(PAdESSignatureParameters parameters, Basi } imageParams.setTextParameters(textParams); + // DSS 6.4+: STRETCH is incompatible with FILL_BOX_AND_LINEBREAK. + // The custom drawer handles its own scaling, so ZOOM_AND_CENTER + // (or any non-STRETCH value) works here as a validation pass-through. + imageParams.setImageScaling(ImageScaling.ZOOM_AND_CENTER); + parameters.setImageParameters(imageParams); } diff --git a/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/pdfbox/JSignPdfOverlaySignatureDrawer.java b/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/pdfbox/JSignPdfOverlaySignatureDrawer.java new file mode 100644 index 00000000..ad401176 --- /dev/null +++ b/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/pdfbox/JSignPdfOverlaySignatureDrawer.java @@ -0,0 +1,495 @@ +// Derived from DSS's NativePdfBoxVisibleSignatureDrawer (LGPL-2.1, https://github.com/esig/dss). +// JSignPdf is also LGPL-2.1 licensed. +package net.sf.jsignpdf.engine.dss.pdfbox; + +import eu.europa.esig.dss.model.DSSDocument; +import eu.europa.esig.dss.pades.DSSFileFont; +import eu.europa.esig.dss.pades.DSSFont; +import eu.europa.esig.dss.pades.PAdESUtils; +import eu.europa.esig.dss.pades.SignatureImageParameters; +import eu.europa.esig.dss.pades.SignatureImageTextParameters; +import eu.europa.esig.dss.pdf.AnnotationBox; +import eu.europa.esig.dss.pdf.pdfbox.PdfBoxUtils; +import eu.europa.esig.dss.pdf.pdfbox.visible.AbstractPdfBoxSignatureDrawer; +import eu.europa.esig.dss.pdf.pdfbox.visible.PdfBoxNativeFont; +import eu.europa.esig.dss.pdf.pdfbox.visible.nativedrawer.PdfBoxDSSFontMetrics; +import eu.europa.esig.dss.pdf.pdfbox.visible.nativedrawer.PdfBoxFontMapper; +import eu.europa.esig.dss.pdf.visible.DSSFontMetrics; +import eu.europa.esig.dss.pdf.visible.ImageRotationUtils; +import eu.europa.esig.dss.pdf.visible.ImageUtils; +import eu.europa.esig.dss.pdf.visible.SignatureFieldDimensionAndPosition; +import eu.europa.esig.dss.spi.signature.resources.DSSResourcesHandler; +import eu.europa.esig.dss.spi.signature.resources.DSSResourcesHandlerBuilder; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.io.IOUtils; +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.graphics.color.PDColor; +import org.apache.pdfbox.pdmodel.graphics.color.PDColorSpace; +import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceGray; +import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.apache.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream; +import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureOptions; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; +import org.apache.pdfbox.util.Matrix; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.Color; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; + +/* + * Custom DSS PDFBox drawer for JSignPdf. + * Draws background, then image, then overlays text on top. + * + * Forks DSS's NativePdfBoxVisibleSignatureDrawer to add background-image + * layering and per-image rotation. Coupled to DSS-internal nativedrawer and + * spi packages, so breakage on DSS upgrades is quarantined behind + * {@link JSignPdfSignatureDrawerFactory} — this drawer only activates when + * a background image is configured. + */ +public class JSignPdfOverlaySignatureDrawer extends AbstractPdfBoxSignatureDrawer { + + private static final Logger LOG = LoggerFactory.getLogger(JSignPdfOverlaySignatureDrawer.class); + + private PDFont pdFont; + + private DSSResourcesHandlerBuilder resourcesHandlerBuilder = PAdESUtils.DEFAULT_RESOURCES_HANDLER_BUILDER; + + public void setResourcesHandlerBuilder(DSSResourcesHandlerBuilder resourcesHandlerBuilder) { + this.resourcesHandlerBuilder = resourcesHandlerBuilder; + } + + /** + * Custom init that allows background-only signatures. + * The DSS {@code super.init()} calls {@code assertSignatureParametersAreValid()} which + * checks {@code getImage() == null && text.isEmpty()} directly — it never consults the + * overridden {@link JSignPdfSignatureImageParameters#isEmpty()}, so a signature with only + * a background image (no foreground graphic, no text) would be rejected. This override + * replicates the setup from {@code AbstractPdfBoxSignatureDrawer.init()} but applies a + * validation that respects the background-image field. + */ + @Override + public void init(SignatureImageParameters parameters, PDDocument document, SignatureOptions signatureOptions) + throws IOException { + boolean hasBackground = parameters instanceof JSignPdfSignatureImageParameters + && ((JSignPdfSignatureImageParameters) parameters).getBackgroundImage() != null; + if (parameters.getImage() == null && parameters.getTextParameters().isEmpty() && !hasBackground) { + throw new IllegalArgumentException("Neither image nor text parameters are defined!"); + } + this.parameters = parameters; + this.document = document; + this.signatureOptions = signatureOptions; + if (!parameters.getTextParameters().isEmpty()) { + this.pdFont = initFont(); + } + } + + private PDFont initFont() throws IOException { + DSSFont dssFont = parameters.getTextParameters().getFont(); + if (dssFont instanceof PdfBoxNativeFont) { + PdfBoxNativeFont nativeFont = (PdfBoxNativeFont) dssFont; + return nativeFont.getFont(); + } + if (dssFont instanceof DSSFileFont) { + DSSFileFont fileFont = (DSSFileFont) dssFont; + try (InputStream is = fileFont.getInputStream()) { + return PDType0Font.load(document, is, fileFont.isEmbedFontSubset()); + } + } + return PdfBoxFontMapper.getPDFont(dssFont.getJavaFont()); + } + + @Override + protected DSSFontMetrics getDSSFontMetrics() { + return new PdfBoxDSSFontMetrics(pdFont); + } + + /** + * Caps the DSS-calculated text size at the user's preferred font size. + * With {@link TextWrapping#FILL_BOX_AND_LINEBREAK}, DSS ignores the font + * size and may produce oversized text on large boxes. This override uses + * the user's font size as an upper bound: text still auto-scales DOWN for + * small boxes, but never exceeds the preferred size. + */ + @Override + public SignatureFieldDimensionAndPosition buildSignatureFieldBox() { + SignatureFieldDimensionAndPosition dim = super.buildSignatureFieldBox(); + SignatureImageTextParameters textParams = parameters.getTextParameters(); + if (textParams != null && textParams.getFont() != null) { + float preferredSize = textParams.getFont().getSize(); + if (preferredSize > 0f && dim.getTextSize() > preferredSize) { + dim.setTextSize(preferredSize); + } + } + return dim; + } + + @Override + public void draw() throws IOException { + try (DSSResourcesHandler resourcesHandler = resourcesHandlerBuilder.createResourcesHandler(); + OutputStream os = resourcesHandler.createOutputStream(); + PDDocument doc = new PDDocument()) { + + int pageNumber = parameters.getFieldParameters().getPage() - ImageUtils.DEFAULT_FIRST_PAGE; + PDPage originalPage = document.getPage(pageNumber); + SignatureFieldDimensionAndPosition dimensionAndPosition = buildSignatureFieldBox(); + + PDPage page = new PDPage(originalPage.getMediaBox()); + doc.addPage(page); + PDAcroForm acroForm = new PDAcroForm(doc); + doc.getDocumentCatalog().setAcroForm(acroForm); + PDSignatureField signatureField = new PDSignatureField(acroForm); + PDAnnotationWidget widget = signatureField.getWidgets().get(0); + List acroFormFields = acroForm.getFields(); + acroForm.setSignaturesExist(true); + acroForm.setAppendOnly(true); + acroForm.getCOSObject().setDirect(true); + acroFormFields.add(signatureField); + + PDRectangle rectangle = getPdRectangle(dimensionAndPosition); + widget.setRectangle(rectangle); + + PDAppearanceDictionary appearance = PdfBoxUtils.createSignatureAppearanceDictionary(doc, rectangle); + widget.setAppearance(appearance); + + PDAppearanceStream appearanceStream = appearance.getNormalAppearance().getAppearanceStream(); + try (PDPageContentStream cs = new PDPageContentStream(doc, appearanceStream)) { + rotateSignature(cs, rectangle, dimensionAndPosition); + setFieldBackground(cs, parameters.getBackgroundColor()); + DSSDocument background = null; + float backgroundScale = 0f; + if (parameters instanceof JSignPdfSignatureImageParameters) { + JSignPdfSignatureImageParameters jsignParams = (JSignPdfSignatureImageParameters) parameters; + background = jsignParams.getBackgroundImage(); + backgroundScale = jsignParams.getBackgroundScale(); + } + + if (background != null) { + setBackgroundImage(cs, doc, dimensionAndPosition, background, backgroundScale); + } + setImageScaledToBox(cs, doc, dimensionAndPosition, parameters.getImage()); + setText(cs, dimensionAndPosition, parameters); + } + + doc.save(os); + DSSDocument document = resourcesHandler.writeToDSSDocument(); + try (InputStream is = document.openStream()) { + signatureOptions.setVisualSignature(is); + } + } + } + + private void rotateSignature(PDPageContentStream cs, PDRectangle rectangle, + SignatureFieldDimensionAndPosition dimensionAndPosition) throws IOException { + switch (dimensionAndPosition.getGlobalRotation()) { + case ImageRotationUtils.ANGLE_90: + cs.transform(Matrix.getRotateInstance(Math.toRadians(ImageRotationUtils.ANGLE_270), 0, 0)); + cs.transform(Matrix.getTranslateInstance(-rectangle.getHeight(), 0)); + break; + case ImageRotationUtils.ANGLE_180: + cs.transform(Matrix.getRotateInstance(Math.toRadians(ImageRotationUtils.ANGLE_180), 0, 0)); + cs.transform(Matrix.getTranslateInstance(-rectangle.getWidth(), -rectangle.getHeight())); + break; + case ImageRotationUtils.ANGLE_270: + cs.transform(Matrix.getRotateInstance(Math.toRadians(ImageRotationUtils.ANGLE_90), 0, 0)); + cs.transform(Matrix.getTranslateInstance(0, -rectangle.getWidth())); + break; + case ImageRotationUtils.ANGLE_360: + case ImageRotationUtils.ANGLE_0: + break; + default: + throw new IllegalStateException(ImageRotationUtils.SUPPORTED_ANGLES_ERROR_MESSAGE); + } + } + + private void setFieldBackground(PDPageContentStream cs, Color color) throws IOException { + setBackground(cs, color, new PDRectangle(-5000, -5000, 10000, 10000)); + } + + private void setBackground(PDPageContentStream cs, Color color, PDRectangle rect) throws IOException { + if (color != null) { + setAlphaChannel(cs, color); + setNonStrokingColor(cs, color); + cs.addRect(rect.getLowerLeftX(), rect.getLowerLeftY(), rect.getWidth(), rect.getHeight()); + cs.fill(); + cleanTransparency(cs, color); + } + } + + private void setImageScaledToBox(PDPageContentStream cs, PDDocument doc, + SignatureFieldDimensionAndPosition dimensionAndPosition, DSSDocument image) throws IOException { + if (image == null) { + return; + } + + AnnotationBox annotationBox = dimensionAndPosition.getAnnotationBox(); + float boxWidth = annotationBox.getWidth(); + float boxHeight = annotationBox.getHeight(); + AnnotationBox imageBoundaryBox = ImageUtils.getImageBoundaryBox(image); + float imageWidth = imageBoundaryBox.getWidth(); + float imageHeight = imageBoundaryBox.getHeight(); + if (imageWidth <= 0f || imageHeight <= 0f) { + return; + } + + int imageRotation = dimensionAndPosition.getGlobalRotation(); + boolean rotated = imageRotation == ImageRotationUtils.ANGLE_90 + || imageRotation == ImageRotationUtils.ANGLE_270; + if (rotated) { + float tmp = imageWidth; + imageWidth = imageHeight; + imageHeight = tmp; + } + + float imageRatio = imageWidth / imageHeight; + float boxRatio = boxWidth / boxHeight; + float drawWidth; + float drawHeight; + float drawX; + float drawY; + if (imageRatio < boxRatio) { + drawHeight = boxHeight; + drawWidth = boxHeight * imageRatio; + drawX = (boxWidth - drawWidth) / 2f; + drawY = 0f; + } else { + drawWidth = boxWidth; + drawHeight = boxWidth / imageRatio; + drawX = 0f; + drawY = (boxHeight - drawHeight) / 2f; + } + + try (InputStream is = image.openStream()) { + cs.saveGraphicsState(); + applyImageRotation(cs, boxWidth, boxHeight, drawWidth, drawHeight, drawX, drawY, imageRotation); + byte[] bytes = IOUtils.toByteArray(is); + PDImageXObject imageXObject = PDImageXObject.createFromByteArray(doc, bytes, image.getName()); + cs.drawImage(imageXObject, drawX, drawY, drawWidth, drawHeight); + cs.restoreGraphicsState(); + } + } + + /** + * Applies a per-image rotation transform so rotated fields render the graphic with + * the same orientation. Mirror of {@code NativePdfBoxVisibleSignatureDrawer}'s + * image-rotation handling. + */ + private void applyImageRotation(PDPageContentStream cs, float boxWidth, float boxHeight, + float drawWidth, float drawHeight, float drawX, float drawY, int imageRotation) throws IOException { + switch (imageRotation) { + case ImageRotationUtils.ANGLE_90: + cs.transform(Matrix.getTranslateInstance(drawX + drawWidth, drawY)); + cs.transform(Matrix.getRotateInstance(Math.toRadians(ImageRotationUtils.ANGLE_90), 0, 0)); + cs.transform(Matrix.getTranslateInstance(-drawX - drawWidth, -drawY)); + break; + case ImageRotationUtils.ANGLE_180: + cs.transform(Matrix.getTranslateInstance(drawX + drawWidth, drawY + drawHeight)); + cs.transform(Matrix.getRotateInstance(Math.toRadians(ImageRotationUtils.ANGLE_180), 0, 0)); + cs.transform(Matrix.getTranslateInstance(-drawX - drawWidth, -drawY - drawHeight)); + break; + case ImageRotationUtils.ANGLE_270: + cs.transform(Matrix.getTranslateInstance(drawX, drawY + drawHeight)); + cs.transform(Matrix.getRotateInstance(Math.toRadians(ImageRotationUtils.ANGLE_270), 0, 0)); + cs.transform(Matrix.getTranslateInstance(-drawX, -drawY - drawHeight)); + break; + case ImageRotationUtils.ANGLE_0: + case ImageRotationUtils.ANGLE_360: + default: + break; + } + } + + private void setBackgroundImage(PDPageContentStream cs, PDDocument doc, + SignatureFieldDimensionAndPosition dimensionAndPosition, DSSDocument image, float bgScale) + throws IOException { + AnnotationBox annotationBox = dimensionAndPosition.getAnnotationBox(); + float boxWidth = annotationBox.getWidth(); + float boxHeight = annotationBox.getHeight(); + AnnotationBox imageBoundaryBox = ImageUtils.getImageBoundaryBox(image); + float imageWidth = imageBoundaryBox.getWidth(); + float imageHeight = imageBoundaryBox.getHeight(); + if (imageWidth <= 0f || imageHeight <= 0f) { + return; + } + + float drawWidth; + float drawHeight; + float drawX; + float drawY; + + if (bgScale == 0f) { + drawWidth = boxWidth; + drawHeight = boxHeight; + drawX = 0f; + drawY = 0f; + } else { + float scale = bgScale > 0f ? bgScale : Math.min(boxWidth / imageWidth, boxHeight / imageHeight); + drawWidth = imageWidth * scale; + drawHeight = imageHeight * scale; + drawX = (boxWidth - drawWidth) / 2f; + drawY = (boxHeight - drawHeight) / 2f; + } + + try (InputStream is = image.openStream()) { + cs.saveGraphicsState(); + byte[] bytes = IOUtils.toByteArray(is); + PDImageXObject imageXObject = PDImageXObject.createFromByteArray(doc, bytes, image.getName()); + cs.drawImage(imageXObject, drawX, drawY, drawWidth, drawHeight); + cs.restoreGraphicsState(); + } + } + + private void setText(PDPageContentStream cs, SignatureFieldDimensionAndPosition dimensionAndPosition, + SignatureImageParameters parameters) throws IOException { + SignatureImageTextParameters textParameters = parameters.getTextParameters(); + if (!textParameters.isEmpty()) { + setTextBackground(cs, textParameters, dimensionAndPosition); + float fontSize = dimensionAndPosition.getTextSize(); + cs.beginText(); + cs.setFont(pdFont, fontSize); + setNonStrokingColor(cs, textParameters.getTextColor()); + setAlphaChannel(cs, textParameters.getTextColor()); + + PdfBoxDSSFontMetrics pdfBoxFontMetrics = new PdfBoxDSSFontMetrics(pdFont); + + String text = dimensionAndPosition.getText(); + String[] strings = pdfBoxFontMetrics.getLines(text); + + float lineHeight = pdfBoxFontMetrics.getHeight(text, dimensionAndPosition.getTextSize()); + cs.setLeading(lineHeight); + + cs.newLineAtOffset(dimensionAndPosition.getTextX(), + dimensionAndPosition.getTextHeight() + dimensionAndPosition.getTextY() - fontSize); + + float previousOffset = 0; + for (String str : strings) { + float stringWidth = pdfBoxFontMetrics.getWidth(str, fontSize); + float offsetX = 0; + switch (textParameters.getSignerTextHorizontalAlignment()) { + case RIGHT: + offsetX = dimensionAndPosition.getTextWidth() - stringWidth - previousOffset; + break; + case CENTER: + offsetX = (dimensionAndPosition.getTextWidth() - stringWidth) / 2 - previousOffset; + break; + default: + break; + } + previousOffset += offsetX; + cs.newLineAtOffset(offsetX, 0); + cs.showText(str); + cs.newLine(); + } + cs.endText(); + cleanTransparency(cs, textParameters.getTextColor()); + } + } + + private void setTextBackground(PDPageContentStream cs, SignatureImageTextParameters textParameters, + SignatureFieldDimensionAndPosition dimensionAndPosition) throws IOException { + if (textParameters.getBackgroundColor() != null) { + PDRectangle rect = new PDRectangle( + dimensionAndPosition.getTextBoxX(), dimensionAndPosition.getTextBoxY(), + dimensionAndPosition.getTextBoxWidth(), dimensionAndPosition.getTextBoxHeight()); + setBackground(cs, textParameters.getBackgroundColor(), rect); + } + } + + private void setNonStrokingColor(PDPageContentStream cs, Color color) throws IOException { + if (color != null) { + cs.setNonStrokingColor(toPDColor(color)); + } + } + + private PDColor toPDColor(Color color) { + float[] components; + PDColorSpace pdColorSpace; + if (ImageUtils.isGrayscale(color)) { + components = new float[] { color.getRed() / 255f }; + pdColorSpace = PDDeviceGray.INSTANCE; + } else { + components = new float[] { color.getRed() / 255f, color.getGreen() / 255f, color.getBlue() / 255f }; + pdColorSpace = PDDeviceRGB.INSTANCE; + } + return new PDColor(components, pdColorSpace); + } + + private void setAlphaChannel(PDPageContentStream cs, Color color) throws IOException { + if (color != null) { + float alpha = color.getAlpha(); + if (alpha < ImageUtils.OPAQUE_VALUE) { + LOG.warn("Transparency detected and enabled (Be aware: not valid with PDF/A !)"); + setAlpha(cs, alpha); + } + } + } + + private void setAlpha(PDPageContentStream cs, float alpha) throws IOException { + PDExtendedGraphicsState gs = new PDExtendedGraphicsState(); + gs.setNonStrokingAlphaConstant(alpha / ImageUtils.OPAQUE_VALUE); + cs.setGraphicsStateParameters(gs); + } + + private void cleanTransparency(PDPageContentStream cs, Color color) throws IOException { + if (color != null) { + float alpha = color.getAlpha(); + if (alpha < ImageUtils.OPAQUE_VALUE) { + setAlpha(cs, ImageUtils.OPAQUE_VALUE); + } + } + } + + private PDRectangle getPdRectangle(SignatureFieldDimensionAndPosition dimensionAndPosition) { + AnnotationBox annotationBox = dimensionAndPosition.getAnnotationBox(); + + PDRectangle pdRectangle = new PDRectangle(); + pdRectangle.setLowerLeftX(annotationBox.getMinX()); + pdRectangle.setLowerLeftY(annotationBox.getMinY()); + pdRectangle.setUpperRightX(annotationBox.getMaxX()); + pdRectangle.setUpperRightY(annotationBox.getMaxY()); + return pdRectangle; + } + + @Override + protected String getExpectedColorSpaceName() throws IOException { + // Check the foreground image first + if (parameters.getImage() != null) { + try (InputStream is = parameters.getImage().openStream()) { + byte[] bytes = IOUtils.toByteArray(is); + PDImageXObject imageXObject = PDImageXObject.createFromByteArray(document, bytes, parameters.getImage().getName()); + PDColorSpace colorSpace = imageXObject.getColorSpace(); + return colorSpace.getName(); + } + } + // Also inspect the background image (otherwise a background-only signature with + // an RGB/CMYK image can get a DEVICEGRAY output intent injected, producing wrong + // output and a PDF/A inconsistency) + if (parameters instanceof JSignPdfSignatureImageParameters) { + JSignPdfSignatureImageParameters jsignParams = (JSignPdfSignatureImageParameters) parameters; + DSSDocument background = jsignParams.getBackgroundImage(); + if (background != null) { + try (InputStream is = background.openStream()) { + byte[] bytes = IOUtils.toByteArray(is); + PDImageXObject imageXObject = PDImageXObject.createFromByteArray(document, bytes, background.getName()); + PDColorSpace colorSpace = imageXObject.getColorSpace(); + return colorSpace.getName(); + } + } + } + return ImageUtils.containRGBColor(parameters) ? COSName.DEVICERGB.getName() : COSName.DEVICEGRAY.getName(); + } +} diff --git a/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/pdfbox/JSignPdfPdfObjFactory.java b/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/pdfbox/JSignPdfPdfObjFactory.java new file mode 100644 index 00000000..9d6fd8dc --- /dev/null +++ b/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/pdfbox/JSignPdfPdfObjFactory.java @@ -0,0 +1,29 @@ +package net.sf.jsignpdf.engine.dss.pdfbox; + +import eu.europa.esig.dss.pdf.AbstractPdfObjFactory; +import eu.europa.esig.dss.pdf.PDFServiceMode; +import eu.europa.esig.dss.pdf.PDFSignatureService; +import eu.europa.esig.dss.pdf.pdfbox.PdfBoxSignatureService; + +public class JSignPdfPdfObjFactory extends AbstractPdfObjFactory { + + @Override + public PDFSignatureService newPAdESSignatureService() { + return configure(new PdfBoxSignatureService(PDFServiceMode.SIGNATURE, new JSignPdfSignatureDrawerFactory())); + } + + @Override + public PDFSignatureService newContentTimestampService() { + return configure(new PdfBoxSignatureService(PDFServiceMode.CONTENT_TIMESTAMP, new JSignPdfSignatureDrawerFactory())); + } + + @Override + public PDFSignatureService newSignatureTimestampService() { + return configure(new PdfBoxSignatureService(PDFServiceMode.SIGNATURE_TIMESTAMP, new JSignPdfSignatureDrawerFactory())); + } + + @Override + public PDFSignatureService newArchiveTimestampService() { + return configure(new PdfBoxSignatureService(PDFServiceMode.ARCHIVE_TIMESTAMP, new JSignPdfSignatureDrawerFactory())); + } +} diff --git a/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/pdfbox/JSignPdfSignatureDrawerFactory.java b/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/pdfbox/JSignPdfSignatureDrawerFactory.java new file mode 100644 index 00000000..d85fbb9f --- /dev/null +++ b/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/pdfbox/JSignPdfSignatureDrawerFactory.java @@ -0,0 +1,25 @@ +package net.sf.jsignpdf.engine.dss.pdfbox; + +import eu.europa.esig.dss.pades.SignatureImageParameters; +import eu.europa.esig.dss.pdf.pdfbox.visible.PdfBoxSignatureDrawer; +import eu.europa.esig.dss.pdf.pdfbox.visible.PdfBoxSignatureDrawerFactory; +import eu.europa.esig.dss.pdf.pdfbox.visible.nativedrawer.NativePdfBoxVisibleSignatureDrawer; + +/** + * Factory that selects the right signature drawer based on the input parameters. + * Only routes to the custom {@link JSignPdfOverlaySignatureDrawer} when a background + * image is actually configured; otherwise falls back to DSS's stock + * {@link NativePdfBoxVisibleSignatureDrawer}. This quarantines the fragile fork so + * a DSS upgrade that breaks it cannot break the default signing path, and keeps + * PDF/A compliance automatic for signatures without a background image. + */ +public class JSignPdfSignatureDrawerFactory implements PdfBoxSignatureDrawerFactory { + + @Override + public PdfBoxSignatureDrawer getSignatureDrawer(SignatureImageParameters imageParameters) { + if (imageParameters instanceof JSignPdfSignatureImageParameters jp && jp.getBackgroundImage() != null) { + return new JSignPdfOverlaySignatureDrawer(); + } + return new NativePdfBoxVisibleSignatureDrawer(); + } +} diff --git a/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/pdfbox/JSignPdfSignatureImageParameters.java b/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/pdfbox/JSignPdfSignatureImageParameters.java new file mode 100644 index 00000000..f5bde115 --- /dev/null +++ b/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/pdfbox/JSignPdfSignatureImageParameters.java @@ -0,0 +1,33 @@ +package net.sf.jsignpdf.engine.dss.pdfbox; + +import eu.europa.esig.dss.model.DSSDocument; +import eu.europa.esig.dss.pades.SignatureImageParameters; + +public class JSignPdfSignatureImageParameters extends SignatureImageParameters { + + private static final long serialVersionUID = 1L; + + private DSSDocument backgroundImage; + private float backgroundScale = -1f; + + public DSSDocument getBackgroundImage() { + return backgroundImage; + } + + public void setBackgroundImage(DSSDocument backgroundImage) { + this.backgroundImage = backgroundImage; + } + + public float getBackgroundScale() { + return backgroundScale; + } + + public void setBackgroundScale(float backgroundScale) { + this.backgroundScale = backgroundScale; + } + + @Override + public boolean isEmpty() { + return getImage() == null && backgroundImage == null && getTextParameters().isEmpty(); + } +} diff --git a/engines/dss/src/test/java/net/sf/jsignpdf/engine/dss/DssSigningEngineTest.java b/engines/dss/src/test/java/net/sf/jsignpdf/engine/dss/DssSigningEngineTest.java index 581be433..8d77b5f4 100644 --- a/engines/dss/src/test/java/net/sf/jsignpdf/engine/dss/DssSigningEngineTest.java +++ b/engines/dss/src/test/java/net/sf/jsignpdf/engine/dss/DssSigningEngineTest.java @@ -4,6 +4,9 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; import java.io.File; import java.io.FileOutputStream; import java.nio.file.Files; @@ -13,6 +16,8 @@ import java.util.List; import java.util.Map; +import javax.imageio.ImageIO; + import net.sf.jsignpdf.BasicSignerOptions; import net.sf.jsignpdf.engine.Capability; import net.sf.jsignpdf.engine.EngineConfig; @@ -410,6 +415,50 @@ private EngineConfig caTrustConfig() throws Exception { return new MapEngineConfig(cfg); } + /** + * Creates a minimal 10x10 PNG image and writes it to a temp file. + */ + private File createTestImage(String name, Color color) throws Exception { + BufferedImage img = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = img.createGraphics(); + g2d.setColor(color); + g2d.fillRect(0, 0, 10, 10); + g2d.dispose(); + File file = tmp.newFile(name + ".png"); + ImageIO.write(img, "png", file); + return file; + } + + @Test + public void visibleSignatureWithBackgroundImageProducesValidSignature() throws Exception { + BasicSignerOptions o = baseOptions(); + o.setVisible(true); + o.setPage(1); + o.setPositionLLX(100f); + o.setPositionLLY(100f); + o.setPositionURX(300f); + o.setPositionURY(250f); + o.setReason("testing"); + o.setLocation("here"); + + // Background image (rendered behind everything) + File bgImage = createTestImage("bg-test", Color.LIGHT_GRAY); + o.setBgImgPath(bgImage.getAbsolutePath()); + o.setBgImgScale(100f); + + // Foreground graphic (rendered above background) + o.setRenderMode(net.sf.jsignpdf.types.RenderMode.GRAPHIC_AND_DESCRIPTION); + File fgImage = createTestImage("fg-test", Color.BLUE); + o.setImgPath(fgImage.getAbsolutePath()); + + assertTrue("signing with background + graphic + text must succeed", + new DssSigningEngine().sign(o, EMPTY_CONFIG)); + assertTrue("output must exist", outputFile.exists()); + + // Verify the output is a structurally valid PAdES signature + assertSignatureLevel(outputFile, SignatureLevel.PAdES_BASELINE_B); + } + @Test public void capabilitiesAreStaticAndDeclarePades() { DssSigningEngine engine = new DssSigningEngine();