diff --git a/pom.xml b/pom.xml
index 7c0965b..818ae13 100644
--- a/pom.xml
+++ b/pom.xml
@@ -26,6 +26,12 @@
slf4j-api
provided
+
+ org.apache.logging.log4j
+ log4j-slf4j2-impl
+ 2.20.0
+ test
+
com.google.guava
guava
diff --git a/src/main/java/com/iqser/red/pdftronlogic/commons/ElementFeatureFactory.java b/src/main/java/com/iqser/red/pdftronlogic/commons/ElementFeatureFactory.java
new file mode 100644
index 0000000..6f1407e
--- /dev/null
+++ b/src/main/java/com/iqser/red/pdftronlogic/commons/ElementFeatureFactory.java
@@ -0,0 +1,80 @@
+package com.iqser.red.pdftronlogic.commons;
+
+import com.pdftron.common.PDFNetException;
+import com.pdftron.pdf.Element;
+
+public class ElementFeatureFactory {
+ public static ElementFeatures extractFeatures(Element element) throws PDFNetException {
+
+ return switch (element.getType()) {
+ case Element.e_path -> buildPath(element);
+ case Element.e_text -> buildText(element);
+ case Element.e_image, Element.e_inline_image -> buildImage(element).build();
+ case Element.e_form -> buildForm(element);
+ // This technically should never happen, it's a safetynet
+ default -> throw new RuntimeException("Feature Extraction is not supported for PDFTron.Element with type: " + element.getType());
+ };
+ }
+
+ public static ElementFeatures extractFeaturesWithHash(Element element, String hashObject) throws PDFNetException {
+ return buildImage(element)
+ .hashOfImage(hashObject)
+ .build();
+ }
+
+
+ private static ElementFeatures.Form buildForm(Element element) throws PDFNetException {
+
+ return ElementFeatures.Form.builder()
+ .elementType(element.getType())
+ .boundingBox(Converter.toRectangle2D(element.getBBox()))
+ .xObjectType(element.getXObject().getType())
+ .dictOrArrayOrStreamLength(element.getXObject().getType() == 7 ? element.getXObject().getDecodedStream().size() : 0)
+ .build();
+ }
+
+
+ private static ElementFeatures.Image.ImageBuilder, ?> buildImage(Element element) throws PDFNetException {
+
+ return ElementFeatures.Image.builder()
+ .elementType(element.getType())
+ .boundingBox(Converter.toRectangle2D(element.getBBox()))
+ .dataSize(element.getImageDataSize())
+ .height(element.getImageHeight())
+ .width(element.getImageWidth())
+ .renderingIntent(element.getImageRenderingIntent())
+ .componentNum(element.getComponentNum())
+ .bitsPerComponent(element.getBitsPerComponent());
+ }
+
+
+ private static ElementFeatures.Text buildText(Element element) throws PDFNetException {
+
+ return ElementFeatures.Text.builder()
+ .elementType(element.getType())
+ .boundingBox(Converter.toRectangle2D(element.getBBox()))
+ .text(element.getTextString())
+ .font(element.getGState().getFont().getType())
+ .fontsize(element.getGState().getFontSize())
+ .build();
+ }
+
+
+ private static ElementFeatures.Path buildPath(Element element) throws PDFNetException {
+
+ return ElementFeatures.Path.builder()
+ .elementType(element.getType())
+ .boundingBox(Converter.toRectangle2D(element.getBBox()))
+ .isClippingPath(element.isClippingPath())
+ .isClipWindingFill(element.isClipWindingFill())
+ .isStroked(element.isStroked())
+ .isFilled(element.isFilled())
+ .isWindingFill(element.isWindingFill())
+ .fillColor(Converter.convertColor(element.getGState().getFillColorSpace(), element.getGState().getFillColor()))
+ .strokeColor(Converter.convertColor(element.getGState().getStrokeColorSpace(), element.getGState().getStrokeColor()))
+ .linePath(Converter.convertToGeneralPathAndTransformToInitialUserSpace(element.getPathData(), element.getCTM()))
+ .build();
+ }
+
+
+}
diff --git a/src/main/java/com/iqser/red/pdftronlogic/commons/ElementFeatures.java b/src/main/java/com/iqser/red/pdftronlogic/commons/ElementFeatures.java
index 51be5ab..6681ac9 100644
--- a/src/main/java/com/iqser/red/pdftronlogic/commons/ElementFeatures.java
+++ b/src/main/java/com/iqser/red/pdftronlogic/commons/ElementFeatures.java
@@ -26,43 +26,6 @@ public class ElementFeatures {
int elementType;
Rectangle2D boundingBox;
- public static ElementFeatures extractFeatures(Element element) throws PDFNetException {
-
- return switch (element.getType()) {
- case Element.e_path -> Path.builder()
- .elementType(element.getType())
- .boundingBox(Converter.toRectangle2D(element.getBBox()))
- .isClippingPath(element.isClippingPath())
- .isClipWindingFill(element.isClipWindingFill())
- .isStroked(element.isStroked())
- .isFilled(element.isFilled())
- .isWindingFill(element.isWindingFill())
- .fillColor(Converter.convertColor(element.getGState().getFillColorSpace(), element.getGState().getFillColor()))
- .strokeColor(Converter.convertColor(element.getGState().getStrokeColorSpace(), element.getGState().getStrokeColor()))
- .linePath(Converter.convertToGeneralPathAndTransformToInitialUserSpace(element.getPathData(), element.getCTM()))
- .build();
- case Element.e_text -> Text.builder()
- .elementType(element.getType())
- .boundingBox(Converter.toRectangle2D(element.getBBox()))
- .text(element.getTextString())
- .font(element.getGState().getFont().getType())
- .fontsize(element.getGState().getFontSize())
- .build();
- case Element.e_image, Element.e_inline_image -> Image.builder()
- .elementType(element.getType())
- .boundingBox(Converter.toRectangle2D(element.getBBox()))
- .dataSize(element.getImageDataSize())
- .height(element.getImageHeight())
- .width(element.getImageWidth())
- .renderingIntent(element.getImageRenderingIntent())
- .componentNum(element.getComponentNum())
- .bitsPerComponent(element.getBitsPerComponent())
- .build();
- // This technically should never happen, it's a safetynet
- default -> throw new RuntimeException("Feature Extraction is not supported for PDFTron.Element with type: " + element.getType());
- };
- }
-
public boolean almostMatches(Element element) throws PDFNetException {
@@ -71,6 +34,12 @@ public class ElementFeatures {
rectsAlmostMatch(element.getBBox());
}
+ public boolean almostMatches(ElementFeatures elementFeatures){
+ return elementFeatures.getElementType() == elementType &&
+ elementFeatures.getBoundingBox() != null &&
+ rectsAlmostMatch(elementFeatures.getBoundingBox());
+ }
+
protected boolean almostEqual(double a, double b) {
@@ -88,12 +57,22 @@ public class ElementFeatures {
almostEqual(bBox.getHeight(), boundingBox.getHeight());
}
+ @SneakyThrows
+ private boolean rectsAlmostMatch(Rectangle2D bBox) {
+ // To address the inconsistencies in the calculation of the bounding box we check equality with a tolerance
+
+ return almostEqual(bBox.getX(), boundingBox.getX()) && //
+ almostEqual(bBox.getY(), boundingBox.getY()) && //
+ almostEqual(bBox.getWidth(), boundingBox.getWidth()) && //
+ almostEqual(bBox.getHeight(), boundingBox.getHeight());
+ }
+
@EqualsAndHashCode(callSuper = true)
@Getter
@SuperBuilder
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
- private static class Text extends ElementFeatures {
+ public static class Text extends ElementFeatures {
String text;
int font;
@@ -159,7 +138,7 @@ public class ElementFeatures {
@Getter
@SuperBuilder
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
- private static class Image extends ElementFeatures {
+ public static class Image extends ElementFeatures {
int dataSize;
int height;
@@ -167,7 +146,7 @@ public class ElementFeatures {
int renderingIntent;
int componentNum;
int bitsPerComponent;
-
+ String hashOfImage;
@Override
public boolean almostMatches(Element element) throws PDFNetException {
@@ -181,6 +160,73 @@ public class ElementFeatures {
bitsPerComponent == element.getBitsPerComponent();
}
+ public boolean almostMatches(ElementFeatures elementFeatures){
+ if(elementFeatures.getClass() != this.getClass()){
+ return false;
+ }
+ return super.almostMatches(elementFeatures) &&
+ this.dataSize == ((Image) elementFeatures).getDataSize() &&
+ this.height == ((Image) elementFeatures).getHeight() &&
+ this.width == ((Image) elementFeatures).getWidth() &&
+ this.renderingIntent == ((Image) elementFeatures).getRenderingIntent() &&
+ this.componentNum == ((Image) elementFeatures).getComponentNum() &&
+ this.bitsPerComponent == ((Image) elementFeatures).getBitsPerComponent() &&
+ calculateHammingDistance(((Image) elementFeatures).getHashOfImage()) <=4;
+ }
+
+ // Helper method to calculate the Hamming distance between two hexadecimal strings
+ private int calculateHammingDistance(String hash2) {
+ int distance = 0;
+ int maxLength = Math.max(this.hashOfImage.length(), hash2.length());
+ for (int i = 0; i < maxLength; i++) {
+ char char1 = i < this.hashOfImage.length() ? this.hashOfImage.charAt(i) : '0';
+ char char2 = i < hash2.length() ? hash2.charAt(i) : '0';
+ if (char1 != char2) {
+ distance++;
+ }
+ }
+ return distance;
+ }
+
+ }
+
+ @EqualsAndHashCode(callSuper = true)
+ @Getter
+ @SuperBuilder
+ @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
+ public static class Form extends ElementFeatures {
+
+ int xObjectType;
+ long dictOrArrayOrStreamLength;
+
+
+ @Override
+ public boolean almostMatches(Element element) throws PDFNetException {
+ return element.getType() == getElementType() && //
+ element.getBBox() != null && //
+ (super.rectsAlmostMatch(element.getBBox()) || almostRotateMatches(element.getBBox().getRectangle())) &&
+ xObjectType == element.getXObject().getType() &&
+ dictOrArrayOrStreamLength == element.getXObject().getDecodedStream().size();
+ }
+
+ public boolean almostMatches(ElementFeatures elementFeatures){
+ if(elementFeatures.getClass() != this.getClass()){
+ return false;
+ }
+ return elementFeatures.getElementType() == getElementType() &&
+ elementFeatures.getBoundingBox() != null &&
+ (super.rectsAlmostMatch(elementFeatures.getBoundingBox()) || almostRotateMatches(elementFeatures.getBoundingBox().getBounds2D())) &&
+ xObjectType == ((Form)elementFeatures).getXObjectType() &&
+ dictOrArrayOrStreamLength == ((Form)elementFeatures).getDictOrArrayOrStreamLength();
+
+ }
+
+
+ private boolean almostRotateMatches(Rectangle2D bBox) {
+ return almostEqual(bBox.getWidth(), getBoundingBox().getHeight()) && //
+ almostEqual(bBox.getHeight(), getBoundingBox().getWidth());
+ }
+
}
diff --git a/src/main/java/com/iqser/red/pdftronlogic/commons/ImageHashFactory.java b/src/main/java/com/iqser/red/pdftronlogic/commons/ImageHashFactory.java
new file mode 100644
index 0000000..308b5ed
--- /dev/null
+++ b/src/main/java/com/iqser/red/pdftronlogic/commons/ImageHashFactory.java
@@ -0,0 +1,96 @@
+package com.iqser.red.pdftronlogic.commons;
+
+import java.awt.Color;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+
+import javax.imageio.ImageIO;
+
+import com.pdftron.filters.FilterWriter;
+import com.pdftron.filters.MemoryFilter;
+import com.pdftron.pdf.Element;
+
+import lombok.SneakyThrows;
+import lombok.experimental.UtilityClass;
+
+@UtilityClass
+public class ImageHashFactory {
+
+ @SneakyThrows
+ public String calculate(Element element) {
+
+ com.pdftron.pdf.Image image = new com.pdftron.pdf.Image(element.getXObject());
+
+ byte[] imageBytes = getBytesOfImage(image);
+ ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(imageBytes);
+ BufferedImage image1 = ImageIO.read(byteArrayInputStream);
+
+ String hash = getSimplePHash(image1);
+
+ return hash;
+
+ }
+
+
+ @SneakyThrows
+ private byte[] getBytesOfImage(com.pdftron.pdf.Image inputImage) {
+ // 0 because the memory filter determines the size
+ var memFilter = new MemoryFilter(0, false);
+ var filterWriter = new FilterWriter(memFilter);
+
+ inputImage.export(filterWriter);
+ filterWriter.flushAll();
+ byte[] res = memFilter.getBuffer();
+
+ memFilter.flushAll();
+ memFilter.destroy();
+ filterWriter.destroy();
+ return res;
+ }
+
+
+ public String getSimplePHash(BufferedImage image) {
+ // Resize the image to a fixed size (e.g., 8x8 pixels)
+ int targetWidth = 8;
+ int targetHeight = 8;
+ BufferedImage resizedImage = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_ARGB);
+ resizedImage.getGraphics().drawImage(image.getScaledInstance(targetWidth, targetHeight, java.awt.Image.SCALE_SMOOTH), 0, 0, targetWidth, targetHeight, null);
+
+ // Convert the image to grayscale
+ BufferedImage grayscaleImage = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_BYTE_GRAY);
+ grayscaleImage.getGraphics().drawImage(resizedImage, 0, 0, null);
+
+ // Calculate the average grayscale pixel value
+ int average = calculateAverage(grayscaleImage);
+
+ // Create a binary hash based on pixel values
+ StringBuilder hashBuilder = new StringBuilder();
+ for (int y = 0; y < targetHeight; y++) {
+ for (int x = 0; x < targetWidth; x++) {
+ int pixelValue = new Color(grayscaleImage.getRGB(x, y)).getRed();
+ if (pixelValue > average) {
+ hashBuilder.append("1");
+ } else {
+ hashBuilder.append("0");
+ }
+ }
+ }
+ return hashBuilder.toString();
+ }
+
+
+ // Helper method to calculate the average grayscale pixel value
+ private int calculateAverage(BufferedImage image) {
+
+ int total = 0;
+ int width = image.getWidth();
+ int height = image.getHeight();
+ for (int y = 0; y < height; y++) {
+ for (int x = 0; x < width; x++) {
+ total += new Color(image.getRGB(x, y)).getRed();
+ }
+ }
+ return total / (width * height);
+ }
+
+}
diff --git a/src/main/java/com/iqser/red/pdftronlogic/commons/InvisibleElementRemovalService.java b/src/main/java/com/iqser/red/pdftronlogic/commons/InvisibleElementRemovalService.java
index 8841398..fe7ba36 100644
--- a/src/main/java/com/iqser/red/pdftronlogic/commons/InvisibleElementRemovalService.java
+++ b/src/main/java/com/iqser/red/pdftronlogic/commons/InvisibleElementRemovalService.java
@@ -167,7 +167,7 @@ public class InvisibleElementRemovalService {
boolean inClippingPath = context.clippingPathStack().almostIntersects(rect.getX1(), rect.getY1(), rect.getWidth(), rect.getHeight());
if (!context.delta() && inClippingPath) {
- context.visibleElements().add(ElementFeatures.extractFeatures(imageElement));
+ context.visibleElements().add(ElementFeatureFactory.extractFeatures(imageElement));
}
if (context.delta() ^ inClippingPath) {
@@ -192,7 +192,7 @@ public class InvisibleElementRemovalService {
boolean isTextVisible = isTextRenderedVisibly(gState, textBBox, context);
if (inClippingPath && isTextVisible) {
- context.visibleElements().add(ElementFeatures.extractFeatures(textElement));
+ context.visibleElements().add(ElementFeatureFactory.extractFeatures(textElement));
}
if (!context.delta()) {
if (inClippingPath && isTextVisible) {
@@ -291,7 +291,7 @@ public class InvisibleElementRemovalService {
context.overlappedElements().addAll(currentOverlappedElements);
context.visibleElements().removeAll(currentOverlappedElements);
}
- context.visibleElements().add(ElementFeatures.extractFeatures(pathElement));
+ context.visibleElements().add(ElementFeatureFactory.extractFeatures(pathElement));
if (!context.delta()) {
writer.writeElement(pathElement);
}
diff --git a/src/main/java/com/iqser/red/pdftronlogic/commons/WatermarkRemovalService.java b/src/main/java/com/iqser/red/pdftronlogic/commons/WatermarkRemovalService.java
new file mode 100644
index 0000000..55d6e50
--- /dev/null
+++ b/src/main/java/com/iqser/red/pdftronlogic/commons/WatermarkRemovalService.java
@@ -0,0 +1,289 @@
+package com.iqser.red.pdftronlogic.commons;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+import com.pdftron.common.PDFNetException;
+import com.pdftron.pdf.Element;
+import com.pdftron.pdf.ElementReader;
+import com.pdftron.pdf.ElementWriter;
+import com.pdftron.pdf.PDFDoc;
+import com.pdftron.pdf.Page;
+import com.pdftron.pdf.PageIterator;
+import com.pdftron.sdf.SDFDoc;
+
+import lombok.SneakyThrows;
+import lombok.experimental.UtilityClass;
+import lombok.extern.slf4j.Slf4j;
+
+@UtilityClass
+@Slf4j
+public class WatermarkRemovalService {
+
+ final static double AREA_THRESHOLD = 0.6; // multiplied with page area
+ final static double OCCURING_ON_PAGES_THRESHOLD_FACTOR = 0.75; // multiplied with number of pages
+
+ final static int MIN_PAGES_THRESHOLD = 3;
+
+
+ /**
+ * The method remove watermark works only for Documents with size greater than MIN_PAGES_THRESHOLD.
+ * First the possible watermarks (big XObjects or Images) will be detected and then checked if those appear on most pages according to the
+ * OCCURING_ON_PAGES_THRESHOLD_FACTOR by using image hashing for similarity and size and stream size of the xobjects.
+ * If so, these detected and confirmed will not be written to the pdf file.
+ * @param pdfFile PDFFile to remove watermarks
+ * @param out The OutputStream the final file will be written to
+ */
+ @SneakyThrows
+ public void removeWatermarks(InputStream pdfFile, OutputStream out) {
+
+ PDFDoc pdfDoc = new PDFDoc(pdfFile);
+
+ if(pdfDoc.getPageCount() < MIN_PAGES_THRESHOLD){
+ log.info("Document page count {} is below threshold {}", pdfDoc.getPageCount(), MIN_PAGES_THRESHOLD);
+ } else {
+ Map> formObjectsForPages = findAllFormObjectsAndImages(pdfDoc);
+
+ List watermarkElementFeatures = filterSameFormObjectsOccuringOnMostPages(formObjectsForPages);
+
+ if (watermarkElementFeatures.size() > 0) {
+ log.info("Watermark found and will be removed!");
+ removeAllWatermarks(pdfDoc, watermarkElementFeatures);
+ } else {
+ log.info("No watermark found!");
+ }
+ }
+
+ try {
+ pdfDoc.save(out, SDFDoc.SaveMode.LINEARIZED, null);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ } finally {
+ pdfDoc.close();
+ }
+ }
+
+
+ @SneakyThrows
+ private Map> findAllFormObjectsAndImages(PDFDoc pdfDoc) {
+
+ List formObjectsOccuringMoreThanOnceOnAPage = new LinkedList<>();
+ Map> formObjectsAndImagesForPages = new HashMap<>();
+ Set visitedXObjIds = new TreeSet<>();
+
+ ElementReader reader = new ElementReader();
+
+
+
+ for (PageIterator iterator = pdfDoc.getPageIterator(); iterator.hasNext(); ) {
+
+ Page page = iterator.next();
+
+ double minAreaCoveringFromPage = AREA_THRESHOLD * page.getPageHeight() * page.getPageWidth();
+
+ LinkedList elementFeaturesLinkedList = new LinkedList<>();
+
+ reader.begin(page);
+ for (Element element = reader.next(); element != null; element = reader.next()) {
+ processElement(element, visitedXObjIds, elementFeaturesLinkedList, formObjectsOccuringMoreThanOnceOnAPage, minAreaCoveringFromPage);
+ }
+
+ formObjectsAndImagesForPages.put(page.getSDFObj().getObjNum(), elementFeaturesLinkedList);
+ }
+
+ reader.destroy();
+
+ return formObjectsAndImagesForPages;
+ }
+
+
+ private void processElement(Element element,
+ Set visitedXObjIds,
+ List elementFeaturesLinkedList,
+ List formObjectsOccuringMoreThanOnceOnAPage,
+ double minAreaCoveringPage) throws PDFNetException {
+
+ if (element.getBBox() == null) {
+ return;
+ }
+ if (element.getBBox().getHeight() * element.getBBox().getWidth() < minAreaCoveringPage) {
+ return;
+ }
+
+ if (element.getType() == Element.e_form) {
+ processXObject(element, visitedXObjIds, elementFeaturesLinkedList, formObjectsOccuringMoreThanOnceOnAPage, minAreaCoveringPage);
+ } else if (element.getType() == Element.e_image || element.getType() == Element.e_inline_image) {
+ processImages(element, elementFeaturesLinkedList);
+ }
+ }
+
+
+ @SneakyThrows
+ private void processImages(Element element, List elementFeaturesLinkedList) {
+
+ String hashOfImage = ImageHashFactory.calculate(element);
+ ElementFeatures elementFeatures = ElementFeatureFactory.extractFeaturesWithHash(element, hashOfImage);
+ elementFeaturesLinkedList.add(elementFeatures);
+ }
+
+
+ @SneakyThrows
+ private void processXObject(Element element,
+ Set visitedXObjIds,
+ List elementFeaturesLinkedList,
+ List formObjectsOccuringMoreThanOnceOnAPage,
+ double minAreaCoveringPage) {
+
+ if (visitedXObjIds.add(element.getXObject().getObjNum())) {
+ ElementReader xObjectReader = new ElementReader();
+ xObjectReader.begin(element.getXObject());
+ for (Element element1 = xObjectReader.next(); element1 != null; element1 = xObjectReader.next()) {
+ processElement(element1, visitedXObjIds, elementFeaturesLinkedList, formObjectsOccuringMoreThanOnceOnAPage, minAreaCoveringPage);
+ }
+ elementFeaturesLinkedList.add(ElementFeatureFactory.extractFeatures(element));
+ xObjectReader.destroy();
+ } else {
+ elementFeaturesLinkedList.add(ElementFeatureFactory.extractFeatures(element));
+ }
+ }
+
+
+ /*
+ parameter
+ */
+ private List filterSameFormObjectsOccuringOnMostPages(Map> formObjectsPerPage) {
+
+ int pageCount = formObjectsPerPage.keySet().size();
+ int minPagesFilter = (int) (OCCURING_ON_PAGES_THRESHOLD_FACTOR * pageCount);
+
+ return formObjectsPerPage.values()
+ .stream()
+ .flatMap(Collection::stream)
+ .filter(elementFeature -> formObjectsPerPage.values()
+ .stream()
+ .filter(elementFeaturesOnPage -> elementFeaturesOnPage.stream().anyMatch(elementFeature::almostMatches))
+ .count() >= minPagesFilter)
+ .toList();
+ }
+
+
+ @SneakyThrows
+ private void removeAllWatermarks(PDFDoc pdfDoc, List watermarksElementFeaturesList) {
+
+ ElementReader reader = new ElementReader();
+ ElementWriter writer = new ElementWriter();
+ Set visitedXObjIds = new TreeSet<>();
+
+ for (PageIterator iterator = pdfDoc.getPageIterator(); iterator.hasNext(); ) {
+
+ Page page = iterator.next();
+
+ writeAllElementsExceptWatermarks(page, reader, writer, watermarksElementFeaturesList, visitedXObjIds);
+
+ }
+
+ reader.destroy();
+ writer.destroy();
+
+ }
+
+
+ @SneakyThrows
+ private void writeAllElementsExceptWatermarks(Page page,
+ ElementReader reader,
+ ElementWriter writer,
+ List watermarksElementFeaturesList,
+ Set visitedXObjIds) {
+
+ reader.begin(page);
+ writer.begin(page, ElementWriter.e_replacement, false, true, page.getResourceDict());
+ processElements(page, reader, writer, watermarksElementFeaturesList, visitedXObjIds);
+ writer.end();
+ reader.end();
+ }
+
+
+ private void processElements(Page page,
+ ElementReader reader,
+ ElementWriter writer,
+ List watermarksElementFeaturesList,
+ Set visitedXObjIds) throws PDFNetException {
+
+ double minAreaCoveringFromPage = AREA_THRESHOLD * page.getPageHeight() * page.getPageWidth();
+ for (Element element = reader.next(); element != null; element = reader.next()) {
+
+ switch (element.getType()) {
+ case Element.e_image, Element.e_inline_image -> {
+ if (element.getBBox() == null) {
+ continue;
+ }
+ if (element.getBBox().getHeight() * element.getBBox().getWidth() < minAreaCoveringFromPage) {
+ writer.writeElement(element);
+ continue;
+ }
+ removeImages(element, writer, watermarksElementFeaturesList);
+ }
+ case Element.e_form -> processForms(page, element, reader, writer, watermarksElementFeaturesList, visitedXObjIds);
+ default -> writer.writeElement(element);
+ }
+ }
+ }
+
+
+ @SneakyThrows
+ private void removeImages(Element element, ElementWriter writer, List watermarksElementFeaturesList) {
+
+ String hashValueOfImage = ImageHashFactory.calculate(element);
+ ElementFeatures imageFeatures = ElementFeatureFactory.extractFeaturesWithHash(element, hashValueOfImage);
+ for (ElementFeatures elementFeatures : watermarksElementFeaturesList) {
+ if (elementFeatures.almostMatches(imageFeatures)) {
+ return;
+ }
+ }
+
+ writer.writeElement(element);
+ }
+
+
+ private void processForms(Page page,
+ Element element,
+ ElementReader reader,
+ ElementWriter writer,
+ List watermarksElementFeaturesList,
+ Set visitedXObjIds) throws PDFNetException {
+
+ for (ElementFeatures elementFeatures : watermarksElementFeaturesList) {
+ if (elementFeatures.almostMatches(element)) {
+ return;
+ }
+ }
+
+ writer.writeElement(element);
+
+ if (!visitedXObjIds.contains(element.getXObject().getObjNum())) {
+ visitedXObjIds.add(element.getXObject().getObjNum());
+ // writer needs to be newly initialized when entering a new content stream
+ // see ElementEditTest in PDFTron (https://www.pdftron.com/documentation/samples/android/java/ElementEditTest)
+ ElementWriter formWriter = new ElementWriter();
+ reader.formBegin();
+ formWriter.begin(element.getXObject());
+
+ reader.clearChangeList();
+ formWriter.setDefaultGState(reader);
+
+ processElements(page, reader, formWriter, watermarksElementFeaturesList, visitedXObjIds);
+ formWriter.end();
+ formWriter.destroy();
+ reader.end();
+ }
+
+ }
+
+}
diff --git a/src/test/java/com/iqser/red/pdftronlogic/commons/WatermarkRemovalServiceTest.java b/src/test/java/com/iqser/red/pdftronlogic/commons/WatermarkRemovalServiceTest.java
new file mode 100644
index 0000000..9cff388
--- /dev/null
+++ b/src/test/java/com/iqser/red/pdftronlogic/commons/WatermarkRemovalServiceTest.java
@@ -0,0 +1,62 @@
+package com.iqser.red.pdftronlogic.commons;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.nio.file.Path;
+import java.util.Locale;
+
+import org.junit.jupiter.api.Test;
+import org.junit.platform.commons.util.StringUtils;
+
+import com.pdftron.pdf.PDFNet;
+
+import lombok.SneakyThrows;
+
+class WatermarkRemovalServiceTest {
+
+ @SneakyThrows
+ @Test
+ void removeWatermarks() {
+
+ PDFNet.addResourceSearchPath("C:/Users/RaphaelArnold/knecon/pdftron/ocrirismodule/Lib");
+ PDFNet.initialize("demo:1650351709282:7bd235e003000000004ec28a6743e1163a085e2115de2536ab6e2cfe5a");
+
+ String filename = "files/PO_82_277_H_2013 (1).pdf";
+
+ String tmpFilename = createTmpFileName(filename, "WATERMARK_REMOVAL");
+ try (var in = this.getClass().getClassLoader().getResourceAsStream(filename); var out = new FileOutputStream(tmpFilename)) {
+
+ {
+ System.out.println(tmpFilename);
+ WatermarkRemovalService.removeWatermarks(in, out);
+ }
+ }
+
+ }
+
+
+ private static boolean isWindows() {
+
+ return System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("windows");
+ }
+
+
+ public static String getTemporaryDirectory() {
+
+ String tmpdir = System.getProperty("java.io.tmpdir");
+ if (isWindows() && StringUtils.isNotBlank(tmpdir)) {
+ return tmpdir;
+ }
+ return "/tmp";
+ }
+
+
+ public static String createTmpFileName(String filename, String suffix) {
+
+ return Path.of(getTemporaryDirectory()).resolve(Path.of(filename).getFileName()).toString().replace(".pdf", "_" + suffix + ".pdf");
+ }
+
+}
\ No newline at end of file
diff --git a/src/test/resources/files/PO_82_277_H_2013 (1).pdf b/src/test/resources/files/PO_82_277_H_2013 (1).pdf
new file mode 100644
index 0000000..fb6c0e5
Binary files /dev/null and b/src/test/resources/files/PO_82_277_H_2013 (1).pdf differ