diff --git a/app/common/src/main/java/stirling/software/SPDF/pdf/parser/PageColumnLayout.java b/app/common/src/main/java/stirling/software/SPDF/pdf/parser/PageColumnLayout.java
new file mode 100644
index 000000000..e3f000624
--- /dev/null
+++ b/app/common/src/main/java/stirling/software/SPDF/pdf/parser/PageColumnLayout.java
@@ -0,0 +1,142 @@
+package stirling.software.SPDF.pdf.parser;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Detects whether a page is one- or two-column from per-line bounding boxes, and classifies an
+ * X-span into the column it belongs to. Detection is a midpoint vote at {@code pageWidth / 2}.
+ *
+ *
Capped at two columns by design — sufficient for the redaction target set (single-column
+ * documents and IEEE-style two-column papers). 3+ column layouts (newspapers, magazines) and
+ * off-centre gutters (asymmetric two-column) would need a histogram or clustering approach to
+ * detect the actual gutter X. (future work)
+ *
+ *
Coordinates are PDFTextStripper screen space (top-left origin, Y increases downward).
+ */
+public final class PageColumnLayout {
+
+ /**
+ * Slack when checking "crosses a gutter" so single-pixel overshoots don't mark a line as
+ * spanning.
+ */
+ public static final float SPAN_SLACK_PT = 2f;
+
+ /**
+ * Slack on each side of the page midpoint inside which a line is considered "spanning"
+ * (covering both columns) rather than belonging to one side.
+ */
+ private static final float MIDPOINT_SLACK_PT = 30f;
+
+ /**
+ * Minimum line width (points) for a line to count toward the two-column tally. Avoids false
+ * positives where right-aligned dates, page numbers, or short "Link" fragments next to a
+ * heading look like a second column when they're really just inline metadata.
+ */
+ private static final float MIN_COLUMN_LINE_WIDTH_PT = 100f;
+
+ /**
+ * Minimum number of clearly leftish AND clearly rightish lines (each of width ≥ {@link
+ * #MIN_COLUMN_LINE_WIDTH_PT}) required to call the page two-column. Anything below this falls
+ * back to single-column.
+ */
+ private static final int MIN_SIDE_LINES = 3;
+
+ private final List columns;
+ private final List gutters;
+
+ private PageColumnLayout(List columns, List gutters) {
+ this.columns = columns;
+ this.gutters = gutters;
+ }
+
+ /**
+ * Determines column layout from per-line bounding boxes ({@code [x1, _, x2, _]}). Counts lines
+ * whose X-midpoint sits clearly left of, or clearly right of, the page midpoint (with {@link
+ * #MIDPOINT_SLACK_PT} slack each side). If both sides have at least {@link #MIN_SIDE_LINES}
+ * lines, the page is treated as two-column with the gutter at the page midpoint. Otherwise it's
+ * single-column.
+ *
+ *
Cross-column lines must already be split: callers should feed boxes from a line extractor
+ * that splits same-Y glyphs at large X gaps (see {@code AllTextLineExtractor}). Without that
+ * split, IEEE-style aligned-baseline 2-column PDFs produce one wide merged box per row and the
+ * side tallies all end up classified as "spanning", falling to single-column.
+ */
+ public static PageColumnLayout fromLineBoxes(List lineBoxes, float pageWidth) {
+ if (lineBoxes == null || lineBoxes.isEmpty()) {
+ return new PageColumnLayout(List.of(new float[] {0f, pageWidth}), List.of());
+ }
+ float pageMid = pageWidth / 2f;
+ int left = 0, right = 0;
+ for (float[] lb : lineBoxes) {
+ if (lb == null || lb.length < 3) continue;
+ float width = lb[2] - lb[0];
+ // Skip narrow lines — dates, page numbers, "Link" labels next to a heading should
+ // not, on their own, make a single-column doc look two-column.
+ if (width < MIN_COLUMN_LINE_WIDTH_PT) continue;
+ float mid = (lb[0] + lb[2]) * 0.5f;
+ if (mid < pageMid - MIDPOINT_SLACK_PT) left++;
+ else if (mid > pageMid + MIDPOINT_SLACK_PT) right++;
+ }
+ if (left < MIN_SIDE_LINES || right < MIN_SIDE_LINES) {
+ return new PageColumnLayout(List.of(new float[] {0f, pageWidth}), List.of());
+ }
+ float gutterL = pageMid - MIDPOINT_SLACK_PT;
+ float gutterR = pageMid + MIDPOINT_SLACK_PT;
+ return new PageColumnLayout(
+ List.of(new float[] {0f, gutterL}, new float[] {gutterR, pageWidth}),
+ List.of(new float[] {gutterL, gutterR}));
+ }
+
+ /** All columns, left-to-right, as {@code [leftX, rightX]} pairs. Never empty. */
+ public List columns() {
+ return columns;
+ }
+
+ /** Gutters between columns, left-to-right, as {@code [leftX, rightX]} pairs. */
+ public List gutters() {
+ return gutters;
+ }
+
+ public int columnCount() {
+ return columns.size();
+ }
+
+ /**
+ * Returns the column index containing the X-midpoint of {@code [x1, x2]}, falling back to the
+ * closest column if the midpoint sits inside a gutter.
+ */
+ public int columnOf(float x1, float x2) {
+ float mid = (x1 + x2) * 0.5f;
+ int best = 0;
+ float bestDist = Float.MAX_VALUE;
+ for (int i = 0; i < columns.size(); i++) {
+ float[] c = columns.get(i);
+ if (mid >= c[0] && mid <= c[1]) return i;
+ float dist = mid < c[0] ? c[0] - mid : mid - c[1];
+ if (dist < bestDist) {
+ bestDist = dist;
+ best = i;
+ }
+ }
+ return best;
+ }
+
+ /**
+ * Returns every column index whose X-range overlaps {@code [x1, x2]} with at least {@link
+ * #SPAN_SLACK_PT} of intrusion. A normal in-column line returns one index; a line crossing a
+ * gutter returns two or more.
+ */
+ public int[] columnsCrossing(float x1, float x2) {
+ List hits = new ArrayList<>();
+ for (int i = 0; i < columns.size(); i++) {
+ float[] c = columns.get(i);
+ float overlap = Math.min(x2, c[1]) - Math.max(x1, c[0]);
+ if (overlap > SPAN_SLACK_PT) hits.add(i);
+ }
+ if (hits.isEmpty()) hits.add(columnOf(x1, x2));
+ int[] out = new int[hits.size()];
+ for (int i = 0; i < hits.size(); i++) out[i] = hits.get(i);
+ return out;
+ }
+}
diff --git a/app/common/src/main/java/stirling/software/SPDF/pdf/parser/PageImageLocator.java b/app/common/src/main/java/stirling/software/SPDF/pdf/parser/PageImageLocator.java
new file mode 100644
index 000000000..dc384b3f5
--- /dev/null
+++ b/app/common/src/main/java/stirling/software/SPDF/pdf/parser/PageImageLocator.java
@@ -0,0 +1,133 @@
+package stirling.software.SPDF.pdf.parser;
+
+import java.awt.geom.Point2D;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.pdfbox.contentstream.PDFGraphicsStreamEngine;
+import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.graphics.image.PDImage;
+import org.apache.pdfbox.util.Matrix;
+
+/**
+ * PDFGraphicsStreamEngine that intercepts {@code drawImage} calls and records each image's bounding
+ * box in PDF user-space (origin bottom-left, Y up) by transforming the unit square through the
+ * current transformation matrix (CTM).
+ *
+ *
Each {@link ImageBox} carries the 0-based page index and the axis-aligned bounding box {@code
+ * (x1, y1, x2, y2)} in PDF user-space coordinates.
+ */
+public final class PageImageLocator extends PDFGraphicsStreamEngine {
+
+ /**
+ * Bounding box of a raster or vector image found on a PDF page.
+ *
+ * @param pageIndex 0-based page index
+ * @param x1 left edge in PDF user-space (origin bottom-left)
+ * @param y1 bottom edge in PDF user-space
+ * @param x2 right edge
+ * @param y2 top edge
+ */
+ public record ImageBox(int pageIndex, float x1, float y1, float x2, float y2) {}
+
+ private final int pageIndex;
+ private final List imageBoxes = new ArrayList<>();
+ private final Point2D.Float currentPoint = new Point2D.Float();
+
+ /**
+ * @param page the PDPage to process
+ * @param pageIndex 0-based index of this page in the document (stored on each returned {@link
+ * ImageBox})
+ */
+ public PageImageLocator(PDPage page, int pageIndex) {
+ super(page);
+ this.pageIndex = pageIndex;
+ }
+
+ /** Returns all image bounding boxes collected during {@link #processPage}. */
+ public List getImageBoxes() {
+ return imageBoxes;
+ }
+
+ @Override
+ public void drawImage(PDImage pdImage) throws IOException {
+ Matrix ctm = getGraphicsState().getCurrentTransformationMatrix();
+ // An image occupies the unit square (0,0)→(1,1) in image space.
+ // Transform all four corners through the CTM to get the page-space bounding box.
+ float a = ctm.getScaleX();
+ float b = ctm.getShearY();
+ float c = ctm.getShearX();
+ float d = ctm.getScaleY();
+ float e = ctm.getTranslateX();
+ float f = ctm.getTranslateY();
+ float[] xs = {e, a + e, c + e, a + c + e};
+ float[] ys = {f, b + f, d + f, b + d + f};
+ float x1 = Float.MAX_VALUE, y1 = Float.MAX_VALUE;
+ float x2 = -Float.MAX_VALUE, y2 = -Float.MAX_VALUE;
+ for (float x : xs) {
+ x1 = Math.min(x1, x);
+ x2 = Math.max(x2, x);
+ }
+ for (float y : ys) {
+ y1 = Math.min(y1, y);
+ y2 = Math.max(y2, y);
+ }
+ imageBoxes.add(new ImageBox(pageIndex, x1, y1, x2, y2));
+ }
+
+ // ---------- required abstract methods (no-op for path operations) ----------
+
+ @Override
+ public void appendRectangle(Point2D p0, Point2D p1, Point2D p2, Point2D p3) {}
+
+ @Override
+ public void clip(int windingRule) {}
+
+ @Override
+ public void moveTo(float x, float y) {
+ currentPoint.setLocation(x, y);
+ }
+
+ @Override
+ public void lineTo(float x, float y) {
+ currentPoint.setLocation(x, y);
+ }
+
+ @Override
+ public void curveTo(float x1, float y1, float x2, float y2, float x3, float y3) {
+ currentPoint.setLocation(x3, y3);
+ }
+
+ @Override
+ public Point2D getCurrentPoint() {
+ return currentPoint;
+ }
+
+ @Override
+ public void closePath() {}
+
+ @Override
+ public void endPath() {}
+
+ @Override
+ public void strokePath() {}
+
+ @Override
+ public void fillPath(int windingRule) {}
+
+ @Override
+ public void fillAndStrokePath(int windingRule) {}
+
+ @Override
+ public void shadingFill(COSName shadingName) {}
+}
diff --git a/app/common/src/main/java/stirling/software/common/util/propertyeditor/JsonListPropertyEditor.java b/app/common/src/main/java/stirling/software/common/util/propertyeditor/JsonListPropertyEditor.java
new file mode 100644
index 000000000..b0b796c67
--- /dev/null
+++ b/app/common/src/main/java/stirling/software/common/util/propertyeditor/JsonListPropertyEditor.java
@@ -0,0 +1,48 @@
+package stirling.software.common.util.propertyeditor;
+
+import java.beans.PropertyEditorSupport;
+import java.util.ArrayList;
+import java.util.List;
+
+import lombok.extern.slf4j.Slf4j;
+
+import tools.jackson.core.type.TypeReference;
+import tools.jackson.databind.DeserializationFeature;
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.databind.json.JsonMapper;
+
+/**
+ * Binds a multipart form value containing a JSON array into a typed {@code List}. Used for
+ * endpoints that accept structured list parameters via {@code @ModelAttribute} — the form field
+ * carries the full JSON array as its value and the editor parses it once.
+ */
+@Slf4j
+public class JsonListPropertyEditor extends PropertyEditorSupport {
+
+ private static final ObjectMapper OBJECT_MAPPER =
+ JsonMapper.builder()
+ .enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY)
+ .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
+ .build();
+
+ private final TypeReference extends List> typeRef;
+
+ public JsonListPropertyEditor(TypeReference extends List> typeRef) {
+ this.typeRef = typeRef;
+ }
+
+ @Override
+ public void setAsText(String text) throws IllegalArgumentException {
+ if (text == null || text.trim().isEmpty()) {
+ setValue(new ArrayList());
+ return;
+ }
+ try {
+ setValue(OBJECT_MAPPER.readValue(text, typeRef));
+ } catch (Exception e) {
+ log.error("Failed to parse JSON list value", e);
+ throw new IllegalArgumentException(
+ "Expected a JSON array but could not parse: " + e.getMessage());
+ }
+ }
+}
diff --git a/app/common/src/main/java/stirling/software/common/util/propertyeditor/JsonObjectPropertyEditor.java b/app/common/src/main/java/stirling/software/common/util/propertyeditor/JsonObjectPropertyEditor.java
new file mode 100644
index 000000000..e714b995b
--- /dev/null
+++ b/app/common/src/main/java/stirling/software/common/util/propertyeditor/JsonObjectPropertyEditor.java
@@ -0,0 +1,41 @@
+package stirling.software.common.util.propertyeditor;
+
+import java.beans.PropertyEditorSupport;
+
+import lombok.extern.slf4j.Slf4j;
+
+import tools.jackson.databind.DeserializationFeature;
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.databind.json.JsonMapper;
+
+/**
+ * Binds a multipart form value containing a JSON object into a typed {@code T}. Companion to {@link
+ * JsonListPropertyEditor} for single-object nested fields on {@code @ModelAttribute} endpoints.
+ */
+@Slf4j
+public class JsonObjectPropertyEditor extends PropertyEditorSupport {
+
+ private static final ObjectMapper OBJECT_MAPPER =
+ JsonMapper.builder().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build();
+
+ private final Class type;
+
+ public JsonObjectPropertyEditor(Class type) {
+ this.type = type;
+ }
+
+ @Override
+ public void setAsText(String text) throws IllegalArgumentException {
+ if (text == null || text.trim().isEmpty()) {
+ setValue(null);
+ return;
+ }
+ try {
+ setValue(OBJECT_MAPPER.readValue(text, type));
+ } catch (Exception e) {
+ log.error("Failed to parse JSON object value", e);
+ throw new IllegalArgumentException(
+ "Expected a JSON object but could not parse: " + e.getMessage());
+ }
+ }
+}
diff --git a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditor.java b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditor.java
deleted file mode 100644
index 6941e6b6d..000000000
--- a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditor.java
+++ /dev/null
@@ -1,53 +0,0 @@
-package stirling.software.common.util.propertyeditor;
-
-import java.beans.PropertyEditorSupport;
-import java.util.ArrayList;
-import java.util.List;
-
-import lombok.extern.slf4j.Slf4j;
-
-import tools.jackson.databind.DeserializationFeature;
-import tools.jackson.databind.JavaType;
-import tools.jackson.databind.ObjectMapper;
-import tools.jackson.databind.json.JsonMapper;
-
-/**
- * Spring property editor that decodes a JSON string into a typed {@link ArrayList}. Used to bind
- * complex list parameters (e.g. {@code List}, {@code List}) from
- * multipart form fields, where Spring's default binding cannot deserialize a JSON array.
- */
-@Slf4j
-public class StringToArrayListPropertyEditor extends PropertyEditorSupport {
-
- private final ObjectMapper objectMapper =
- JsonMapper.builder()
- .enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY)
- .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
- .build();
-
- private final Class elementType;
-
- public StringToArrayListPropertyEditor(Class elementType) {
- this.elementType = elementType;
- }
-
- @Override
- public void setAsText(String text) throws IllegalArgumentException {
- if (text == null || text.trim().isEmpty()) {
- setValue(new ArrayList<>());
- return;
- }
- try {
- JavaType listType =
- objectMapper
- .getTypeFactory()
- .constructCollectionType(ArrayList.class, elementType);
- List list = objectMapper.readValue(text, listType);
- setValue(list);
- } catch (Exception e) {
- log.error("Exception while converting {}", e);
- throw new IllegalArgumentException(
- "Failed to convert java.lang.String to java.util.List");
- }
- }
-}
diff --git a/app/common/src/test/java/stirling/software/SPDF/pdf/parser/PageColumnLayoutTest.java b/app/common/src/test/java/stirling/software/SPDF/pdf/parser/PageColumnLayoutTest.java
new file mode 100644
index 000000000..5aa9715b8
--- /dev/null
+++ b/app/common/src/test/java/stirling/software/SPDF/pdf/parser/PageColumnLayoutTest.java
@@ -0,0 +1,149 @@
+package stirling.software.SPDF.pdf.parser;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for {@link PageColumnLayout} gutter detection and column classification. */
+class PageColumnLayoutTest {
+
+ private static final float PAGE_WIDTH = 612f; // Letter portrait
+
+ // ── single-column ────────────────────────────────────────────────────────────────────────────
+
+ @Test
+ void singleColumn_oneColumnNoGutters() {
+ List lines = List.of(lineBox(72f, 396f));
+
+ PageColumnLayout layout = PageColumnLayout.fromLineBoxes(lines, PAGE_WIDTH);
+
+ assertThat(layout.columnCount()).isEqualTo(1);
+ assertThat(layout.gutters()).isEmpty();
+ }
+
+ @Test
+ void singleColumn_classifyAnchor_returnsZero() {
+ PageColumnLayout layout =
+ PageColumnLayout.fromLineBoxes(List.of(lineBox(72f, 396f)), PAGE_WIDTH);
+
+ assertThat(layout.columnOf(100f, 200f)).isEqualTo(0);
+ }
+
+ // ── two-column ───────────────────────────────────────────────────────────────────────────────
+
+ @Test
+ void twoColumn_detectsGutter() {
+ PageColumnLayout layout =
+ PageColumnLayout.fromLineBoxes(buildTwoColumnLines(3), PAGE_WIDTH);
+
+ assertThat(layout.columnCount()).isEqualTo(2);
+ assertThat(layout.gutters()).hasSize(1);
+ float[] gutter = layout.gutters().get(0);
+ // Gutter is centered on pageWidth/2 with PageColumnLayout.MIDPOINT_SLACK_PT slack each
+ // side.
+ float pageMid = PAGE_WIDTH / 2f;
+ assertThat(gutter[0]).isBetween(pageMid - 40f, pageMid - 20f);
+ assertThat(gutter[1]).isBetween(pageMid + 20f, pageMid + 40f);
+ }
+
+ @Test
+ void twoColumn_classifyLeftAndRightAnchors() {
+ PageColumnLayout layout =
+ PageColumnLayout.fromLineBoxes(buildTwoColumnLines(3), PAGE_WIDTH);
+ assertThat(layout.columnOf(100f, 200f)).isEqualTo(0);
+ assertThat(layout.columnOf(380f, 460f)).isEqualTo(1);
+ }
+
+ @Test
+ void twoColumn_columnsCrossing_leftLineOnlyHitsLeft() {
+ PageColumnLayout layout =
+ PageColumnLayout.fromLineBoxes(buildTwoColumnLines(3), PAGE_WIDTH);
+ assertThat(layout.columnsCrossing(72f, 280f)).containsExactly(0);
+ assertThat(layout.columnsCrossing(320f, 540f)).containsExactly(1);
+ }
+
+ @Test
+ void twoColumn_spanningLine_returnsBothColumns() {
+ List lines = new ArrayList<>(buildTwoColumnLines(3));
+ // Full-width header that crosses pageWidth/2.
+ lines.add(lineBox(72f, 396f));
+
+ PageColumnLayout layout = PageColumnLayout.fromLineBoxes(lines, PAGE_WIDTH);
+
+ assertThat(layout.columnsCrossing(72f, 540f)).containsExactly(0, 1);
+ assertThat(layout.columnsCrossing(72f, 280f)).containsExactly(0);
+ }
+
+ private static List buildTwoColumnLines(int rowsPerColumn) {
+ List lines = new ArrayList<>();
+ for (int i = 0; i < rowsPerColumn; i++) {
+ lines.add(lineBox(72f, 136f)); // left column body (72..208)
+ lines.add(lineBox(320f, 220f)); // right column body (320..540)
+ }
+ return lines;
+ }
+
+ // ── three-column ─────────────────────────────────────────────────────────────────────────────
+
+ @Test
+ void threeColumn_collapsesToLeftRightSplit() {
+ // The midpoint-based detector splits the page at pageWidth/2 and treats anything else as
+ // single-column or spanning. A genuine 3-column layout collapses to 2 columns; the middle
+ // column's content ends up classified by midpoint as left or right of pageMid.
+ List lines = new ArrayList<>();
+ for (int i = 0; i < 6; i++) {
+ lines.add(lineBox(72f, 150f)); // 72..222
+ lines.add(lineBox(252f, 150f)); // 252..402
+ lines.add(lineBox(432f, 150f)); // 432..582
+ }
+
+ PageColumnLayout layout = PageColumnLayout.fromLineBoxes(lines, PAGE_WIDTH);
+
+ assertThat(layout.columnCount()).isEqualTo(2);
+ assertThat(layout.gutters()).hasSize(1);
+ }
+
+ // ── empty page ───────────────────────────────────────────────────────────────────────────────
+
+ @Test
+ void emptyPage_singleColumnFallback() {
+ PageColumnLayout layout = PageColumnLayout.fromLineBoxes(List.of(), PAGE_WIDTH);
+ assertThat(layout.columnCount()).isEqualTo(1);
+ assertThat(layout.gutters()).isEmpty();
+ }
+
+ @Test
+ void onlyShortFragments_singleColumnFallback() {
+ // Page numbers / decorations — too narrow to vote either side.
+ List lines = List.of(lineBox(300f, 6f));
+ PageColumnLayout layout = PageColumnLayout.fromLineBoxes(lines, PAGE_WIDTH);
+ assertThat(layout.columnCount()).isEqualTo(1);
+ }
+
+ // ── narrow gap should not be confused for a gutter ───────────────────────────────────────────
+
+ @Test
+ void narrowInternalGap_doesNotProduceGutter() {
+ // Both halves sit left of the page midpoint, so no line votes for a right column and
+ // detection falls back to single-column.
+ List lines = new ArrayList<>();
+ lines.add(lineBox(72f, 100f));
+ lines.add(lineBox(180f, 100f));
+ for (int i = 0; i < 5; i++) {
+ lines.add(lineBox(72f, 208f));
+ }
+
+ PageColumnLayout layout = PageColumnLayout.fromLineBoxes(lines, PAGE_WIDTH);
+ assertThat(layout.columnCount()).isEqualTo(1);
+ }
+
+ // ── helpers ──────────────────────────────────────────────────────────────────────────────────
+
+ /** Builds a line bounding box {@code [x1, 0, x1+width, 0]}; Y is unused by detection. */
+ private static float[] lineBox(float x1, float width) {
+ return new float[] {x1, 0f, x1 + width, 0f};
+ }
+}
diff --git a/app/common/src/test/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditorTest.java b/app/common/src/test/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditorTest.java
deleted file mode 100644
index a2e03f086..000000000
--- a/app/common/src/test/java/stirling/software/common/util/propertyeditor/StringToArrayListPropertyEditorTest.java
+++ /dev/null
@@ -1,163 +0,0 @@
-package stirling.software.common.util.propertyeditor;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-import java.util.List;
-
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-import stirling.software.common.model.api.security.RedactionArea;
-
-class StringToArrayListPropertyEditorTest {
-
- private StringToArrayListPropertyEditor editor;
-
- @BeforeEach
- void setUp() {
- editor = new StringToArrayListPropertyEditor<>(RedactionArea.class);
- }
-
- @Test
- void testSetAsText_ValidJson() {
- // Arrange
- String json =
- "[{\"x\":10.5,\"y\":20.5,\"width\":100.0,\"height\":50.0,\"page\":1,\"color\":\"#FF0000\"}]";
-
- // Act
- editor.setAsText(json);
- Object value = editor.getValue();
-
- // Assert
- assertNotNull(value, "Value should not be null");
- assertInstanceOf(List.class, value, "Value should be a List");
-
- @SuppressWarnings("unchecked")
- List list = (List) value;
- assertEquals(1, list.size(), "List should have 1 entry");
-
- RedactionArea area = list.get(0);
- assertEquals(10.5, area.getX(), "X should be 10.5");
- assertEquals(20.5, area.getY(), "Y should be 20.5");
- assertEquals(100.0, area.getWidth(), "Width should be 100.0");
- assertEquals(50.0, area.getHeight(), "Height should be 50.0");
- assertEquals(1, area.getPage(), "Page should be 1");
- assertEquals("#FF0000", area.getColor(), "Color should be #FF0000");
- }
-
- @Test
- void testSetAsText_MultipleItems() {
- // Arrange
- String json =
- "["
- + "{\"x\":10.0,\"y\":20.0,\"width\":100.0,\"height\":50.0,\"page\":1,\"color\":\"#FF0000\"},"
- + "{\"x\":30.0,\"y\":40.0,\"width\":200.0,\"height\":150.0,\"page\":2,\"color\":\"#00FF00\"}"
- + "]";
-
- // Act
- editor.setAsText(json);
- Object value = editor.getValue();
-
- // Assert
- assertNotNull(value, "Value should not be null");
- assertInstanceOf(List.class, value, "Value should be a List");
-
- @SuppressWarnings("unchecked")
- List list = (List) value;
- assertEquals(2, list.size(), "List should have 2 entries");
-
- RedactionArea area1 = list.get(0);
- assertEquals(10.0, area1.getX(), "X should be 10.0");
- assertEquals(20.0, area1.getY(), "Y should be 20.0");
- assertEquals(1, area1.getPage(), "Page should be 1");
-
- RedactionArea area2 = list.get(1);
- assertEquals(30.0, area2.getX(), "X should be 30.0");
- assertEquals(40.0, area2.getY(), "Y should be 40.0");
- assertEquals(2, area2.getPage(), "Page should be 2");
- }
-
- @Test
- void testSetAsText_EmptyString() {
- // Arrange
- String json = "";
-
- // Act
- editor.setAsText(json);
- Object value = editor.getValue();
-
- // Assert
- assertNotNull(value, "Value should not be null");
- assertInstanceOf(List.class, value, "Value should be a List");
-
- @SuppressWarnings("unchecked")
- List list = (List) value;
- assertTrue(list.isEmpty(), "List should be empty");
- }
-
- @Test
- void testSetAsText_NullString() {
- // Act
- editor.setAsText(null);
- Object value = editor.getValue();
-
- // Assert
- assertNotNull(value, "Value should not be null");
- assertInstanceOf(List.class, value, "Value should be a List");
-
- @SuppressWarnings("unchecked")
- List list = (List) value;
- assertTrue(list.isEmpty(), "List should be empty");
- }
-
- @Test
- void testSetAsText_SingleItemAsArray() {
- // Arrange - note this is a single object, not an array
- String json =
- "{\"x\":10.0,\"y\":20.0,\"width\":100.0,\"height\":50.0,\"page\":1,\"color\":\"#FF0000\"}";
-
- // Act
- editor.setAsText(json);
- Object value = editor.getValue();
-
- // Assert
- assertNotNull(value, "Value should not be null");
- assertInstanceOf(List.class, value, "Value should be a List");
-
- @SuppressWarnings("unchecked")
- List list = (List) value;
- assertEquals(1, list.size(), "List should have 1 entry");
-
- RedactionArea area = list.get(0);
- assertEquals(10.0, area.getX(), "X should be 10.0");
- assertEquals(20.0, area.getY(), "Y should be 20.0");
- }
-
- @Test
- void testSetAsText_InvalidJson() {
- // Arrange
- String json = "invalid json";
-
- // Act & Assert
- assertThrows(IllegalArgumentException.class, () -> editor.setAsText(json));
- }
-
- @Test
- void testSetAsText_UnknownProperties() {
- // Arrange - this JSON contains properties not in RedactionArea
- // With FAIL_ON_UNKNOWN_PROPERTIES disabled, this should ignore the unknown properties
- String json = "[{\"invalid\":\"structure\"}]";
-
- // Act
- editor.setAsText(json);
- Object value = editor.getValue();
-
- // Assert
- assertNotNull(value, "Value should not be null");
- assertInstanceOf(List.class, value, "Value should be a List");
-
- @SuppressWarnings("unchecked")
- List list = (List) value;
- assertEquals(1, list.size(), "List should have 1 entry (empty object)");
- }
-}
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java
index ec4420fb1..3a10b1419 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java
@@ -39,7 +39,9 @@ import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
-import stirling.software.common.util.propertyeditor.StringToArrayListPropertyEditor;
+import stirling.software.common.util.propertyeditor.JsonListPropertyEditor;
+
+import tools.jackson.core.type.TypeReference;
/**
* Find/replace text editing for PDFs. Round-trips through {@link PdfJsonConversionService}: the
@@ -73,7 +75,7 @@ public class EditTextController {
binder.registerCustomEditor(
List.class,
"edits",
- new StringToArrayListPropertyEditor<>(EditTextOperation.class));
+ new JsonListPropertyEditor<>(new TypeReference>() {}));
}
@AutoJobPostMapping(
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/AllTextLineExtractor.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/AllTextLineExtractor.java
new file mode 100644
index 000000000..f744b41af
--- /dev/null
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/AllTextLineExtractor.java
@@ -0,0 +1,127 @@
+package stirling.software.SPDF.controller.api.security;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.apache.pdfbox.text.TextPosition;
+
+/**
+ * PDFTextStripper subclass that collects all text positions and groups them into line-level
+ * bounding boxes.
+ *
+ *
Two outputs are maintained in parallel:
+ *
+ *
+ *
{@link #getLineBoxes()} returns {@code [x1, pdfYbottom, x2, pdfYtop]} in PDF user-space
+ * (origin bottom-left, Y up). This is what existing callers expect.
+ *
{@link #getScreenLineBoxes()} returns {@code [x1, screenYtop, x2, screenYbottom]} computed
+ * directly from glyph positions without a PDF↔screen round-trip — used by column-aware
+ * redaction where ulp-level drift in the round-trip caused false rejects against anchors.
+ *
+ *
+ *
Lines are flushed not only on Y jumps but also on large X gaps within the same Y row. That way
+ * left-column glyphs and right-column glyphs that happen to share a baseline (common in IEEE
+ * conference templates) get emitted as two distinct line boxes instead of one wide merged box.
+ */
+final class AllTextLineExtractor extends PDFTextStripper {
+
+ /** Min vertical jump (screen Y) before the next glyph is treated as a new line. */
+ private static final float LINE_Y_TOLERANCE = 3.0f;
+
+ /**
+ * Min horizontal gap (screen X) between consecutive glyphs on the same Y that indicates a
+ * column boundary. Chosen large enough to not split normal inter-word spacing (~6–10pt for 11pt
+ * text) but small enough to catch standard column gutters (typically ≥15pt).
+ */
+ private static final float COLUMN_GAP_X = 14f;
+
+ private final float pageHeight;
+ private final List lineBoxes = new ArrayList<>();
+ private final List screenLineBoxes = new ArrayList<>();
+
+ private final List currentLine = new ArrayList<>();
+ private float lastScreenY = Float.NaN;
+ private float lastGlyphRight = Float.NaN;
+
+ AllTextLineExtractor(int pageNumber, float pageHeight) throws IOException {
+ this.pageHeight = pageHeight;
+ setStartPage(pageNumber);
+ setEndPage(pageNumber);
+ setSortByPosition(true);
+ }
+
+ List getLineBoxes() {
+ return lineBoxes;
+ }
+
+ /**
+ * Returns line boxes as {@code [x1, screenYtop, x2, screenYbottom]}. {@code screenYtop} is the
+ * minimum {@code TextPosition.getY() - getHeight()} on the line and {@code screenYbottom} is
+ * the maximum {@code getY()} (the line's baseline). These values come straight from PDFBox
+ * without going through {@code pageHeight - …}, so they're stable for ulp-sensitive comparisons
+ * against anchor screen Ys.
+ */
+ List getScreenLineBoxes() {
+ return screenLineBoxes;
+ }
+
+ @Override
+ protected void writeString(String text, List positions) {
+ for (TextPosition tp : positions) {
+ // Skip whitespace-only positions (spaces, newline markers, indent characters).
+ // These have a TextPosition but no visible glyph; including them causes
+ // space-only "lines" to produce degenerate segments that appear as thin
+ // black bars after redaction.
+ String unicode = tp.getUnicode();
+ if (unicode == null || unicode.isBlank()) {
+ continue;
+ }
+ float screenY = tp.getY();
+ float screenX = tp.getX();
+ boolean yJump =
+ !Float.isNaN(lastScreenY) && Math.abs(screenY - lastScreenY) > LINE_Y_TOLERANCE;
+ boolean xJump =
+ !Float.isNaN(lastGlyphRight) && (screenX - lastGlyphRight) > COLUMN_GAP_X;
+ if (yJump || xJump) {
+ flushLine();
+ }
+ lastScreenY = screenY;
+ lastGlyphRight = screenX + tp.getWidth();
+ currentLine.add(tp);
+ }
+ }
+
+ @Override
+ protected void endPage(PDPage page) throws IOException {
+ flushLine();
+ super.endPage(page);
+ }
+
+ private void flushLine() {
+ if (currentLine.isEmpty()) {
+ return;
+ }
+ float minX = Float.MAX_VALUE, maxX = -Float.MAX_VALUE;
+ float minScreenY = Float.MAX_VALUE, maxScreenY = -Float.MAX_VALUE;
+ for (TextPosition tp : currentLine) {
+ minX = Math.min(minX, tp.getX());
+ maxX = Math.max(maxX, tp.getX() + tp.getWidth());
+ minScreenY = Math.min(minScreenY, tp.getY() - tp.getHeight());
+ maxScreenY = Math.max(maxScreenY, tp.getY());
+ }
+ emitSegment(minX, maxX, minScreenY, maxScreenY);
+ currentLine.clear();
+ lastScreenY = Float.NaN;
+ lastGlyphRight = Float.NaN;
+ }
+
+ private void emitSegment(float minX, float maxX, float minScreenY, float maxScreenY) {
+ float pdfY1 = pageHeight - maxScreenY; // bottom in PDF coords
+ float pdfY2 = pageHeight - minScreenY; // top in PDF coords
+ lineBoxes.add(new float[] {minX, pdfY1, maxX, pdfY2});
+ screenLineBoxes.add(new float[] {minX, minScreenY, maxX, maxScreenY});
+ }
+}
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ManualRedactionService.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ManualRedactionService.java
new file mode 100644
index 000000000..15a233f1f
--- /dev/null
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ManualRedactionService.java
@@ -0,0 +1,403 @@
+package stirling.software.SPDF.controller.api.security;
+
+import java.awt.Color;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.PDPageTree;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
+import org.springframework.stereotype.Service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.SPDF.model.PDFText;
+import stirling.software.SPDF.model.api.security.ManualRedactPdfRequest;
+import stirling.software.SPDF.pdf.parser.PageImageLocator;
+import stirling.software.common.model.api.security.RedactionArea;
+import stirling.software.common.util.GeneralUtils;
+import stirling.software.common.util.PdfUtils;
+import stirling.software.common.util.TempFile;
+import stirling.software.common.util.TempFileManager;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+class ManualRedactionService {
+
+ private static final float DEFAULT_TEXT_PADDING_MULTIPLIER = 0.6f;
+ private static final float REDACTION_WIDTH_REDUCTION_FACTOR = 0.9f;
+
+ private final TempFileManager tempFileManager;
+
+ // -----------------------------------------------------------------------
+ // Area and page redaction
+ // -----------------------------------------------------------------------
+
+ void redactAreas(List redactionAreas, PDDocument document, PDPageTree allPages)
+ throws IOException {
+
+ if (redactionAreas == null || redactionAreas.isEmpty()) {
+ return;
+ }
+
+ Map> redactionsByPage = new HashMap<>();
+
+ for (RedactionArea redactionArea : redactionAreas) {
+ if (redactionArea.getPage() == null
+ || redactionArea.getPage() <= 0
+ || redactionArea.getHeight() == null
+ || redactionArea.getHeight() <= 0.0D
+ || redactionArea.getWidth() == null
+ || redactionArea.getWidth() <= 0.0D) {
+ continue;
+ }
+
+ redactionsByPage
+ .computeIfAbsent(redactionArea.getPage(), k -> new ArrayList<>())
+ .add(redactionArea);
+ }
+
+ for (Map.Entry> entry : redactionsByPage.entrySet()) {
+ Integer pageNumber = entry.getKey();
+ List areasForPage = entry.getValue();
+
+ if (pageNumber > allPages.getCount()) {
+ continue;
+ }
+
+ PDPage page = allPages.get(pageNumber - 1);
+
+ try (PDPageContentStream contentStream =
+ new PDPageContentStream(
+ document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
+
+ contentStream.saveGraphicsState();
+ for (RedactionArea redactionArea : areasForPage) {
+ Color redactColor = decodeOrDefault(redactionArea.getColor());
+
+ contentStream.setNonStrokingColor(redactColor);
+
+ float x = redactionArea.getX().floatValue();
+ float y = redactionArea.getY().floatValue();
+ float width = redactionArea.getWidth().floatValue();
+ float height = redactionArea.getHeight().floatValue();
+
+ float pdfY = page.getBBox().getHeight() - y - height;
+
+ contentStream.addRect(x, pdfY, width, height);
+ contentStream.fill();
+ }
+ contentStream.restoreGraphicsState();
+ }
+ }
+ }
+
+ void redactPages(ManualRedactPdfRequest request, PDDocument document, PDPageTree allPages)
+ throws IOException {
+
+ Color redactColor = decodeOrDefault(request.getPageRedactionColor());
+ List pageNumbers = getPageNumbers(request, allPages.getCount());
+
+ for (Integer pageNumber : pageNumbers) {
+ PDPage page = allPages.get(pageNumber);
+
+ try (PDPageContentStream contentStream =
+ new PDPageContentStream(
+ document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
+ contentStream.setNonStrokingColor(redactColor);
+
+ PDRectangle box = page.getBBox();
+ contentStream.addRect(0, 0, box.getWidth(), box.getHeight());
+ contentStream.fill();
+ }
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Overlay drawing
+ // -----------------------------------------------------------------------
+
+ void redactFoundText(
+ PDDocument document,
+ List blocks,
+ float customPadding,
+ Color redactColor,
+ boolean isTextRemovalMode)
+ throws IOException {
+
+ var allPages = document.getDocumentCatalog().getPages();
+
+ Map> blocksByPage = new HashMap<>();
+ for (PDFText block : blocks) {
+ blocksByPage.computeIfAbsent(block.getPageIndex(), k -> new ArrayList<>()).add(block);
+ }
+
+ for (Map.Entry> entry : blocksByPage.entrySet()) {
+ Integer pageIndex = entry.getKey();
+ List pageBlocks = entry.getValue();
+
+ if (pageIndex >= allPages.getCount()) {
+ continue;
+ }
+
+ var page = allPages.get(pageIndex);
+ try (PDPageContentStream contentStream =
+ new PDPageContentStream(
+ document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
+
+ contentStream.saveGraphicsState();
+
+ try {
+ contentStream.setNonStrokingColor(redactColor);
+ PDRectangle pageBox = page.getBBox();
+
+ for (PDFText block : pageBlocks) {
+ float padding =
+ (block.getY2() - block.getY1()) * DEFAULT_TEXT_PADDING_MULTIPLIER
+ + customPadding;
+
+ float originalWidth = block.getX2() - block.getX1();
+ float boxWidth;
+ float boxX;
+
+ if (isTextRemovalMode) {
+ boxWidth = originalWidth * REDACTION_WIDTH_REDUCTION_FACTOR;
+ float widthReduction = originalWidth - boxWidth;
+ boxX = block.getX1() + (widthReduction / 2);
+ } else {
+ boxWidth = originalWidth;
+ boxX = block.getX1();
+ }
+
+ contentStream.addRect(
+ boxX,
+ pageBox.getHeight() - block.getY2() - padding,
+ boxWidth,
+ block.getY2() - block.getY1() + 2 * padding);
+ }
+
+ contentStream.fill();
+
+ } finally {
+ contentStream.restoreGraphicsState();
+ }
+ }
+
+ // Remove annotations whose bounding rect overlaps a redacted block, to prevent
+ // users from hovering over redacted URLs and seeing the underlying destination.
+ try {
+ float pageH = page.getBBox().getHeight();
+ List kept = new ArrayList<>();
+ for (PDAnnotation ann : page.getAnnotations()) {
+ PDRectangle ar = ann.getRectangle();
+ boolean overlaps = false;
+ if (ar != null) {
+ for (PDFText block : pageBlocks) {
+ float padding =
+ (block.getY2() - block.getY1())
+ * DEFAULT_TEXT_PADDING_MULTIPLIER
+ + customPadding;
+ float bx1 = block.getX1();
+ float bx2 = block.getX2();
+ float by1 = pageH - block.getY2() - padding;
+ float by2 = pageH - block.getY1() + padding;
+ if (ar.getLowerLeftX() < bx2
+ && ar.getUpperRightX() > bx1
+ && ar.getLowerLeftY() < by2
+ && ar.getUpperRightY() > by1) {
+ overlaps = true;
+ break;
+ }
+ }
+ }
+ if (!overlaps) {
+ kept.add(ann);
+ }
+ }
+ page.setAnnotations(kept);
+ } catch (Exception e) {
+ log.debug(
+ "[redact] could not remove annotations on page {}: {}",
+ pageIndex,
+ e.getMessage());
+ }
+ }
+ }
+
+ void redactImageBoxes(PDDocument document, List imageBoxes, Color color)
+ throws IOException {
+ Map> byPage = new HashMap<>();
+ for (float[] box : imageBoxes) {
+ byPage.computeIfAbsent((int) box[0], k -> new ArrayList<>()).add(box);
+ }
+ PDPageTree pages = document.getDocumentCatalog().getPages();
+ for (Map.Entry> entry : byPage.entrySet()) {
+ int pageIdx = entry.getKey();
+ if (pageIdx < 0 || pageIdx >= pages.getCount()) {
+ log.warn("[redact/execute] image box references out-of-range page {}", pageIdx);
+ continue;
+ }
+ PDPage page = pages.get(pageIdx);
+ try (PDPageContentStream cs =
+ new PDPageContentStream(
+ document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
+ cs.saveGraphicsState();
+ cs.setNonStrokingColor(color);
+ for (float[] box : entry.getValue()) {
+ float x1 = box[1], y1 = box[2], x2 = box[3], y2 = box[4];
+ cs.addRect(x1, y1, x2 - x1, y2 - y1);
+ }
+ cs.fill();
+ cs.restoreGraphicsState();
+ }
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Page element extraction
+ // -----------------------------------------------------------------------
+
+ /**
+ * Returns bounding boxes for every text line and image on {@code page} in PDF user-space
+ * coordinates: {@code [x1, y1, x2, y2]} (origin bottom-left, Y increases upward).
+ */
+ List extractPageElementBoxes(PDDocument document, PDPage page, int pageIndex)
+ throws IOException {
+ List boxes = new ArrayList<>();
+
+ AllTextLineExtractor textExtractor =
+ new AllTextLineExtractor(pageIndex + 1, page.getBBox().getHeight());
+ textExtractor.getText(document);
+ boxes.addAll(textExtractor.getLineBoxes());
+
+ PageImageLocator imgLocator = new PageImageLocator(page, pageIndex);
+ imgLocator.processPage(page);
+ for (PageImageLocator.ImageBox imgBox : imgLocator.getImageBoxes()) {
+ boxes.add(new float[] {imgBox.x1(), imgBox.y1(), imgBox.x2(), imgBox.y2()});
+ }
+
+ return boxes;
+ }
+
+ // -----------------------------------------------------------------------
+ // Finalization
+ // -----------------------------------------------------------------------
+
+ TempFile finalizeRedaction(
+ PDDocument document,
+ Map> allFoundTextsByPage,
+ String colorString,
+ float customPadding,
+ Boolean convertToImage,
+ boolean isTextRemovalMode)
+ throws IOException {
+
+ List allFoundTexts = new ArrayList<>();
+ for (List pageTexts : allFoundTextsByPage.values()) {
+ allFoundTexts.addAll(pageTexts);
+ }
+
+ if (!allFoundTexts.isEmpty()) {
+ Color redactColor = decodeOrDefault(colorString);
+ redactFoundText(document, allFoundTexts, customPadding, redactColor, isTextRemovalMode);
+ cleanDocumentMetadata(document);
+ }
+
+ if (Boolean.TRUE.equals(convertToImage)) {
+ try (PDDocument convertedPdf = PdfUtils.convertPdfToPdfImage(document)) {
+ cleanDocumentMetadata(convertedPdf);
+
+ TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
+ try {
+ convertedPdf.save(tempOut.getFile());
+ } catch (IOException e) {
+ tempOut.close();
+ throw e;
+ }
+
+ log.info(
+ "Redaction finalized (image mode): {} pages ➜ {} KB",
+ convertedPdf.getNumberOfPages(),
+ tempOut.getFile().length() / 1024);
+
+ return tempOut;
+ }
+ }
+
+ TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
+ try {
+ document.save(tempOut.getFile());
+ } catch (IOException e) {
+ tempOut.close();
+ throw e;
+ }
+
+ log.info(
+ "Redaction finalized: {} pages ➜ {} KB",
+ document.getNumberOfPages(),
+ tempOut.getFile().length() / 1024);
+
+ return tempOut;
+ }
+
+ private void cleanDocumentMetadata(PDDocument document) {
+ try {
+ var documentInfo = document.getDocumentInformation();
+ if (documentInfo != null) {
+ documentInfo.setAuthor(null);
+ documentInfo.setSubject(null);
+ documentInfo.setKeywords(null);
+ documentInfo.setModificationDate(java.util.Calendar.getInstance());
+ log.debug("Cleaned document metadata for security");
+ }
+
+ if (document.getDocumentCatalog() != null) {
+ try {
+ document.getDocumentCatalog().setMetadata(null);
+ } catch (Exception e) {
+ log.debug("Could not clear XMP metadata: {}", e.getMessage());
+ }
+ }
+
+ } catch (Exception e) {
+ log.warn("Failed to clean document metadata: {}", e.getMessage());
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Utilities
+ // -----------------------------------------------------------------------
+
+ static Color decodeOrDefault(String hex) {
+ if (hex == null) {
+ return Color.BLACK;
+ }
+
+ String colorString = hex.startsWith("#") ? hex : "#" + hex;
+
+ try {
+ return Color.decode(colorString);
+ } catch (NumberFormatException e) {
+ return Color.BLACK;
+ }
+ }
+
+ private List getPageNumbers(ManualRedactPdfRequest request, int pagesCount) {
+ String pageNumbersInput = request.getPageNumbers();
+ String[] parsedPageNumbers =
+ pageNumbersInput != null ? pageNumbersInput.split(",") : new String[0];
+ List pageNumbers =
+ GeneralUtils.parsePageList(parsedPageNumbers, pagesCount, false);
+ Collections.sort(pageNumbers);
+ return pageNumbers;
+ }
+}
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/MultiPatternTextFinder.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/MultiPatternTextFinder.java
new file mode 100644
index 000000000..7771493f2
--- /dev/null
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/MultiPatternTextFinder.java
@@ -0,0 +1,174 @@
+package stirling.software.SPDF.controller.api.security;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.apache.pdfbox.text.TextPosition;
+
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.SPDF.model.PDFText;
+
+/**
+ * Scans a PDF document once and matches all provided patterns in a single pass, collecting
+ * bounding-box positions for every match. Use in place of creating one {@code TextFinder} per
+ * search term to avoid O(n) full-document scans.
+ */
+@Slf4j
+final class MultiPatternTextFinder extends PDFTextStripper {
+
+ private static final long REGEX_MATCH_TIMEOUT_SECONDS = 30;
+ private static final ExecutorService REGEX_EXECUTOR =
+ Executors.newVirtualThreadPerTaskExecutor();
+
+ private final List patterns;
+ private final Map> foundTextsByPage = new HashMap<>();
+
+ private final List pageTextPositions = new ArrayList<>();
+ private final StringBuilder pageTextBuilder = new StringBuilder();
+
+ MultiPatternTextFinder(List patterns) throws IOException {
+ this.patterns = patterns;
+ this.setWordSeparator(" ");
+ this.setLineSeparator("\n");
+ }
+
+ Map> getFoundTextsByPage() {
+ return foundTextsByPage;
+ }
+
+ @Override
+ protected void startPage(PDPage page) throws IOException {
+ super.startPage(page);
+ pageTextPositions.clear();
+ pageTextBuilder.setLength(0);
+ }
+
+ @Override
+ protected void writeString(String text, List textPositions) {
+ pageTextBuilder.append(text);
+ pageTextPositions.addAll(textPositions);
+ }
+
+ @Override
+ protected void writeWordSeparator() {
+ pageTextBuilder.append(getWordSeparator());
+ pageTextPositions.add(null);
+ }
+
+ @Override
+ protected void writeLineSeparator() {
+ pageTextBuilder.append(getLineSeparator());
+ pageTextPositions.add(null);
+ }
+
+ @Override
+ protected void endPage(PDPage page) throws IOException {
+ String text = pageTextBuilder.toString();
+ if (!text.isEmpty()) {
+ int pageIndex = getCurrentPageNo() - 1;
+ for (Pattern pattern : patterns) {
+ Matcher matcher = pattern.matcher(text);
+ while (safeFind(matcher)) {
+ PDFText pdfText = resolveMatchPosition(matcher, pageIndex);
+ if (pdfText != null) {
+ foundTextsByPage
+ .computeIfAbsent(pageIndex, k -> new ArrayList<>())
+ .add(pdfText);
+ }
+ }
+ }
+ }
+ super.endPage(page);
+ }
+
+ /**
+ * Wraps a single {@code matcher.find()} call with a {@value #REGEX_MATCH_TIMEOUT_SECONDS}
+ * second timeout. Prevents pathological regex backtracking from blocking the request
+ * indefinitely; per-match timeout so fast legitimate scans are unaffected.
+ */
+ private static boolean safeFind(Matcher matcher) throws IOException {
+ Future future =
+ REGEX_EXECUTOR.submit((java.util.concurrent.Callable) matcher::find);
+ try {
+ return future.get(REGEX_MATCH_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ } catch (TimeoutException e) {
+ future.cancel(true);
+ throw new IOException(
+ "Regex match timed out after "
+ + REGEX_MATCH_TIMEOUT_SECONDS
+ + "s — pattern may cause catastrophic backtracking");
+ } catch (InterruptedException e) {
+ future.cancel(true);
+ Thread.currentThread().interrupt();
+ throw new IOException("Regex match interrupted", e);
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause();
+ if (cause instanceof IOException ioEx) throw ioEx;
+ throw new IOException("Regex match failed: " + cause.getMessage(), cause);
+ }
+ }
+
+ private PDFText resolveMatchPosition(Matcher matcher, int pageIndex) {
+ int matchStart = matcher.start();
+ int matchEnd = matcher.end();
+
+ float minX = Float.MAX_VALUE;
+ float minY = Float.MAX_VALUE;
+ float maxX = Float.MIN_VALUE;
+ float maxY = Float.MIN_VALUE;
+ boolean foundPosition = false;
+
+ for (int i = matchStart; i < matchEnd; i++) {
+ if (i >= pageTextPositions.size()) break;
+ TextPosition pos = pageTextPositions.get(i);
+ if (pos != null) {
+ foundPosition = true;
+ minX = Math.min(minX, pos.getX());
+ maxX = Math.max(maxX, pos.getX() + pos.getWidth());
+ minY = Math.min(minY, pos.getY() - pos.getHeight());
+ maxY = Math.max(maxY, pos.getY());
+ }
+ }
+
+ if (!foundPosition && matchStart < pageTextPositions.size()) {
+ for (int i = Math.max(0, matchStart - 5);
+ i < Math.min(pageTextPositions.size(), matchEnd + 5);
+ i++) {
+ TextPosition pos = pageTextPositions.get(i);
+ if (pos != null) {
+ foundPosition = true;
+ minX = Math.min(minX, pos.getX());
+ maxX = Math.max(maxX, pos.getX() + pos.getWidth());
+ minY = Math.min(minY, pos.getY() - pos.getHeight());
+ maxY = Math.max(maxY, pos.getY());
+ break;
+ }
+ }
+ }
+
+ if (!foundPosition) {
+ log.warn(
+ "Found text match '{}' but no valid position data at {}-{}",
+ matcher.group(),
+ matchStart,
+ matchEnd);
+ return null;
+ }
+
+ return new PDFText(pageIndex, minX, minY, maxX, maxY, matcher.group());
+ }
+}
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java
index 33edd89fd..127b43630 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java
@@ -1,38 +1,12 @@
package stirling.software.SPDF.controller.api.security;
-import java.awt.Color;
import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
-import java.util.Set;
-import java.util.regex.Pattern;
-import java.util.stream.Collectors;
-import org.apache.pdfbox.contentstream.operator.Operator;
-import org.apache.pdfbox.cos.COSArray;
-import org.apache.pdfbox.cos.COSBase;
-import org.apache.pdfbox.cos.COSFloat;
-import org.apache.pdfbox.cos.COSName;
-import org.apache.pdfbox.cos.COSNumber;
-import org.apache.pdfbox.cos.COSString;
-import org.apache.pdfbox.pdfparser.PDFStreamParser;
-import org.apache.pdfbox.pdfwriter.ContentStreamWriter;
import org.apache.pdfbox.pdmodel.PDDocument;
-import org.apache.pdfbox.pdmodel.PDPage;
-import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDPageTree;
-import org.apache.pdfbox.pdmodel.PDResources;
-import org.apache.pdfbox.pdmodel.common.PDRectangle;
-import org.apache.pdfbox.pdmodel.common.PDStream;
-import org.apache.pdfbox.pdmodel.font.PDFont;
-import org.apache.pdfbox.pdmodel.graphics.PDXObject;
-import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -44,54 +18,45 @@ import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
-import lombok.AllArgsConstructor;
-import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.PDFText;
import stirling.software.SPDF.model.api.security.ManualRedactPdfRequest;
+import stirling.software.SPDF.model.api.security.RedactExecuteRequest;
+import stirling.software.SPDF.model.api.security.RedactExecuteRequest.ImageBox;
+import stirling.software.SPDF.model.api.security.RedactExecuteRequest.RedactStyle;
+import stirling.software.SPDF.model.api.security.RedactExecuteRequest.TextRange;
import stirling.software.SPDF.model.api.security.RedactPdfRequest;
-import stirling.software.SPDF.pdf.TextFinder;
-import stirling.software.SPDF.utils.text.TextEncodingHelper;
-import stirling.software.SPDF.utils.text.TextFinderUtils;
-import stirling.software.SPDF.utils.text.WidthCalculator;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.SecurityApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.security.RedactionArea;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
-import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
-import stirling.software.common.util.propertyeditor.StringToArrayListPropertyEditor;
+import stirling.software.common.util.propertyeditor.JsonListPropertyEditor;
+import stirling.software.common.util.propertyeditor.JsonObjectPropertyEditor;
+
+import tools.jackson.core.type.TypeReference;
@SecurityApi
@Slf4j
@RequiredArgsConstructor
public class RedactController {
- private static final float DEFAULT_TEXT_PADDING_MULTIPLIER = 0.6f;
- private static final float PRECISION_THRESHOLD = 1e-3f;
- private static final int FONT_SCALE_FACTOR = 1000;
-
- // Redaction box width reduction factor (10% reduction)
- private static final float REDACTION_WIDTH_REDUCTION_FACTOR = 0.9f;
-
- // Text showing operators
- private static final Set TEXT_SHOWING_OPERATORS = Set.of("Tj", "TJ", "'", "\"");
-
- private static final COSString EMPTY_COS_STRING = new COSString("");
-
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
+ private final ManualRedactionService manualRedactionService;
+ private final TextRedactionService textRedactionService;
+ private final RedactExecuteService redactExecuteService;
private String removeFileExtension(String filename) {
- return GeneralUtils.removeExtension(filename);
+ return stirling.software.common.util.GeneralUtils.removeExtension(filename);
}
@InitBinder
@@ -99,7 +64,17 @@ public class RedactController {
binder.registerCustomEditor(
List.class,
"redactions",
- new StringToArrayListPropertyEditor<>(RedactionArea.class));
+ new JsonListPropertyEditor<>(new TypeReference>() {}));
+ binder.registerCustomEditor(
+ List.class,
+ "ranges",
+ new JsonListPropertyEditor<>(new TypeReference>() {}));
+ binder.registerCustomEditor(
+ List.class,
+ "imageBoxes",
+ new JsonListPropertyEditor<>(new TypeReference>() {}));
+ binder.registerCustomEditor(
+ RedactStyle.class, "style", new JsonObjectPropertyEditor<>(RedactStyle.class));
}
@AutoJobPostMapping(
@@ -118,14 +93,12 @@ public class RedactController {
throws IOException {
MultipartFile file = request.getFileInput();
- List redactionAreas = request.getRedactions();
try (PDDocument document = pdfDocumentFactory.load(file)) {
PDPageTree allPages = document.getDocumentCatalog().getPages();
- redactPages(request, document, allPages);
-
- redactAreas(redactionAreas, document, allPages);
+ manualRedactionService.redactPages(request, document, allPages);
+ manualRedactionService.redactAreas(request.getRedactions(), document, allPages);
if (Boolean.TRUE.equals(request.getConvertPDFToImage())) {
try (PDDocument convertedPdf = PdfUtils.convertPdfToPdfImage(document)) {
@@ -150,354 +123,6 @@ public class RedactController {
}
}
- private void redactAreas(
- List redactionAreas, PDDocument document, PDPageTree allPages)
- throws IOException {
-
- if (redactionAreas == null || redactionAreas.isEmpty()) {
- return;
- }
-
- Map> redactionsByPage = new HashMap<>();
-
- for (RedactionArea redactionArea : redactionAreas) {
-
- if (redactionArea.getPage() == null
- || redactionArea.getPage() <= 0
- || redactionArea.getHeight() == null
- || redactionArea.getHeight() <= 0.0D
- || redactionArea.getWidth() == null
- || redactionArea.getWidth() <= 0.0D) {
- continue;
- }
-
- redactionsByPage
- .computeIfAbsent(redactionArea.getPage(), k -> new ArrayList<>())
- .add(redactionArea);
- }
-
- for (Map.Entry> entry : redactionsByPage.entrySet()) {
- Integer pageNumber = entry.getKey();
- List areasForPage = entry.getValue();
-
- if (pageNumber > allPages.getCount()) {
- continue; // Skip if the page number is out of bounds
- }
-
- PDPage page = allPages.get(pageNumber - 1);
-
- try (PDPageContentStream contentStream =
- new PDPageContentStream(
- document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
-
- contentStream.saveGraphicsState();
- for (RedactionArea redactionArea : areasForPage) {
- Color redactColor = decodeOrDefault(redactionArea.getColor());
-
- contentStream.setNonStrokingColor(redactColor);
-
- float x = redactionArea.getX().floatValue();
- float y = redactionArea.getY().floatValue();
- float width = redactionArea.getWidth().floatValue();
- float height = redactionArea.getHeight().floatValue();
-
- float pdfY = page.getBBox().getHeight() - y - height;
-
- contentStream.addRect(x, pdfY, width, height);
- contentStream.fill();
- }
- contentStream.restoreGraphicsState();
- }
- }
- }
-
- private void redactPages(
- ManualRedactPdfRequest request, PDDocument document, PDPageTree allPages)
- throws IOException {
-
- Color redactColor = decodeOrDefault(request.getPageRedactionColor());
- List pageNumbers = getPageNumbers(request, allPages.getCount());
-
- for (Integer pageNumber : pageNumbers) {
-
- PDPage page = allPages.get(pageNumber);
-
- try (PDPageContentStream contentStream =
- new PDPageContentStream(
- document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
- contentStream.setNonStrokingColor(redactColor);
-
- PDRectangle box = page.getBBox();
-
- contentStream.addRect(0, 0, box.getWidth(), box.getHeight());
- contentStream.fill();
- }
- }
- }
-
- private void redactFoundText(
- PDDocument document,
- List blocks,
- float customPadding,
- Color redactColor,
- boolean isTextRemovalMode)
- throws IOException {
-
- var allPages = document.getDocumentCatalog().getPages();
-
- Map> blocksByPage = new HashMap<>();
- for (PDFText block : blocks) {
- blocksByPage.computeIfAbsent(block.getPageIndex(), k -> new ArrayList<>()).add(block);
- }
-
- for (Map.Entry> entry : blocksByPage.entrySet()) {
- Integer pageIndex = entry.getKey();
- List pageBlocks = entry.getValue();
-
- if (pageIndex >= allPages.getCount()) {
- continue; // Skip if page index is out of bounds
- }
-
- var page = allPages.get(pageIndex);
- try (PDPageContentStream contentStream =
- new PDPageContentStream(
- document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
-
- contentStream.saveGraphicsState();
-
- try {
- contentStream.setNonStrokingColor(redactColor);
- PDRectangle pageBox = page.getBBox();
-
- for (PDFText block : pageBlocks) {
- float padding =
- (block.getY2() - block.getY1()) * DEFAULT_TEXT_PADDING_MULTIPLIER
- + customPadding;
-
- float originalWidth = block.getX2() - block.getX1();
- float boxWidth;
- float boxX;
-
- // Only apply width reduction when text is actually being removed
- if (isTextRemovalMode) {
- // Calculate reduced width and center the box
- boxWidth =
- originalWidth
- * REDACTION_WIDTH_REDUCTION_FACTOR; // 10% reduction
- float widthReduction = originalWidth - boxWidth;
- boxX = block.getX1() + (widthReduction / 2); // Center the reduced box
- } else {
- // Use original width for box-only redaction
- boxWidth = originalWidth;
- boxX = block.getX1();
- }
-
- contentStream.addRect(
- boxX,
- pageBox.getHeight() - block.getY2() - padding,
- boxWidth,
- block.getY2() - block.getY1() + 2 * padding);
- }
-
- contentStream.fill();
-
- } finally {
- contentStream.restoreGraphicsState();
- }
- }
- }
- }
-
- String createPlaceholderWithFont(String originalWord, PDFont font) {
- if (originalWord == null || originalWord.isEmpty()) {
- return originalWord;
- }
-
- if (font != null && TextEncodingHelper.isFontSubset(font.getName())) {
- try {
- float originalWidth = safeGetStringWidth(font, originalWord) / FONT_SCALE_FACTOR;
- return createAlternativePlaceholder(originalWord, originalWidth, font, 1.0f);
- } catch (Exception e) {
- log.debug(
- "Subset font placeholder creation failed for {}: {}",
- font.getName(),
- e.getMessage());
- return "";
- }
- }
-
- return " ".repeat(originalWord.length());
- }
-
- /**
- * Enhanced placeholder creation using advanced width calculation. Incorporates font validation
- * and sophisticated fallback strategies.
- */
- String createPlaceholderWithWidth(
- String originalWord, float targetWidth, PDFont font, float fontSize) {
- if (originalWord == null || originalWord.isEmpty()) {
- return originalWord;
- }
-
- if (font == null || fontSize <= 0) {
- return " ".repeat(originalWord.length());
- }
-
- try {
- // Check font reliability before proceeding
- if (!WidthCalculator.isWidthCalculationReliable(font)) {
- log.debug(
- "Font {} unreliable for width calculation, using simple placeholder",
- font.getName());
- return " ".repeat(originalWord.length());
- }
-
- // Use enhanced subset font detection
- if (TextEncodingHelper.isFontSubset(font.getName())) {
- return createSubsetFontPlaceholder(originalWord, targetWidth, font, fontSize);
- }
-
- // Enhanced space width calculation
- float spaceWidth = WidthCalculator.calculateAccurateWidth(font, " ", fontSize);
-
- if (spaceWidth <= 0) {
- return createAlternativePlaceholder(originalWord, targetWidth, font, fontSize);
- }
-
- int spaceCount = Math.max(1, Math.round(targetWidth / spaceWidth));
-
- // More conservative space limit based on original word characteristics
- int maxSpaces =
- Math.max(
- originalWord.length() * 2, Math.round(targetWidth / spaceWidth * 1.5f));
- spaceCount = Math.min(spaceCount, maxSpaces);
-
- return " ".repeat(spaceCount);
-
- } catch (Exception e) {
- log.debug("Enhanced placeholder creation failed: {}", e.getMessage());
- return createAlternativePlaceholder(originalWord, targetWidth, font, fontSize);
- }
- }
-
- private String createSubsetFontPlaceholder(
- String originalWord, float targetWidth, PDFont font, float fontSize) {
- try {
- log.debug("Subset font {} - trying to find replacement characters", font.getName());
- String result = createAlternativePlaceholder(originalWord, targetWidth, font, fontSize);
-
- if (result.isEmpty()) {
- log.debug(
- "Subset font {} has no suitable replacement characters, using empty string",
- font.getName());
- }
-
- return result;
-
- } catch (Exception e) {
- log.debug("Subset font placeholder creation failed: {}", e.getMessage());
- return "";
- }
- }
-
- private String createAlternativePlaceholder(
- String originalWord, float targetWidth, PDFont font, float fontSize) {
- try {
- String[] alternatives = {" ", ".", "-", "_", "~", "°", "·"};
-
- if (TextEncodingHelper.fontSupportsCharacter(font, " ")) {
- float spaceWidth = safeGetStringWidth(font, " ") / FONT_SCALE_FACTOR * fontSize;
- if (spaceWidth > 0) {
- int spaceCount = Math.max(1, Math.round(targetWidth / spaceWidth));
- int maxSpaces = originalWord.length() * 2;
- spaceCount = Math.min(spaceCount, maxSpaces);
- log.debug("Using spaces for font {}", font.getName());
- return " ".repeat(spaceCount);
- }
- }
-
- for (String altChar : alternatives) {
- if (" ".equals(altChar)) continue; // Already tried spaces
-
- try {
- if (!TextEncodingHelper.fontSupportsCharacter(font, altChar)) {
- continue;
- }
-
- float charWidth =
- safeGetStringWidth(font, altChar) / FONT_SCALE_FACTOR * fontSize;
- if (charWidth > 0) {
- int charCount = Math.max(1, Math.round(targetWidth / charWidth));
- int maxChars = originalWord.length() * 2;
- charCount = Math.min(charCount, maxChars);
- log.debug(
- "Using character '{}' for width calculation but spaces for placeholder in font {}",
- altChar,
- font.getName());
-
- return " ".repeat(charCount);
- }
- } catch (Exception e) {
- }
- }
-
- log.debug(
- "All placeholder alternatives failed for font {}, using empty string",
- font.getName());
- return "";
-
- } catch (Exception e) {
- log.debug("Alternative placeholder creation failed: {}", e.getMessage());
- return "";
- }
- }
-
- void writeFilteredContentStream(PDDocument document, PDPage page, List