Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions src/lib/analyzer/checks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type {Cover, PageExtract, TextMark, TextRun} from './extract'
import type {BBox, CheckId, CoverKind, Finding, WidthAnalysis} from './types'
import {LABEL_RUN_COVERAGE, MIN_COVERAGE} from './constants'
import {
LABEL_RUN_COVERAGE,
MAX_WORD_SIZE_CHARS,
MIN_COVERAGE,
} from './constants'
import {coverage, height, intersection, sameLine, width} from './geometry'

/**
Expand Down Expand Up @@ -113,9 +117,14 @@ function analyzeWordSize(cover: Cover, runs: TextRun[]): WidthAnalysis | null {
return null
}

// Require a word immediately adjacent (a genuine inline gap). The far-side
// slack is font-relative (~1 em), NOT the box's own width — otherwise a wide
// box would treat text a whole box-width away as "adjacent".
// Require words on BOTH sides — a genuine inline gap where a single word was
// dropped out of running text. Boxes with text on only one side are usually
// not word redactions at all: a mark at a line's start is a classification
// portion marking (paragraph to its right, nothing to its left), and one at a
// line's end is a whole field like an email "From:" value (label to its left,
// nothing to its right). Neither is a guessable word, so both are excluded.
// The far-side slack is font-relative (~1 em), NOT the box's own width —
// otherwise a wide box would treat text a whole box-width away as "adjacent".
const gap = Math.max(6, ref.fontSizePt * 0.5)
const slack = Math.max(4, ref.fontSizePt)
const hasLeft = lineRuns.some(
Expand All @@ -124,7 +133,7 @@ function analyzeWordSize(cover: Cover, runs: TextRun[]): WidthAnalysis | null {
const hasRight = lineRuns.some(
(r) => r.bbox[0] >= box[2] - gap && r.bbox[0] <= box[2] + slack
)
if (!hasLeft && !hasRight) return null
if (!hasLeft || !hasRight) return null

// Calibrate average glyph width from same-font line text.
const sameFont = lineRuns.filter(
Expand Down Expand Up @@ -155,6 +164,12 @@ function analyzeWordSize(cover: Cover, runs: TextRun[]): WidthAnalysis | null {
Math.ceil((boxWidthPt / avgGlyphWidthPt) * 1.25)
)

// A width-based length estimate is only a meaningful hint for a short token.
// Once even the low end of the estimate is longer than any plausible single
// word, the box is a redacted phrase or field (e.g. a full "Last, First
// <email>" line), where width narrows nothing — so don't flag it at all.
if (minChars > MAX_WORD_SIZE_CHARS) return null

// Likely-numeric if the neighbours are dominated by digits/currency.
const neighbourText = lineRuns.map((r) => r.str).join('')
const isLikelyNumeric =
Expand Down
8 changes: 8 additions & 0 deletions src/lib/analyzer/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ export const LABEL_RUN_COVERAGE = 0.7
/** Fraction of a glyph box that must be covered to count as redacted. */
export const MIN_COVERAGE = 0.6

/**
* Longest length (low-end estimate, in characters) for which a box-width "how
* many characters?" hint is still meaningful. A single hidden word is short;
* once even the minimum estimate exceeds this, the box is a redacted phrase or
* whole field, where width narrows nothing — so the word-size check bails.
*/
export const MAX_WORD_SIZE_CHARS = 15

/** Minimum cover area (viewport units²) to bother with — ignores hairlines. */
export const MIN_COVER_AREA = 12

Expand Down
53 changes: 52 additions & 1 deletion src/lib/analyzer/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,25 @@ async function extractCovers(
case OPS.transform:
state.ctm = Util.transform(state.ctm, args as number[])
break
case OPS.paintFormXObjectBegin: {
// pdf.js flattens Form XObjects inline, bracketing their content with an
// implicit save + form-matrix transform (+ bbox clip). Without applying
// that matrix, path fills drawn inside a form — e.g. a redaction mark's
// appearance stream — come out in the parent's space, shifted off their
// true position and onto whatever text happens to sit there. Mirror the
// save + transform so covers land where they actually paint; the paired
// End restores state. (Text runs are already placed correctly by pdf.js
// getTextContent, so an unhandled form matrix desyncs covers from text.)
stack.push({...state, ctm: state.ctm.slice()})
const matrix = (args as unknown[])[0] as number[] | null | undefined
if (matrix) state.ctm = Util.transform(state.ctm, matrix)
break
}
case OPS.paintFormXObjectEnd: {
const prev = stack.pop()
if (prev) state = prev
break
}
case OPS.setFillRGBColor: {
// Normalized by pdf.js to 0..255 RGB.
const a = args as number[]
Expand Down Expand Up @@ -386,6 +405,17 @@ async function extractCovers(
// Painted but not filled — discard the path without flagging it.
pending = []
break
case OPS.endPath:
// A path that ends without a fill or stroke paints nothing. This is
// almost always a clip path (construct -> clip -> endPath, as tools do
// to mask an icon or image). If it isn't discarded, its rectangle stays
// in `pending` and the NEXT unrelated fill flushes it as a dark cover —
// fabricating a "redaction" over whatever visible text the clip region
// happened to bound (e.g. footer social handles clipped for layout). A
// path that is both clipped AND filled still reaches its fill op with
// `pending` intact, so real filled shapes are unaffected.
pending = []
break
case OPS.beginText:
textMatrix = IDENTITY
lineMatrix = IDENTITY
Expand Down Expand Up @@ -559,6 +589,27 @@ async function extractAnnotationCovers(
return covers
}

/**
* Collapse covers that describe the same rectangle. A single redaction mark is
* routinely painted many times over — its appearance stream is re-emitted, or
* the same fill is stamped repeatedly in the content stream — and without this
* one box would produce dozens of identical findings, inflating counts and the
* grade. Covers are keyed by kind + integer-rounded bbox; the earliest-painted
* instance is kept so the paint-order (opIndex) used by the overlay-label logic
* stays correct.
*/
function dedupeCovers(covers: Cover[]): Cover[] {
const byRect = new Map<string, Cover>()
for (const c of covers) {
const key = `${c.kind}:${Math.round(c.bbox[0])},${Math.round(
c.bbox[1]
)},${Math.round(c.bbox[2])},${Math.round(c.bbox[3])}`
const prev = byRect.get(key)
if (!prev || c.opIndex < prev.opIndex) byRect.set(key, c)
}
return [...byRect.values()]
}

export async function extractPage(
page: PDFPageProxy,
pageNumber: number,
Expand All @@ -575,7 +626,7 @@ export async function extractPage(
width: vp.width,
height: vp.height,
runs,
covers: [...vector.covers, ...annots],
covers: dedupeCovers([...vector.covers, ...annots]),
lightMarks: vector.lightMarks,
hasLargeImage: vector.hasLargeImage,
}
Expand Down
Loading