smart redaction (#6195)

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
EthanHealy01
2026-06-03 16:16:33 +00:00
committed by GitHub
co-authored by James Brunton
parent 7f3ca7ea70
commit 35a712a278
26 changed files with 4397 additions and 1802 deletions
@@ -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}.
*
* <p>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)
*
* <p>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 &ge; {@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<float[]> columns;
private final List<float[]> gutters;
private PageColumnLayout(List<float[]> columns, List<float[]> 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.
*
* <p>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<float[]> 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<float[]> columns() {
return columns;
}
/** Gutters between columns, left-to-right, as {@code [leftX, rightX]} pairs. */
public List<float[]> 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<Integer> 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;
}
}
@@ -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).
*
* <p>Usage:
*
* <pre>{@code
* PageImageLocator locator = new PageImageLocator(page, pageIndex);
* locator.processPage(page);
* List<ImageBox> boxes = locator.getImageBoxes();
* }</pre>
*
* <p>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<ImageBox> 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<ImageBox> 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) {}
}
@@ -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<T>}. 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<T> 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<T>> typeRef;
public JsonListPropertyEditor(TypeReference<? extends List<T>> typeRef) {
this.typeRef = typeRef;
}
@Override
public void setAsText(String text) throws IllegalArgumentException {
if (text == null || text.trim().isEmpty()) {
setValue(new ArrayList<T>());
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());
}
}
}
@@ -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<T> extends PropertyEditorSupport {
private static final ObjectMapper OBJECT_MAPPER =
JsonMapper.builder().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build();
private final Class<T> type;
public JsonObjectPropertyEditor(Class<T> 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());
}
}
}
@@ -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<RedactionArea>}, {@code List<EditTextOperation>}) from
* multipart form fields, where Spring's default binding cannot deserialize a JSON array.
*/
@Slf4j
public class StringToArrayListPropertyEditor<T> 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<T> elementType;
public StringToArrayListPropertyEditor(Class<T> 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<T> 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");
}
}
}