Feature/pdf to markdown agent (#6271)

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
EthanHealy01
2026-05-14 16:20:45 +00:00
committed by GitHub
co-authored by James Brunton
parent 5b9ef852ab
commit ece1bb6865
36 changed files with 4081 additions and 267 deletions
+8
View File
@@ -51,4 +51,12 @@ dependencies {
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
api 'jakarta.mail:jakarta.mail-api:2.1.5'
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
// Tabula table extraction — used by the shared PDF parser and directly by downstream modules.
// api-scoped so downstream modules (core, proprietary) retain it on their compile classpath.
api ('technology.tabula:tabula:1.0.5') {
exclude group: 'org.slf4j', module: 'slf4j-simple'
exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on'
exclude group: 'com.google.code.gson', module: 'gson'
}
}
@@ -0,0 +1,73 @@
package stirling.software.SPDF.pdf.parser;
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
import java.io.IOException;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Chains table parsers in priority order: Tabula lattice → Tabula stream → {@link
* LineAlignmentTableParser}. The first parser returning a result above {@link
* #TABULA_CONFIDENCE_THRESHOLD} wins; results from different parsers are never mixed on one page.
*/
@Service
@Primary
@RequiredArgsConstructor
@Slf4j
public class CompositeTableParser implements TableParser {
/** Min Tabula confidence to accept results; below this LineAlignment is tried instead. */
static final float TABULA_CONFIDENCE_THRESHOLD = 0.5f;
private final TabulaTableParser tabulaParser;
private final LineAlignmentTableParser lineAlignmentParser;
@Override
public List<TableFragment> parse(PDDocument document, RawPage rawPage) throws IOException {
// Step 1: Tabula lattice mode (ruled/bordered tables).
List<TableFragment> latticeResults = filterConfident(tabulaParser.parse(document, rawPage));
if (!latticeResults.isEmpty()) {
log.debug(
"Page {}: using Tabula lattice ({} table(s))",
rawPage.pageNumber(),
latticeResults.size());
return latticeResults;
}
// Step 2: Tabula stream mode (borderless/whitespace-delimited tables).
// parseStream is not on the TableParser interface — this intentionally couples to the
// concrete TabulaTableParser since stream mode is a Tabula-specific concept.
List<TableFragment> streamResults =
filterConfident(tabulaParser.parseStream(document, rawPage));
if (!streamResults.isEmpty()) {
log.debug(
"Page {}: using Tabula stream ({} table(s))",
rawPage.pageNumber(),
streamResults.size());
return streamResults;
}
// Step 3: Geometry-based line-alignment fallback.
List<TableFragment> lineResults = lineAlignmentParser.parse(document, rawPage);
if (!lineResults.isEmpty()) {
log.debug(
"Page {}: using LineAlignment ({} table(s))",
rawPage.pageNumber(),
lineResults.size());
return lineResults;
}
return List.of();
}
private List<TableFragment> filterConfident(List<TableFragment> tables) {
return tables.stream().filter(t -> t.confidence() >= TABULA_CONFIDENCE_THRESHOLD).toList();
}
}
@@ -0,0 +1,528 @@
package stirling.software.SPDF.pdf.parser;
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
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.Optional;
import java.util.TreeMap;
import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Fallback {@link TableParser} for borderless financial tables using text geometry.
*
* <p>Identifies "anchor lines" (≥2 numeric tokens), builds a column grid from their right-edge
* positions, groups vertically proximate anchor lines into table candidates, then scores each group
* on column consistency and anchor density (confidence ceiling 0.85).
*/
@Service
@Slf4j
public class LineAlignmentTableParser implements TableParser {
/** Width in points of each column position bucket. */
static final float COLUMN_BUCKET_PT = 5f;
/** Tolerance in buckets when matching a token's right-edge to a confirmed column position. */
private static final int COLUMN_MATCH_BUCKETS = 2;
/** Maximum gap (as a multiple of modal line spacing) before splitting a group. */
private static final float MAX_GAP_FACTOR = 2.5f;
/** Minimum anchor rows (numeric-heavy) to form a valid table. */
static final int MIN_TABLE_ROWS = 3;
/** Minimum confirmed column positions to form a valid table. */
static final int MIN_COLUMNS = 2;
/**
* Min fraction of anchor lines a column must appear on to be confirmed (permissive for N/A
* rows).
*/
private static final double COLUMN_MIN_FREQUENCY = 0.40;
/**
* Matches financial numeric tokens: integers, decimals, parenthetical negatives, currency,
* percent, nil dashes.
*/
private static final Pattern NUMERIC =
Pattern.compile("^[\\(\\-\\$£€¥]?\\d[\\d,\\.]*[\\)%]?$|^[-–—]$");
/**
* Lines within this y-distance are merged into one row (restores rows split by LineBuilder's
* column-gap logic).
*/
static final float ROW_MERGE_TOLERANCE_PT = 2f;
// ── public API ───────────────────────────────────────────────────────────────────────────────
@Override
public List<TableFragment> parse(PDDocument document, RawPage rawPage) throws IOException {
List<RawLine> lines = rawPage.lines();
if (lines.size() < MIN_TABLE_ROWS) return List.of();
float modalSpacing = computeModalSpacing(lines);
List<TokenizedLine> tokenized =
mergeCoincidentLines(lines.stream().map(this::tokenize).toList());
List<TokenizedLine> anchors = tokenized.stream().filter(TokenizedLine::isAnchor).toList();
if (anchors.size() < MIN_TABLE_ROWS) return List.of();
List<Float> columnGrid = buildColumnGrid(anchors);
if (columnGrid.size() < MIN_COLUMNS) {
log.debug(
"Page {}: LineAlignment — fewer than {} confirmed columns, skipping",
rawPage.pageNumber(),
MIN_COLUMNS);
return List.of();
}
List<List<TokenizedLine>> groups = groupRows(tokenized, columnGrid, modalSpacing);
List<TableFragment> results = new ArrayList<>();
for (int i = 0; i < groups.size(); i++) {
buildFragment(groups.get(i), columnGrid, rawPage.pageNumber(), i)
.ifPresent(results::add);
}
log.debug(
"Page {}: LineAlignment detected {} table(s) ({} anchor lines, {} columns)",
rawPage.pageNumber(),
results.size(),
anchors.size(),
columnGrid.size());
return results;
}
// ── coincident-line merging ──────────────────────────────────────────────────────────────────
/**
* Merges tokenised lines sharing the same y-position into one row, rejoining label/value halves
* split by LineBuilder.
*/
List<TokenizedLine> mergeCoincidentLines(List<TokenizedLine> tokenized) {
if (tokenized.size() < 2) return tokenized;
List<TokenizedLine> result = new ArrayList<>();
int i = 0;
while (i < tokenized.size()) {
float baseY = tokenized.get(i).line().bounds().y();
int j = i + 1;
while (j < tokenized.size()
&& Math.abs(tokenized.get(j).line().bounds().y() - baseY)
<= ROW_MERGE_TOLERANCE_PT) {
j++;
}
if (j == i + 1) {
result.add(tokenized.get(i));
} else {
result.add(mergeGroup(tokenized.subList(i, j)));
}
i = j;
}
return result;
}
private TokenizedLine mergeGroup(List<TokenizedLine> group) {
List<TextFragment> mergedFragments =
group.stream()
.flatMap(tl -> tl.line().fragments().stream())
.sorted(Comparator.comparingDouble(f -> f.bounds().x()))
.toList();
Bounds mergedBounds =
group.stream()
.map(tl -> tl.line().bounds())
.reduce(Bounds::merge)
.orElse(group.get(0).line().bounds());
RawLine mergedLine =
new RawLine(
group.get(0).line().lineId(),
mergedFragments,
mergedBounds,
group.get(0).line().pageNumber());
return tokenize(mergedLine);
}
// ── tokenisation ─────────────────────────────────────────────────────────────────────────────
/**
* Splits fragments into word-level tokens; x-positions are estimated linearly within each
* fragment.
*/
TokenizedLine tokenize(RawLine line) {
List<LineToken> tokens = new ArrayList<>();
for (TextFragment frag : line.fragments()) {
tokens.addAll(tokensFromFragment(frag));
}
List<LineToken> numeric = tokens.stream().filter(LineToken::numeric).toList();
return new TokenizedLine(line, tokens, numeric);
}
private List<LineToken> tokensFromFragment(TextFragment frag) {
String raw = frag.text();
if (raw == null || raw.isBlank()) return List.of();
float fragX = frag.bounds().x();
float fragWidth = frag.bounds().width();
int rawLen = raw.length();
List<LineToken> result = new ArrayList<>();
int offset = 0;
for (String part : raw.split("\\s+")) {
if (part.isEmpty()) {
offset++;
continue;
}
int idx = raw.indexOf(part, offset);
if (idx < 0) idx = offset;
float tokenX = rawLen > 0 ? fragX + ((float) idx / rawLen) * fragWidth : fragX;
float tokenRight =
rawLen > 0
? fragX + ((float) (idx + part.length()) / rawLen) * fragWidth
: fragX + fragWidth;
result.add(new LineToken(part, tokenX, tokenRight, NUMERIC.matcher(part).matches()));
offset = idx + part.length();
}
return result;
}
// ── column grid ──────────────────────────────────────────────────────────────────────────────
/**
* Returns confirmed column right-edge positions — those appearing on ≥ {@value
* #COLUMN_MIN_FREQUENCY} × N anchor lines.
*/
private List<Float> buildColumnGrid(List<TokenizedLine> anchors) {
// bucket → set of line indices that contributed a numeric token to that bucket
Map<Integer, List<Integer>> bucketLines = new HashMap<>();
for (int i = 0; i < anchors.size(); i++) {
for (LineToken t : anchors.get(i).numeric()) {
int bucket = bucket(t.right());
bucketLines.computeIfAbsent(bucket, k -> new ArrayList<>()).add(i);
}
}
int minHits =
Math.max(MIN_TABLE_ROWS, (int) Math.ceil(anchors.size() * COLUMN_MIN_FREQUENCY));
// Confirmed buckets → average right-edge for that bucket
TreeMap<Integer, Float> confirmed = new TreeMap<>();
for (Map.Entry<Integer, List<Integer>> entry : bucketLines.entrySet()) {
// Count distinct lines
long distinctLines = entry.getValue().stream().distinct().count();
if (distinctLines >= minHits) {
double avg =
entry.getValue().stream()
.distinct() // weight each line equally regardless of token count
.mapToDouble(
lineIdx ->
avgRightEdgeForBucket(
anchors, lineIdx, entry.getKey()))
.average()
.orElse(entry.getKey() * (double) COLUMN_BUCKET_PT);
confirmed.put(entry.getKey(), (float) avg);
}
}
return new ArrayList<>(confirmed.values()); // already sorted by bucket (left to right)
}
/**
* Returns the average right-edge position of tokens in {@code line} whose bucket matches {@code
* targetBucket}, falling back to the bucket's nominal centre when no tokens match.
*/
private double avgRightEdgeForBucket(
List<TokenizedLine> anchors, int lineIdx, int targetBucket) {
return anchors.get(lineIdx).numeric().stream()
.filter(t -> bucket(t.right()) == targetBucket)
.mapToDouble(LineToken::right)
.average()
.orElse(targetBucket * (double) COLUMN_BUCKET_PT);
}
// ── grouping ─────────────────────────────────────────────────────────────────────────────────
/**
* Groups anchor lines into table candidates, including adjacent label rows; a gap &gt;
* MAX_GAP_FACTOR × modal spacing splits groups.
*/
private List<List<TokenizedLine>> groupRows(
List<TokenizedLine> all, List<Float> columnGrid, float modalSpacing) {
float maxGap = modalSpacing > 0 ? modalSpacing * MAX_GAP_FACTOR : 30f;
List<List<TokenizedLine>> groups = new ArrayList<>();
List<TokenizedLine> current = new ArrayList<>();
for (int i = 0; i < all.size(); i++) {
TokenizedLine tl = all.get(i);
boolean fits = tl.isAnchor() && matchesGrid(tl, columnGrid);
if (current.isEmpty()) {
if (fits) current.add(tl);
continue;
}
float gap =
tl.line().bounds().y()
- current.get(current.size() - 1).line().bounds().bottom();
if (gap > maxGap) {
groups.add(current);
current = new ArrayList<>();
if (fits) current.add(tl);
continue;
}
if (fits) {
current.add(tl);
} else if (!tl.line().text().isBlank()) {
// Include non-anchor lines (labels) only if they have text and are within
// proximity.
current.add(tl);
}
}
if (!current.isEmpty()) groups.add(current);
return groups.stream().filter(g -> hasEnoughAnchorRows(g, columnGrid)).toList();
}
private boolean hasEnoughAnchorRows(List<TokenizedLine> group, List<Float> columnGrid) {
return group.stream().filter(r -> r.isAnchor() && matchesGrid(r, columnGrid)).count()
>= MIN_TABLE_ROWS;
}
/** A line "matches" the grid when ≥ 60 % of its numeric tokens land in confirmed columns. */
private boolean matchesGrid(TokenizedLine tl, List<Float> columnGrid) {
if (tl.numeric().isEmpty()) return false;
long matches =
tl.numeric().stream()
.filter(t -> nearestColumnIndex(t.right(), columnGrid) >= 0)
.count();
return (double) matches / tl.numeric().size() >= 0.60;
}
private boolean hasInconsistentColumnMatch(TokenizedLine tl, List<Float> columnGrid) {
if (tl.numeric().isEmpty()) return false;
long hits =
tl.numeric().stream()
.filter(t -> nearestColumnIndex(t.right(), columnGrid) >= 0)
.count();
return (double) hits / tl.numeric().size() < 0.60;
}
// ── fragment assembly ────────────────────────────────────────────────────────────────────────
private Optional<TableFragment> buildFragment(
List<TokenizedLine> group, List<Float> columnGrid, int pageNumber, int tableIndex) {
long anchorCount =
group.stream().filter(r -> r.isAnchor() && matchesGrid(r, columnGrid)).count();
if (anchorCount < MIN_TABLE_ROWS) return Optional.empty();
List<String> warnings = new ArrayList<>();
List<List<String>> rawRows = new ArrayList<>();
List<TableRow> rows = new ArrayList<>();
for (int rowIdx = 0; rowIdx < group.size(); rowIdx++) {
TokenizedLine tl = group.get(rowIdx);
List<String> rawRow = buildRawRow(tl, columnGrid);
rawRows.add(Collections.unmodifiableList(rawRow));
rows.add(buildTableRow(rowIdx, tl, rawRow, columnGrid));
}
// Column count = 1 label column + confirmed numeric columns
int colCount = columnGrid.size() + 1;
Bounds bounds = computeGroupBounds(group);
float confidence = computeConfidence(group, columnGrid, warnings);
return Optional.of(
new TableFragment(
"tbl-la-p" + pageNumber + "-" + tableIndex,
pageNumber,
bounds,
List.of(),
Collections.unmodifiableList(rows),
Collections.unmodifiableList(rawRows),
colCount,
confidence,
Collections.unmodifiableList(warnings),
null));
}
/**
* Builds a raw row as a list of strings: index 0 = label text, indices 1..N = column values.
*/
private List<String> buildRawRow(TokenizedLine tl, List<Float> columnGrid) {
String[] cells = new String[columnGrid.size() + 1];
Arrays.fill(cells, "");
// Separate label tokens (those not landing in any confirmed column) from column tokens.
List<String> labelParts = new ArrayList<>();
for (LineToken token : tl.all()) {
int col = nearestColumnIndex(token.right(), columnGrid);
if (col >= 0 && token.numeric()) {
int cellIdx = col + 1;
cells[cellIdx] =
cells[cellIdx].isEmpty()
? token.text()
: cells[cellIdx] + " " + token.text();
} else {
labelParts.add(token.text());
}
}
cells[0] = String.join(" ", labelParts).trim();
return Arrays.asList(cells);
}
private TableRow buildTableRow(
int rowIdx, TokenizedLine tl, List<String> rawRow, List<Float> columnGrid) {
List<TableCell> cells = new ArrayList<>(rawRow.size());
// Label cell: use the line's full bounds as an approximation.
cells.add(TableCell.of(0, rawRow.get(0), tl.line().bounds()));
for (int col = 0; col < columnGrid.size(); col++) {
String text = col + 1 < rawRow.size() ? rawRow.get(col + 1) : "";
float right = columnGrid.get(col);
float left = col > 0 ? columnGrid.get(col - 1) : right - 50f;
Bounds cellBounds =
new Bounds(
left,
tl.line().bounds().y(),
right - left,
tl.line().bounds().height());
cells.add(TableCell.of(col + 1, text, cellBounds));
}
return new TableRow(rowIdx, Collections.unmodifiableList(cells));
}
// ── confidence scoring ───────────────────────────────────────────────────────────────────────
/**
* Heuristic score in [0.0, 0.85] (ceiling keeps results below Tabula lattice which starts at
* 1.0). Base 0.70; +0.05/col beyond 2 (max +0.10); +0.05 at ≥5 anchors, +0.05 at ≥8; 0.15 if
* &gt;30 % of anchors have inconsistent columns; 0.10 if non-anchors outnumber anchors.
*/
private float computeConfidence(
List<TokenizedLine> group, List<Float> columnGrid, List<String> warnings) {
float score = 0.70f;
long anchorCount =
group.stream().filter(r -> r.isAnchor() && matchesGrid(r, columnGrid)).count();
long totalRows = group.size();
// More columns
int extraCols = Math.min(columnGrid.size() - MIN_COLUMNS, 2);
score += extraCols * 0.05f;
// More anchor rows
if (anchorCount >= 5) score += 0.05f;
if (anchorCount >= 8) score += 0.05f;
// Inconsistent column matching
long inconsistent =
group.stream()
.filter(TokenizedLine::isAnchor)
.filter(tl -> hasInconsistentColumnMatch(tl, columnGrid))
.count();
if (inconsistent > anchorCount * 0.30) {
score -= 0.15f;
warnings.add(
"Column match inconsistent on "
+ inconsistent
+ "/"
+ anchorCount
+ " anchor rows");
}
// Label-heavy
long nonAnchor = totalRows - anchorCount;
if (nonAnchor > anchorCount) {
score -= 0.10f;
warnings.add(
"Non-anchor rows ("
+ nonAnchor
+ ") outnumber anchor rows ("
+ anchorCount
+ ")");
}
return Math.max(0f, Math.min(0.85f, score));
}
// ── utility ──────────────────────────────────────────────────────────────────────────────────
/**
* Returns the grid index nearest to {@code rightEdge}, or -1 if none is within {@value
* #COLUMN_MATCH_BUCKETS} buckets.
*/
private int nearestColumnIndex(float rightEdge, List<Float> grid) {
int nearest = -1;
float minDist = COLUMN_MATCH_BUCKETS * COLUMN_BUCKET_PT + 1f;
for (int i = 0; i < grid.size(); i++) {
float dist = Math.abs(rightEdge - grid.get(i));
if (dist < minDist) {
minDist = dist;
nearest = i;
}
}
return nearest;
}
private Bounds computeGroupBounds(List<TokenizedLine> group) {
return group.stream()
.map(tl -> tl.line().bounds())
.reduce(Bounds::merge)
.orElse(new Bounds(0, 0, 0, 0));
}
/** Modal gap between consecutive line edges, used to calibrate the group-split threshold. */
private float computeModalSpacing(List<RawLine> lines) {
if (lines.size() < 2) return 0f;
Map<Float, Long> freq = new HashMap<>();
for (int i = 1; i < lines.size(); i++) {
float gap = lines.get(i).bounds().y() - lines.get(i - 1).bounds().bottom();
if (gap > 0) freq.merge(Math.round(gap / 2f) * 2f, 1L, Long::sum);
}
return freq.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse(0f);
}
private static int bucket(float x) {
return Math.round(x / COLUMN_BUCKET_PT);
}
// ── private data types ───────────────────────────────────────────────────────────────────────
/** A word-level token with an approximate right-edge x-position. */
record LineToken(String text, float x, float right, boolean numeric) {}
/** A {@link RawLine} with tokens pre-computed; an "anchor" has ≥ 2 numeric tokens. */
record TokenizedLine(RawLine line, List<LineToken> all, List<LineToken> numeric) {
boolean isAnchor() {
return numeric.size() >= 2;
}
}
}
@@ -0,0 +1,139 @@
package stirling.software.SPDF.pdf.parser;
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Groups {@link TextFragment} objects into visual {@link RawLine}s using baseline proximity.
*
* <p>Fragments are on the same line when their baselines are within a font-size-derived tolerance.
* A new line starts whenever the horizontal gap exceeds an adaptive column-gap threshold ({@code
* max(effectiveWidth * COLUMN_GAP_RATIO, COLUMN_GAP_MIN_PT)}), splitting two-column text.
*/
@Service
@Slf4j
public class LineBuilder {
/** Baseline tolerance as a fraction of font size; 0.5 keeps mixed-size text on one line. */
private static final float BASELINE_TOLERANCE_FACTOR = 0.5f;
/** Absolute minimum tolerance so tiny font sizes don't collapse multi-line content. */
private static final float MIN_BASELINE_TOLERANCE = 2f;
/**
* Column-gap threshold as a fraction of page width; 0.10 clears tab stops but stays below
* two-column gutters.
*/
static final float COLUMN_GAP_RATIO = 0.10f;
/** Floor for the column-gap threshold so narrow pages don't over-split lines. */
static final float COLUMN_GAP_MIN_PT = 40f;
public List<RawLine> build(List<TextFragment> fragments, int pageNumber) {
if (fragments.isEmpty()) return List.of();
float effectiveWidth = inferEffectiveWidth(fragments);
float columnGapThreshold = Math.max(effectiveWidth * COLUMN_GAP_RATIO, COLUMN_GAP_MIN_PT);
log.debug(
"LineBuilder page {}: effectiveWidth={:.1f}pt, columnGapThreshold={:.1f}pt",
pageNumber,
effectiveWidth,
columnGapThreshold);
// Sort top-to-bottom first, then left-to-right within the same baseline band.
List<TextFragment> sorted =
fragments.stream()
.sorted(
Comparator.comparingDouble(TextFragment::baseline)
.thenComparingDouble(f -> f.bounds().x()))
.toList();
List<List<TextFragment>> groups = groupByBaseline(sorted, columnGapThreshold);
List<RawLine> lines = new ArrayList<>(groups.size());
for (int i = 0; i < groups.size(); i++) {
List<TextFragment> group =
groups.get(i).stream()
.sorted(Comparator.comparingDouble(f -> f.bounds().x()))
.toList();
Bounds lineBounds =
group.stream()
.map(TextFragment::bounds)
.reduce(Bounds::merge)
.orElse(new Bounds(0, 0, 0, 0));
lines.add(new RawLine("ln-p" + pageNumber + "-" + i, group, lineBounds, pageNumber));
}
return lines;
}
private List<List<TextFragment>> groupByBaseline(
List<TextFragment> sorted, float columnGapThreshold) {
List<List<TextFragment>> groups = new ArrayList<>();
List<TextFragment> current = new ArrayList<>();
float currentBaseline = Float.NaN;
for (TextFragment fragment : sorted) {
if (current.isEmpty()) {
current.add(fragment);
currentBaseline = fragment.baseline();
continue;
}
float maxFontSize =
Math.max(
fragment.fontSize(),
(float)
current.stream()
.mapToDouble(TextFragment::fontSize)
.max()
.orElse(0));
float tolerance =
Math.max(maxFontSize * BASELINE_TOLERANCE_FACTOR, MIN_BASELINE_TOLERANCE);
boolean sameBaseline = Math.abs(fragment.baseline() - currentBaseline) <= tolerance;
boolean columnGap = sameBaseline && hasColumnGap(fragment, current, columnGapThreshold);
if (sameBaseline && !columnGap) {
current.add(fragment);
// Anchor to the weighted mean baseline so long lines stay stable.
currentBaseline =
(currentBaseline * (current.size() - 1) + fragment.baseline())
/ current.size();
} else {
groups.add(current);
current = new ArrayList<>();
current.add(fragment);
currentBaseline = fragment.baseline();
}
}
if (!current.isEmpty()) groups.add(current);
return groups;
}
/**
* True when the gap from the rightmost fragment in {@code group} to {@code next} exceeds {@code
* threshold}.
*/
private static boolean hasColumnGap(
TextFragment next, List<TextFragment> group, float threshold) {
float lastRight = group.get(group.size() - 1).bounds().right();
return next.bounds().x() - lastRight > threshold;
}
/** Infers effective page width from the rightmost fragment right-edge plus a 10 % margin. */
private static float inferEffectiveWidth(List<TextFragment> fragments) {
double maxRight =
fragments.stream().mapToDouble(f -> f.bounds().right()).max().orElse(500.0);
return (float) maxRight * 1.10f;
}
}
@@ -0,0 +1,79 @@
package stirling.software.SPDF.pdf.parser;
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Runs the per-page ingestion pipeline: {@link WordExtractingStripper} → {@link LineBuilder} →
* {@link TableParser}, producing a {@link PdfModels.ParsedPage} per page. The caller owns the
* {@link PDDocument} lifecycle.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class PdfIngester {
private final LineBuilder lineBuilder;
private final TableParser tableParser;
public List<ParsedPage> parse(PDDocument document) throws IOException {
return parse(document, document.getNumberOfPages());
}
public List<ParsedPage> parse(PDDocument document, int maxPages) throws IOException {
int pageCount = Math.min(document.getNumberOfPages(), maxPages);
List<ParsedPage> pages = new ArrayList<>(pageCount);
long fragmentsMs = 0;
long tablesMs = 0;
long t0 = System.currentTimeMillis();
for (int p = 1; p <= pageCount; p++) {
long ft = System.currentTimeMillis();
List<TextFragment> fragments = extractFragments(document, p);
fragmentsMs += System.currentTimeMillis() - ft;
PDPage page = document.getPage(p - 1);
PDRectangle mediaBox = page.getMediaBox();
List<RawLine> lines = lineBuilder.build(fragments, p);
RawPage rawPage = new RawPage(p, mediaBox.getWidth(), mediaBox.getHeight(), lines);
long tt = System.currentTimeMillis();
List<TableFragment> tables = tableParser.parse(document, rawPage);
tablesMs += System.currentTimeMillis() - tt;
log.debug(
"Page {}: {} fragments → {} lines, {} table(s)",
p,
fragments.size(),
lines.size(),
tables.size());
pages.add(new ParsedPage(p, mediaBox.getWidth(), mediaBox.getHeight(), tables, lines));
}
log.info(
"[timing] parse pages={} total={}ms fragments={}ms tables={}ms",
pageCount,
System.currentTimeMillis() - t0,
fragmentsMs,
tablesMs);
return pages;
}
private List<TextFragment> extractFragments(PDDocument document, int pageNumber)
throws IOException {
WordExtractingStripper stripper = new WordExtractingStripper(pageNumber);
stripper.getText(document);
return stripper.getFragments();
}
}
@@ -0,0 +1,148 @@
package stirling.software.SPDF.pdf.parser;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.apache.pdfbox.pdmodel.PDDocument;
/**
* All PDF parser model types and the table-parser contract in one place.
*
* <p>Import as {@code import static stirling.software.SPDF.pdf.parser.PdfModels.*;} to use all
* nested types without qualification.
*/
public final class PdfModels {
private PdfModels() {}
// ── Geometry ──────────────────────────────────────────────────────────────
public record Bounds(float x, float y, float width, float height) {
public float right() {
return x + width;
}
public float bottom() {
return y + height;
}
public static Bounds merge(Bounds a, Bounds b) {
float x = Math.min(a.x, b.x);
float y = Math.min(a.y, b.y);
return new Bounds(
x, y, Math.max(a.right(), b.right()) - x, Math.max(a.bottom(), b.bottom()) - y);
}
}
// ── Text fragments and lines ──────────────────────────────────────────────
/**
* A contiguous run of text from a single PDF content-stream string operation. {@code baseline}
* is preserved separately from bounds for line-grouping — characters of different sizes on the
* same visual line share a baseline but differ in top Y.
*/
public record TextFragment(
String fragmentId,
String text,
Bounds bounds,
float baseline,
float fontSize,
String fontName,
boolean bold) {}
public record RawLine(
String lineId, List<TextFragment> fragments, Bounds bounds, int pageNumber) {
public String text() {
if (fragments.isEmpty()) return "";
StringBuilder sb = new StringBuilder();
TextFragment prev = null;
for (TextFragment f : fragments) {
if (prev != null) {
float gap = f.bounds().x() - prev.bounds().right();
float avgCharWidth = prev.bounds().width() / Math.max(prev.text().length(), 1);
if (gap > avgCharWidth * 0.5f) sb.append(' ');
}
sb.append(f.text());
prev = f;
}
return sb.toString();
}
public float dominantFontSize() {
return fragments.stream()
.collect(Collectors.groupingBy(TextFragment::fontSize, Collectors.counting()))
.entrySet()
.stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse(0f);
}
public boolean hasBold() {
return fragments.stream().anyMatch(TextFragment::bold);
}
}
public record RawPage(int pageNumber, float widthPt, float heightPt, List<RawLine> lines) {}
// ── Table model ───────────────────────────────────────────────────────────
/**
* A single cell within a {@link TableRow}. {@code colSpan} and {@code rowSpan} are always 1 in
* v1 — span detection is deferred but the fields keep the schema stable.
*/
public record TableCell(int colIndex, String text, Bounds bounds, int colSpan, int rowSpan) {
public static TableCell of(int colIndex, String text, Bounds bounds) {
return new TableCell(colIndex, text, bounds, 1, 1);
}
}
public record TableRow(int rowIndex, List<TableCell> cells) {}
/**
* A table as it appears on a single page. {@code headers} is empty in v1 — all rows are in
* {@code rows}. {@code rawRows} preserves exact Tabula text output for diagnostics. {@code
* confidence} is a heuristic score in [0.0, 1.0]. {@code continuedFromPage} is null in v1.
*/
public record TableFragment(
String tableId,
int pageNumber,
Bounds bounds,
List<TableRow> headers,
List<TableRow> rows,
List<List<String>> rawRows,
int columnCount,
float confidence,
List<String> warnings,
Integer continuedFromPage) {}
// ── Page output ───────────────────────────────────────────────────────────
public record ParsedPage(
int pageNumber,
float widthPt,
float heightPt,
List<TableFragment> tables,
List<RawLine> layoutLines) {}
// ── Parser contract ───────────────────────────────────────────────────────
/**
* Extracts tables from a single page of a PDF. The caller owns the document lifecycle;
* implementations must not close it.
*/
public interface TableParser {
/**
* @param document open PDF; must not be closed by the implementation
* @param rawPage page metadata and lines for the page to process
* @return zero or more table fragments found on the page, never null
*/
List<TableFragment> parse(PDDocument document, RawPage rawPage) throws IOException;
}
}
@@ -0,0 +1,256 @@
package stirling.software.SPDF.pdf.parser;
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import technology.tabula.ObjectExtractor;
import technology.tabula.Page;
import technology.tabula.RectangularTextContainer;
import technology.tabula.Table;
import technology.tabula.extractors.BasicExtractionAlgorithm;
import technology.tabula.extractors.ExtractionAlgorithm;
import technology.tabula.extractors.SpreadsheetExtractionAlgorithm;
/**
* Primary {@link TableParser} implementation using Tabula's lattice-mode extraction.
*
* <h3>Algorithm</h3>
*
* Uses {@link SpreadsheetExtractionAlgorithm} (lattice mode), which detects tables from ruled lines
* (horizontal and vertical PDF path operators). This is reliable for tables with visible borders.
* Borderless or whitespace-delimited tables will not be detected — that requires stream mode, which
* is deferred to a later iteration.
*
* <h3>Coordinate system</h3>
*
* Tabula normalises page coordinates so that (0,0) is the top-left corner and Y increases downward,
* matching the {@link Bounds} convention used throughout this parser. No coordinate transformation
* is needed when mapping Tabula cell bounds to {@link Bounds}.
*
* <h3>Known limitations</h3>
*
* <ul>
* <li>Borderless tables are not detected (use stream mode, deferred).
* <li>Colspan and rowspan are not detected; all cells report colSpan=1, rowSpan=1.
* <li>Header rows are not identified; all rows appear in {@code rows}, headers is empty.
* <li>Cross-page table linking is not performed; each page is independent.
* <li>Rotated tables (90°/270° pages) may produce incorrect bounds.
* </ul>
*/
@Service
@Slf4j
public class TabulaTableParser implements TableParser {
/** Lattice mode — reliable for tables with visible ruled borders. */
@Override
public List<TableFragment> parse(PDDocument document, RawPage rawPage) throws IOException {
return parseWithAlgorithm(
document, rawPage, new SpreadsheetExtractionAlgorithm(), "lattice");
}
/**
* Convenience overload for callers that only have a page number, not a full {@link RawPage}.
*/
public List<TableFragment> parse(PDDocument document, int pageNumber) throws IOException {
return parse(document, new RawPage(pageNumber, 0f, 0f, List.of()));
}
/** Stream mode — whitespace-based column detection for borderless tables. */
public List<TableFragment> parseStream(PDDocument document, RawPage rawPage)
throws IOException {
return parseWithAlgorithm(document, rawPage, new BasicExtractionAlgorithm(), "stream");
}
private List<TableFragment> parseWithAlgorithm(
PDDocument document, RawPage rawPage, ExtractionAlgorithm algorithm, String modeName)
throws IOException {
int pageNumber = rawPage.pageNumber();
List<Table> tabulaTables;
try {
// Do NOT use try-with-resources: ObjectExtractor.close() closes the underlying
// PDDocument, which we don't own. The extractor holds no resources of its own.
ObjectExtractor extractor = new ObjectExtractor(document);
Page page = extractor.extract(pageNumber);
tabulaTables = new ArrayList<>(algorithm.extract(page));
} catch (Exception e) {
log.warn(
"Tabula {} extraction failed on page {}: {}",
modeName,
pageNumber,
e.getMessage());
return List.of();
}
if (tabulaTables.isEmpty()) {
log.debug("Page {}: no tables detected by Tabula ({})", pageNumber, modeName);
return List.of();
}
log.debug(
"Page {}: Tabula ({}) detected {} table(s)",
pageNumber,
modeName,
tabulaTables.size());
List<TableFragment> fragments = new ArrayList<>(tabulaTables.size());
for (int i = 0; i < tabulaTables.size(); i++) {
fragments.add(toFragment(tabulaTables.get(i), pageNumber, i));
}
return fragments;
}
// ── private helpers ──────────────────────────────────────────────────────────────────────────
private TableFragment toFragment(Table table, int pageNumber, int tableIndex) {
List<List<RectangularTextContainer>> tabulaRows = table.getRows();
List<String> warnings = new ArrayList<>();
List<List<String>> rawRows = buildRawRows(tabulaRows);
int colCount = inferColumnCount(rawRows, warnings);
List<TableRow> rows = buildRows(tabulaRows, colCount, warnings);
float confidence = computeConfidence(rawRows, colCount, warnings);
Bounds bounds = tableBounds(table);
String tableId = "tbl-p" + pageNumber + "-" + tableIndex;
if (!warnings.isEmpty()) {
log.warn("Page {}, table {}: {}", pageNumber, tableIndex, warnings);
}
return new TableFragment(
tableId,
pageNumber,
bounds,
List.of(), // headers: deferred to v2
rows,
rawRows,
colCount,
confidence,
Collections.unmodifiableList(warnings),
null); // continuedFromPage: deferred to v2
}
private List<List<String>> buildRawRows(List<List<RectangularTextContainer>> tabulaRows) {
List<List<String>> rawRows = new ArrayList<>(tabulaRows.size());
for (List<RectangularTextContainer> tabulaRow : tabulaRows) {
List<String> cells = new ArrayList<>(tabulaRow.size());
for (RectangularTextContainer cell : tabulaRow) {
cells.add(normaliseText(cell.getText()));
}
rawRows.add(Collections.unmodifiableList(cells));
}
return rawRows;
}
private List<TableRow> buildRows(
List<List<RectangularTextContainer>> tabulaRows, int colCount, List<String> warnings) {
List<TableRow> rows = new ArrayList<>(tabulaRows.size());
for (int rowIdx = 0; rowIdx < tabulaRows.size(); rowIdx++) {
List<RectangularTextContainer> tabulaRow = tabulaRows.get(rowIdx);
List<TableCell> cells = new ArrayList<>(tabulaRow.size());
for (int colIdx = 0; colIdx < tabulaRow.size(); colIdx++) {
RectangularTextContainer c = tabulaRow.get(colIdx);
Bounds cellBounds =
new Bounds(
(float) c.getX(),
(float) c.getY(),
(float) c.getWidth(),
(float) c.getHeight());
cells.add(TableCell.of(colIdx, normaliseText(c.getText()), cellBounds));
}
if (tabulaRow.size() != colCount) {
warnings.add(
"Row "
+ rowIdx
+ " has "
+ tabulaRow.size()
+ " cells; expected "
+ colCount);
}
rows.add(new TableRow(rowIdx, Collections.unmodifiableList(cells)));
}
return rows;
}
/**
* The canonical column count for a table is the size of the widest row. Tabula can produce
* uneven rows when a cell's ruling lines are partially missing.
*/
private int inferColumnCount(List<List<String>> rawRows, List<String> warnings) {
if (rawRows.isEmpty()) return 0;
int max = rawRows.stream().mapToInt(List::size).max().orElse(0);
int mode =
rawRows.stream()
.collect(
java.util.stream.Collectors.groupingBy(
List::size, java.util.stream.Collectors.counting()))
.entrySet()
.stream()
.max(java.util.Map.Entry.comparingByValue())
.map(java.util.Map.Entry::getKey)
.orElse(0);
if (max != mode) {
warnings.add("Inconsistent column count: modal=" + mode + " max=" + max);
}
return mode > 0 ? mode : max;
}
/**
* Heuristic confidence score in [0.0, 1.0].
*
* <ul>
* <li>Starts at 1.0.
* <li>-0.3 if only one column (single-column tables are usually not real tables).
* <li>-0.1 per row with an inconsistent column count, capped at -0.4.
* <li>-0.3 if the empty-cell ratio across all cells exceeds 80%.
* </ul>
*/
private float computeConfidence(
List<List<String>> rawRows, int colCount, List<String> warnings) {
if (rawRows.isEmpty() || colCount == 0) return 0f;
float score = 1.0f;
if (colCount == 1) score -= 0.3f;
long inconsistentRows = rawRows.stream().filter(r -> r.size() != colCount).count();
score -= Math.min(inconsistentRows * 0.1f, 0.4f);
long totalCells = rawRows.stream().mapToLong(List::size).sum();
long emptyCells =
rawRows.stream().flatMap(Collection::stream).filter(String::isBlank).count();
if (totalCells > 0 && (float) emptyCells / totalCells > 0.8f) {
score -= 0.3f;
}
return Math.max(0f, Math.min(1f, score));
}
private Bounds tableBounds(Table table) {
return new Bounds(
(float) table.getX(),
(float) table.getY(),
(float) table.getWidth(),
(float) table.getHeight());
}
private String normaliseText(String raw) {
if (raw == null) return "";
// Tabula wraps multi-line cell content with \r\n — collapse to a single space.
return raw.replace("\r\n", " ").replace("\n", " ").replace("\r", " ").trim();
}
}
@@ -0,0 +1,113 @@
package stirling.software.SPDF.pdf.parser;
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
/**
* Extends {@link PDFTextStripper} to capture per-fragment geometry and font metadata.
*
* <p>Overrides {@link #writeString} to split each content-stream string into word-level {@link
* TextFragment}s with bounding boxes, baseline, font name, and bold flag. Coordinates are in
* PDFTextStripper space: (0,0) top-left, Y increases downward, {@code getY()} is the baseline.
*/
class WordExtractingStripper extends PDFTextStripper {
private final int targetPage;
private final List<TextFragment> fragments = new ArrayList<>();
private int fragmentIndex = 0;
WordExtractingStripper(int pageNumber) throws IOException {
this.targetPage = pageNumber;
setStartPage(pageNumber);
setEndPage(pageNumber);
setSortByPosition(true);
}
@Override
protected void startPage(PDPage page) throws IOException {
super.startPage(page);
fragments.clear();
fragmentIndex = 0;
}
@Override
protected void writeString(String text, List<TextPosition> textPositions) throws IOException {
if (text == null || text.isBlank()) return;
// Fast path: no whitespace → emit one fragment (most financial PDFs have each
// number as its own string operation, so this is the common case).
if (text.indexOf(' ') < 0) {
emitFragment(text, textPositions);
return;
}
// Per-word splitting requires 1:1 text-char to TextPosition correspondence.
// Fall back to one fragment when sizes differ (ligatures, encoding edge cases).
if (textPositions.size() != text.length()) {
emitFragment(text, textPositions);
return;
}
// Emit one TextFragment per whitespace-delimited word with accurate per-word bounds.
int start = 0;
for (int i = 0; i <= text.length(); i++) {
if (i == text.length() || text.charAt(i) == ' ') {
if (start < i) {
emitFragment(text.substring(start, i), textPositions.subList(start, i));
}
start = i + 1;
}
}
}
private void emitFragment(String text, List<TextPosition> positions) {
if (positions.isEmpty()) return;
float minX = Float.MAX_VALUE;
float minY = Float.MAX_VALUE;
float maxRight = -Float.MAX_VALUE;
float maxBaseline = -Float.MAX_VALUE;
TextPosition first = null;
for (TextPosition tp : positions) {
if (tp == null) continue;
if (first == null) first = tp;
float x = tp.getX();
// getY() is the baseline; top of character = getY() - getHeight().
float top = tp.getY() - tp.getHeight();
float right = x + tp.getWidth();
float baseline = tp.getY();
minX = Math.min(minX, x);
minY = Math.min(minY, top);
maxRight = Math.max(maxRight, right);
maxBaseline = Math.max(maxBaseline, baseline);
}
if (first == null) return;
PDFont font = first.getFont();
String fontName = font != null ? font.getName() : "";
boolean bold = fontName != null && fontName.toLowerCase().contains("bold");
// getHeight() gives the rendered glyph height, which is the most reliable visual size.
float fontSize = first.getHeight();
Bounds bounds = new Bounds(minX, minY, maxRight - minX, maxBaseline - minY);
String id = "tf-p" + targetPage + "-" + fragmentIndex++;
fragments.add(new TextFragment(id, text, bounds, maxBaseline, fontSize, fontName, bold));
}
List<TextFragment> getFragments() {
return Collections.unmodifiableList(fragments);
}
}
@@ -0,0 +1,153 @@
package stirling.software.SPDF.pdf.parser;
import static org.assertj.core.api.Assertions.assertThat;
import static stirling.software.SPDF.pdf.parser.PdfModels.*;
import java.util.List;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link LineAlignmentTableParser}, focused on the coincident-line merge logic and
* column-grid construction.
*/
class LineAlignmentTableParserTest {
private final LineAlignmentTableParser parser = new LineAlignmentTableParser();
// ── mergeCoincidentLines ─────────────────────────────────────────────────────────────────────
@Test
void mergeCoincidentLines_singleLine_unchanged() {
var lines = List.of(tokenized(rawLine(10f, 100f, "Revenue")));
assertThat(parser.mergeCoincidentLines(lines)).hasSize(1);
}
@Test
void mergeCoincidentLines_distinctYLines_unchanged() {
// Two lines at different y positions — must NOT be merged.
var lines =
List.of(
tokenized(rawLine(10f, 100f, "Revenue")),
tokenized(rawLine(10f, 115f, "Cost")));
assertThat(parser.mergeCoincidentLines(lines)).hasSize(2);
}
@Test
void mergeCoincidentLines_sameY_merged() {
// Simulates a financial-table row split by LineBuilder at the column gap:
// label fragment at x=72 → "Revenue"
// value fragment at x=350 → "1,234"
// Both have y=100. After merge they should form one TokenizedLine.
var label = rawLine(72f, 100f, "Revenue");
var value = rawLine(350f, 100f, "1,234");
var merged = parser.mergeCoincidentLines(List.of(tokenized(label), tokenized(value)));
assertThat(merged).hasSize(1);
// The merged line should contain tokens from both halves.
var tokens = merged.get(0).all();
assertThat(tokens.stream().map(t -> t.text()).toList())
.containsExactlyInAnyOrder("Revenue", "1,234");
}
@Test
void mergeCoincidentLines_sameY_mergedLineHasCorrectBounds() {
var label = rawLine(72f, 100f, "Revenue"); // 7 chars × 6pt = 42pt wide → right = 114
var value = rawLine(350f, 100f, "1,234"); // 5 chars × 6pt = 30pt wide → right = 380
var merged = parser.mergeCoincidentLines(List.of(tokenized(label), tokenized(value)));
var bounds = merged.get(0).line().bounds();
assertThat(bounds.x()).isEqualTo(72f);
assertThat(bounds.right()).isEqualTo(380f);
}
@Test
void mergeCoincidentLines_withinTolerance_merged() {
// Lines 1.5pt apart (within ROW_MERGE_TOLERANCE_PT = 2pt) should merge.
var a = rawLine(10f, 100.0f, "Alpha");
var b = rawLine(200f, 101.5f, "99");
var merged = parser.mergeCoincidentLines(List.of(tokenized(a), tokenized(b)));
assertThat(merged).hasSize(1);
}
@Test
void mergeCoincidentLines_beyondTolerance_notMerged() {
// Lines 3pt apart (beyond ROW_MERGE_TOLERANCE_PT = 2pt) should NOT merge.
var a = rawLine(10f, 100.0f, "Alpha");
var b = rawLine(200f, 103.0f, "99");
var merged = parser.mergeCoincidentLines(List.of(tokenized(a), tokenized(b)));
assertThat(merged).hasSize(2);
}
@Test
void mergeCoincidentLines_threeCoincident_allMerged() {
// Three fragments at the same y (e.g. wide financial table with two value columns).
var a = rawLine(72f, 100f, "Revenue");
var b = rawLine(300f, 100f, "1,234");
var c = rawLine(400f, 100f, "5,678");
var merged = parser.mergeCoincidentLines(List.of(tokenized(a), tokenized(b), tokenized(c)));
assertThat(merged).hasSize(1);
assertThat(merged.get(0).all()).hasSize(3);
}
@Test
void mergeCoincidentLines_coincidentPairFollowedByDistinctLine_twoGroups() {
var a = rawLine(72f, 100f, "Revenue");
var b = rawLine(350f, 100f, "1,234"); // same y as a → merges with a
var c = rawLine(10f, 115f, "Expenses"); // different y → stays separate
var merged = parser.mergeCoincidentLines(List.of(tokenized(a), tokenized(b), tokenized(c)));
assertThat(merged).hasSize(2);
}
@Test
void mergeCoincidentLines_numericAnchorStatus_correctAfterMerge() {
// After merging, the combined line should be an anchor (≥2 numeric tokens).
// "Revenue" alone → not an anchor. "1,234 567" alone → anchor.
// Merged → anchor with at least 2 numerics.
var label = rawLine(72f, 100f, "Revenue");
var values = rawLineMultiWord(350f, 100f, "1,234", 30f, "567", 30f);
var merged = parser.mergeCoincidentLines(List.of(tokenized(label), tokenized(values)));
assertThat(merged).hasSize(1);
assertThat(merged.get(0).isAnchor()).isTrue();
}
// ── helpers ──────────────────────────────────────────────────────────────────────────────────
/** Creates a RawLine with a single TextFragment of the given text at the given position. */
private static RawLine rawLine(float x, float y, String text) {
float width = text.length() * 6f; // ~6pt per char — rough but consistent
float height = 12f;
Bounds bounds = new Bounds(x, y, width, height);
TextFragment fragment =
new TextFragment("tf-test", text, bounds, y + height, 11f, "Helvetica", false);
return new RawLine("ln-test", List.of(fragment), bounds, 1);
}
/**
* Creates a RawLine with two TextFragments representing two words separated by a small gap.
* Used to simulate a values-only line with multiple numeric tokens.
*/
private static RawLine rawLineMultiWord(
float x, float y, String word1, float w1, String word2, float w2) {
float height = 12f;
Bounds b1 = new Bounds(x, y, w1, height);
Bounds b2 = new Bounds(x + w1 + 5f, y, w2, height);
TextFragment f1 = new TextFragment("tf-1", word1, b1, y + height, 11f, "Helvetica", false);
TextFragment f2 = new TextFragment("tf-2", word2, b2, y + height, 11f, "Helvetica", false);
Bounds lineBounds = new Bounds(x, y, x + w1 + 5f + w2 - x, height);
return new RawLine("ln-test", List.of(f1, f2), lineBounds, 1);
}
/** Tokenises a RawLine via the parser's own tokenise logic (package-private access). */
private LineAlignmentTableParser.TokenizedLine tokenized(RawLine line) {
return parser.tokenize(line);
}
}
-6
View File
@@ -87,12 +87,6 @@ dependencies {
implementation 'com.sun.xml.bind:jaxb-core:4.0.7'
implementation 'org.apache.poi:poi-ooxml:5.5.1'
// https://mvnrepository.com/artifact/technology.tabula/tabula
implementation ('technology.tabula:tabula:1.0.5') {
exclude group: 'org.slf4j', module: 'slf4j-simple'
exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on'
exclude group: 'com.google.code.gson', module: 'gson'
}
// CVE-2022-25647: Explicit gson 2.13.2 to prevent unsafe deserialization (tabula would pull 2.8.7)
implementation 'com.google.code.gson:gson:2.13.2'
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
@@ -4,13 +4,13 @@ import java.io.ByteArrayOutputStream;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVPrinter;
import org.apache.commons.csv.QuoteMode;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.ContentDisposition;
@@ -26,24 +26,21 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.CsvConversionResponse;
import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.SPDF.pdf.FlexibleCSVWriter;
import stirling.software.SPDF.pdf.parser.PdfModels.TableFragment;
import stirling.software.SPDF.pdf.parser.TabulaTableParser;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
import technology.tabula.ObjectExtractor;
import technology.tabula.Page;
import technology.tabula.Table;
import technology.tabula.extractors.SpreadsheetExtractionAlgorithm;
@ConvertApi
@Slf4j
@RequiredArgsConstructor
public class ExtractCSVController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TabulaTableParser tabulaTableParser;
@AutoJobPostMapping(value = "/pdf/csv", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@CsvConversionResponse
@@ -58,24 +55,23 @@ public class ExtractCSVController {
try (PDDocument document = pdfDocumentFactory.load(request)) {
List<Integer> pages = request.getPageNumbersList(document, true);
SpreadsheetExtractionAlgorithm sea = new SpreadsheetExtractionAlgorithm();
CSVFormat format =
CSVFormat.EXCEL.builder().setEscape('"').setQuoteMode(QuoteMode.ALL).build();
for (int pageNum : pages) {
try (ObjectExtractor extractor = new ObjectExtractor(document)) {
log.info("{}", pageNum);
Page page = extractor.extract(pageNum);
List<Table> tables = sea.extract(page);
log.info("{}", pageNum);
List<TableFragment> fragments = tabulaTableParser.parse(document, pageNum);
for (int i = 0; i < tables.size(); i++) {
StringWriter sw = new StringWriter();
FlexibleCSVWriter csvWriter = new FlexibleCSVWriter(format);
csvWriter.write(sw, Collections.singletonList(tables.get(i)));
String entryName = generateEntryName(baseName, pageNum, i + 1);
csvEntries.add(new CsvEntry(entryName, sw.toString()));
for (int i = 0; i < fragments.size(); i++) {
StringWriter sw = new StringWriter();
try (CSVPrinter printer = format.print(sw)) {
for (List<String> row : fragments.get(i).rawRows()) {
printer.printRecord(row);
}
}
csvEntries.add(
new CsvEntry(
generateEntryName(baseName, pageNum, i + 1), sw.toString()));
}
}
@@ -1,16 +0,0 @@
package stirling.software.SPDF.pdf;
import org.apache.commons.csv.CSVFormat;
import technology.tabula.writers.CSVWriter;
public class FlexibleCSVWriter extends CSVWriter {
public FlexibleCSVWriter() {
super();
}
public FlexibleCSVWriter(CSVFormat csvFormat) {
super(csvFormat);
}
}
@@ -20,6 +20,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.SPDF.pdf.parser.TabulaTableParser;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
@@ -27,6 +28,7 @@ import stirling.software.common.util.GeneralUtils;
class ExtractCSVControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TabulaTableParser tabulaTableParser;
@InjectMocks private ExtractCSVController controller;
@@ -1,25 +0,0 @@
package stirling.software.SPDF.pdf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.apache.commons.csv.CSVFormat;
import org.junit.jupiter.api.Test;
class FlexibleCSVWriterTest {
@Test
void testDefaultConstructor() {
FlexibleCSVWriter writer = new FlexibleCSVWriter();
assertNotNull(writer, "The FlexibleCSVWriter instance should not be null");
}
@Test
void testConstructorWithCSVFormat() {
CSVFormat csvFormat = CSVFormat.DEFAULT;
FlexibleCSVWriter writer = new FlexibleCSVWriter(csvFormat);
assertNotNull(
writer,
"The FlexibleCSVWriter instance should not be null when initialized with"
+ " CSVFormat");
}
}
-6
View File
@@ -59,12 +59,6 @@ dependencies {
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
// Tabula table extraction — used by MathAuditorOrchestrator
implementation ('technology.tabula:tabula:1.0.5') {
exclude group: 'org.slf4j', module: 'slf4j-simple'
exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on'
exclude group: 'com.google.code.gson', module: 'gson'
}
implementation 'com.google.code.gson:gson:2.13.2'
api 'io.micrometer:micrometer-registry-prometheus'
@@ -20,7 +20,8 @@ public enum AiWorkflowOutcome {
TOOL_CALL("tool_call"),
COMPLETED("completed"),
UNSUPPORTED_CAPABILITY("unsupported_capability"),
CANNOT_CONTINUE("cannot_continue");
CANNOT_CONTINUE("cannot_continue"),
GENERATE_FILE("generate_file");
private final String value;
@@ -4,6 +4,8 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -20,6 +22,14 @@ public class AiWorkflowResponse {
@Schema(description = "Answer returned by the AI workflow when applicable")
private String answer;
@JsonProperty("content")
@Schema(description = "Text content to package as a file (generate_file outcomes)")
private String generatedContent;
@JsonProperty("filename")
@Schema(description = "Desired output filename for generate_file outcomes")
private String generatedFilename;
@Schema(description = "Summary returned by the AI workflow when applicable")
private String summary;
@@ -1,17 +0,0 @@
package stirling.software.proprietary.pdf;
import org.apache.commons.csv.CSVFormat;
import technology.tabula.writers.CSVWriter;
/** Exposes Tabula's protected {@link CSVWriter#CSVWriter(CSVFormat)} constructor. */
public class FlexibleCSVWriter extends CSVWriter {
public FlexibleCSVWriter() {
super();
}
public FlexibleCSVWriter(CSVFormat csvFormat) {
super(csvFormat);
}
}
@@ -160,6 +160,7 @@ public class AiWorkflowService {
case TOOL_CALL -> onToolCall(response, filesById, listener);
case PLAN -> onPlan(response, filesById, request, listener);
case ANSWER -> onAnswer(response, filesById, request, listener);
case GENERATE_FILE -> onGenerateFile(response, listener);
case NOT_FOUND,
NEED_CLARIFICATION,
CANNOT_DO,
@@ -364,6 +365,29 @@ public class AiWorkflowService {
return new WorkflowState.Terminal(response);
}
private WorkflowState onGenerateFile(AiWorkflowResponse response, ProgressListener listener)
throws IOException {
String content = response.getGeneratedContent();
String filename = response.getGeneratedFilename();
if (content == null || filename == null || filename.isBlank()) {
return new WorkflowState.Terminal(
cannotContinue(
"AI engine returned generate_file without content or filename."));
}
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.PROCESSING));
String safeFilename = Filenames.toSimpleFileName(filename);
byte[] bytes = content.getBytes(java.nio.charset.StandardCharsets.UTF_8);
org.springframework.core.io.Resource resource =
new org.springframework.core.io.ByteArrayResource(bytes) {
@Override
public String getFilename() {
return safeFilename;
}
};
return new WorkflowState.Terminal(
buildCompletedResponse(response.getSummary(), List.of(resource), List.of(), null));
}
@SuppressWarnings("unchecked")
private WorkflowState runPlan(
List<Map<String, Object>> steps,
@@ -3,7 +3,6 @@ package stirling.software.proprietary.service;
import java.io.IOException;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@@ -12,6 +11,7 @@ import java.util.Set;
import java.util.stream.Collectors;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVPrinter;
import org.apache.commons.csv.QuoteMode;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
@@ -21,25 +21,30 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.pdf.parser.PdfIngester;
import stirling.software.SPDF.pdf.parser.PdfModels.ParsedPage;
import stirling.software.SPDF.pdf.parser.PdfModels.RawLine;
import stirling.software.SPDF.pdf.parser.PdfModels.TableFragment;
import stirling.software.SPDF.pdf.parser.PdfModels.TextFragment;
import stirling.software.SPDF.pdf.parser.TabulaTableParser;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.proprietary.model.api.ai.AiPdfContentType;
import stirling.software.proprietary.model.api.ai.AiWorkflowFileRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowTextSelection;
import stirling.software.proprietary.model.api.ai.FolioType;
import stirling.software.proprietary.pdf.FlexibleCSVWriter;
import technology.tabula.ObjectExtractor;
import technology.tabula.Page;
import technology.tabula.Table;
import technology.tabula.extractors.SpreadsheetExtractionAlgorithm;
@Slf4j
@Service
@RequiredArgsConstructor
public class PdfContentExtractor {
private final TabulaTableParser tabulaTableParser;
private final PdfIngester pdfIngester;
private static final int MAX_CHARACTERS_PER_PAGE = 4_000;
private static final int TEXT_PRESENCE_THRESHOLD = 20;
@@ -101,21 +106,21 @@ public class PdfContentExtractor {
* @return list of CSV strings (one per table), empty if no tables found
*/
public List<String> extractTablesAsCsv(PDDocument document, int pageNumber) throws IOException {
SpreadsheetExtractionAlgorithm sea = new SpreadsheetExtractionAlgorithm();
List<TableFragment> fragments = tabulaTableParser.parse(document, pageNumber);
if (fragments.isEmpty()) return List.of();
CSVFormat format =
CSVFormat.EXCEL.builder().setEscape('"').setQuoteMode(QuoteMode.ALL).build();
List<String> csvStrings = new ArrayList<>();
try (ObjectExtractor extractor = new ObjectExtractor(document)) {
Page tabulaPage = extractor.extract(pageNumber);
List<Table> tables = sea.extract(tabulaPage);
for (Table table : tables) {
StringWriter sw = new StringWriter();
FlexibleCSVWriter csvWriter = new FlexibleCSVWriter(format);
csvWriter.write(sw, Collections.singletonList(table));
csvStrings.add(sw.toString());
for (TableFragment fragment : fragments) {
StringWriter sw = new StringWriter();
try (CSVPrinter printer = format.print(sw)) {
for (List<String> row : fragment.rawRows()) {
printer.printRecord(row);
}
}
csvStrings.add(sw.toString());
}
return csvStrings;
}
@@ -183,6 +188,8 @@ public class PdfContentExtractor {
case PAGE_TEXT, FULL_TEXT ->
Optional.<PdfContentResult>ofNullable(
extractText(lf, fileReq, remainingPages, remainingCharacters));
case PAGE_LAYOUT ->
Optional.<PdfContentResult>ofNullable(extractPageLayout(lf, remainingPages));
default -> {
log.warn(
"Content type {} not yet implemented, skipping for {}",
@@ -207,6 +214,35 @@ public class PdfContentExtractor {
return extracted.isEmpty() ? null : buildExtractedFileText(lf.fileName(), extracted);
}
private PageLayoutFileResult extractPageLayout(LoadedFile lf, int maxPages) throws IOException {
List<ParsedPage> parsedPages = pdfIngester.parse(lf.document(), maxPages);
List<LayoutPage> pages = new ArrayList<>();
for (ParsedPage pp : parsedPages) {
if (pp.layoutLines().isEmpty()) continue;
List<LayoutLine> lines = new ArrayList<>();
for (RawLine rawLine : pp.layoutLines()) {
List<LayoutFragment> fragments = new ArrayList<>();
for (TextFragment tf : rawLine.fragments()) {
fragments.add(
new LayoutFragment(
tf.text(),
tf.bounds().x(),
tf.bounds().y(),
tf.bounds().width(),
tf.fontSize(),
tf.bold()));
}
lines.add(new LayoutLine(rawLine.bounds().y(), fragments));
}
pages.add(new LayoutPage(pp.pageNumber(), lines));
}
if (pages.isEmpty()) return null;
PageLayoutFileResult result = new PageLayoutFileResult();
result.setFileName(lf.fileName());
result.setPages(pages);
return result;
}
private WorkflowArtifact buildArtifact(ArtifactKind kind, List<PdfContentResult> results) {
return switch (kind) {
case EXTRACTED_TEXT -> {
@@ -214,10 +250,12 @@ public class PdfContentExtractor {
artifact.setFiles(results.stream().map(ExtractedFileText.class::cast).toList());
yield artifact;
}
case PAGE_LAYOUT -> {
PageLayoutArtifact artifact = new PageLayoutArtifact();
artifact.setFiles(results.stream().map(PageLayoutFileResult.class::cast).toList());
yield artifact;
}
case TOOL_REPORT ->
// TOOL_REPORT artifacts don't come from PDF content extraction — they're
// built by AiWorkflowService from tool-response metadata. Never reached
// from this code path; presence in the enum is to satisfy the switch.
throw new IllegalArgumentException(
"TOOL_REPORT artifacts are not produced by PdfContentExtractor");
};
@@ -331,6 +369,7 @@ public class PdfContentExtractor {
*/
enum ArtifactKind {
EXTRACTED_TEXT("extracted_text"),
PAGE_LAYOUT("page_layout"),
TOOL_REPORT("tool_report");
private final String value;
@@ -394,4 +433,40 @@ public class PdfContentExtractor {
this.report = report;
}
}
// Serialization contract with the Python engine — see PageLayoutArtifactContractTest.
/** One text fragment with its bounding-box geometry and font properties. */
record LayoutFragment(
String text, float x, float y, float width, float fontSize, boolean bold) {}
/** A visual line on the page: y-coordinate and all fragments on that line. */
record LayoutLine(float y, List<LayoutFragment> fragments) {}
/** All layout lines for a single page. */
record LayoutPage(int pageNumber, List<LayoutLine> lines) {}
/** Page layout data for one file, as a PdfContentResult. */
@Data
static final class PageLayoutFileResult implements PdfContentResult {
private String fileName;
private List<LayoutPage> pages = new ArrayList<>();
@Override
public ArtifactKind getArtifactKind() {
return ArtifactKind.PAGE_LAYOUT;
}
@Override
public int pagesConsumed() {
return pages.size();
}
}
/** Artifact carrying full spatial page layout for all input files. */
@Data
static final class PageLayoutArtifact implements WorkflowArtifact {
private final ArtifactKind kind = ArtifactKind.PAGE_LAYOUT;
private List<PageLayoutFileResult> files = new ArrayList<>();
}
}
@@ -408,6 +408,31 @@ class AiWorkflowServiceTest {
verify(internalApiClient, times(1)).post(eq(COMPRESS_ENDPOINT), any());
}
@Test
void generateFileStoresContentDirectlyWithoutToolCall() throws IOException {
MockMultipartFile input = pdf("report.pdf", "bytes");
stubOrchestrator(
"""
{
"outcome":"generate_file",
"content":"# Hello\\n\\nWorld",
"filename":"report-reconstruction.md",
"summary":"Reconstructed the document as a Markdown file."
}
""");
AtomicInteger ids = stubFileStorage();
AiWorkflowResponse result = service.orchestrate(requestFor(input, "convert to markdown"));
assertEquals(AiWorkflowOutcome.COMPLETED, result.getOutcome());
assertEquals(1, result.getResultFiles().size());
assertEquals("report-reconstruction.md", result.getResultFiles().get(0).getFileName());
assertEquals("file-1", result.getResultFiles().get(0).getFileId());
assertEquals(1, ids.get());
// No tool endpoint should be called — content goes directly to file storage.
verify(internalApiClient, never()).post(anyString(), any());
}
@Test
void toolCallWithoutEndpointFallsBackToCannotContinue() throws IOException {
MockMultipartFile input = pdf("input.pdf", "bytes");
@@ -0,0 +1,66 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
import stirling.software.proprietary.service.PdfContentExtractor.LayoutFragment;
import stirling.software.proprietary.service.PdfContentExtractor.LayoutLine;
import stirling.software.proprietary.service.PdfContentExtractor.LayoutPage;
import stirling.software.proprietary.service.PdfContentExtractor.PageLayoutArtifact;
import stirling.software.proprietary.service.PdfContentExtractor.PageLayoutFileResult;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
/**
* Contract test: verifies that {@link PageLayoutArtifact} serializes to the JSON field names that
* the Python engine expects in {@code engine/src/stirling/contracts/pdf_to_markdown.py}.
*
* <p>The companion Python test in {@code tests/test_pdf_to_markdown.py} deserializes the same JSON
* literal and asserts field values. If either side renames a field, one of these tests fails.
*/
class PageLayoutArtifactContractTest {
static final String CONTRACT_JSON =
"""
{"kind":"page_layout","files":[{"fileName":"test.pdf","pages":[{"pageNumber":1,"lines":[{"y":10.0,"fragments":[{"text":"Hello","x":1.0,"y":2.0,"width":30.0,"fontSize":12.0,"bold":true}]}]}]}]}""";
@Test
void pageLayoutArtifact_serialisesToExpectedJson() throws Exception {
LayoutFragment fragment = new LayoutFragment("Hello", 1.0f, 2.0f, 30.0f, 12.0f, true);
LayoutLine line = new LayoutLine(10.0f, List.of(fragment));
LayoutPage page = new LayoutPage(1, List.of(line));
PageLayoutFileResult fileResult = new PageLayoutFileResult();
fileResult.setFileName("test.pdf");
fileResult.setPages(List.of(page));
PageLayoutArtifact artifact = new PageLayoutArtifact();
artifact.setFiles(List.of(fileResult));
JsonNode json = new JsonMapper().valueToTree(artifact);
assertEquals("page_layout", json.get("kind").asText());
JsonNode file = json.get("files").get(0);
assertEquals("test.pdf", file.get("fileName").asText());
JsonNode pg = file.get("pages").get(0);
assertEquals(1, pg.get("pageNumber").asInt());
JsonNode ln = pg.get("lines").get(0);
assertEquals(10.0, ln.get("y").asDouble(), 0.001);
JsonNode frag = ln.get("fragments").get(0);
assertEquals("Hello", frag.get("text").asText());
assertEquals(1.0, frag.get("x").asDouble(), 0.001);
assertEquals(2.0, frag.get("y").asDouble(), 0.001);
assertEquals(30.0, frag.get("width").asDouble(), 0.001);
assertEquals(12.0, frag.get("fontSize").asDouble(), 0.001);
assertTrue(frag.get("bold").asBoolean());
}
}
View File
+2
View File
@@ -5,6 +5,7 @@ from .orchestrator import OrchestratorAgent
from .pdf_edit import PdfEditAgent, PdfEditParameterSelector, PdfEditPlanSelection
from .pdf_questions import PdfQuestionAgent
from .pdf_review import PdfReviewAgent
from .pdf_to_markdown import PdfToMarkdownAgent
from .user_spec import UserSpecAgent
__all__ = [
@@ -15,5 +16,6 @@ __all__ = [
"PdfEditPlanSelection",
"PdfQuestionAgent",
"PdfReviewAgent",
"PdfToMarkdownAgent",
"UserSpecAgent",
]
@@ -11,12 +11,14 @@ from pydantic_ai.tools import RunContext
from stirling.agents.pdf_edit import PdfEditAgent
from stirling.agents.pdf_questions import PdfQuestionAgent
from stirling.agents.pdf_review import PdfReviewAgent
from stirling.agents.pdf_to_markdown import PdfToMarkdownAgent
from stirling.agents.user_spec import UserSpecAgent
from stirling.contracts import (
AgentDraftWorkflowResponse,
ExtractedTextArtifact,
OrchestratorRequest,
OrchestratorResponse,
PageLayoutArtifact,
PdfEditResponse,
PdfQuestionOrchestrateResponse,
SupportedCapability,
@@ -25,6 +27,7 @@ from stirling.contracts import (
format_file_names,
)
from stirling.contracts.pdf_edit import EditPlanResponse
from stirling.contracts.pdf_to_markdown import PdfToMarkdownOrchestrateResponse
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
@@ -68,6 +71,11 @@ class OrchestratorAgent:
" feedback')."
),
),
ToolOutput(
self.delegate_pdf_to_markdown,
name="delegate_pdf_to_markdown",
description=("Delegate requests to reconstruct a PDF as a Markdown document."),
),
ToolOutput(
self.unsupported_capability,
name="unsupported_capability",
@@ -84,6 +92,8 @@ class OrchestratorAgent:
"Use delegate_pdf_review when the user wants the PDF returned with review"
" comments attached — anything like 'review this', 'annotate with comments',"
" 'leave feedback on the PDF'. "
"Use delegate_pdf_to_markdown for any request to convert a PDF to Markdown "
"or reconstruct its content as readable text. "
"Use unsupported_capability when the user asks about the assistant itself "
"or when none of the other outputs fit; supply a helpful message."
),
@@ -123,6 +133,8 @@ class OrchestratorAgent:
return await self._run_pdf_edit(request)
case SupportedCapability.AGENT_DRAFT:
return await self._run_agent_draft(request)
case SupportedCapability.PDF_TO_MARKDOWN:
return await self._run_pdf_to_markdown(request)
case (
SupportedCapability.ORCHESTRATE
| SupportedCapability.AGENT_REVISE
@@ -151,6 +163,12 @@ class OrchestratorAgent:
async def _run_agent_draft(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse:
return await UserSpecAgent(self.runtime).orchestrate(request)
async def delegate_pdf_to_markdown(self, ctx: RunContext[OrchestratorDeps]) -> PdfToMarkdownOrchestrateResponse:
return await self._run_pdf_to_markdown(ctx.deps.request)
async def _run_pdf_to_markdown(self, request: OrchestratorRequest) -> PdfToMarkdownOrchestrateResponse:
return await PdfToMarkdownAgent(self.runtime).orchestrate(request)
async def delegate_pdf_review(self, ctx: RunContext[OrchestratorDeps]) -> EditPlanResponse:
return await self._run_pdf_review(ctx.deps.request)
@@ -186,5 +204,10 @@ class OrchestratorAgent:
file_names = [f.file_name for f in artifact.files]
descriptions.append(f"- extracted_text: {total_pages} pages from {file_names}")
continue
if isinstance(artifact, PageLayoutArtifact):
total_pages = sum(len(f.pages) for f in artifact.files)
file_names = [f.file_name for f in artifact.files]
descriptions.append(f"- page_layout: {total_pages} pages from {file_names}")
continue
descriptions.append("- unknown artifact")
return "\n".join(descriptions)
@@ -0,0 +1,3 @@
from .agent import PdfToMarkdownAgent
__all__ = ["PdfToMarkdownAgent"]
@@ -0,0 +1,435 @@
"""PDF to Markdown Agent.
Converts a parsed PDF document into a single clean Markdown document, preserving
headings, paragraphs, and tables in reading order.
"""
from __future__ import annotations
import asyncio
import logging
import re
import time
from pydantic import BaseModel, Field
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.contracts import (
EditCannotDoResponse,
GenerateFileResponse,
NeedContentFileRequest,
NeedContentResponse,
OrchestratorRequest,
PdfContentType,
SupportedCapability,
format_conversation_history,
)
from stirling.contracts.pdf_to_markdown import (
PageLayout,
PageLayoutArtifact,
PdfToMarkdownCannotDoResponse,
PdfToMarkdownOrchestrateResponse,
PdfToMarkdownRequest,
PdfToMarkdownResponse,
PdfToMarkdownSuccessResponse,
)
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
# Warn when output tokens are close to the typical model output limit (~8192 for most
# configurations). The actual limit is model-specific; this threshold catches likely truncation.
_OUTPUT_TOKEN_TRUNCATION_THRESHOLD = 7500
# Chunking limits — keep each LLM call to a manageable payload size.
# Fragment count is the primary driver of JSON payload size (each fragment carries x/y/width/
# fontSize/bold metadata beyond its text). Page cap prevents low-text pages accumulating.
_MAX_CHUNK_FRAGMENTS = 1_000
_MAX_CHUNK_PAGES = 10
# Max concurrent LLM calls — limits API rate pressure on large documents.
_MAX_PARALLEL_CHUNKS = 3
# ── LLM output model ────────────────────────────────────────────────────────────────────────────
class _ReconstructionOutput(BaseModel):
markdown: str = Field(description="Full document reconstructed as clean Markdown.")
# ── Agent ────────────────────────────────────────────────────────────────────────────────────────
class PdfToMarkdownAgent:
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
self._sem = asyncio.Semaphore(_MAX_PARALLEL_CHUNKS)
self._reconstruct_agent = Agent(
model=runtime.smart_model,
output_type=NativeOutput(_ReconstructionOutput),
system_prompt=(
"You reconstruct PDF pages into clean Markdown from spatial fragment data.\n"
"Input: PAGE LAYOUT — per-fragment x/y/font data for structural analysis.\n\n"
"COLUMN DETECTION (for tables in page_layout):\n"
"- Look at the x-positions of fragments across 3+ consecutive lines.\n"
"- If fragments cluster at the same x-positions across multiple lines, those are table columns.\n"
"- Each distinct x-cluster is one column."
" Name them from the header row (the first line in the cluster).\n"
"- Do NOT merge values from different x-columns into one cell.\n\n"
"ROW DETECTION:\n"
"- Each unique y-coordinate (or group within 3pt) is one table row.\n"
"- Every line of layout data is its own row — do not merge rows.\n"
"- If a column has no fragment on a given y-row, that cell is empty.\n\n"
"TABLE RENDERING:\n"
"- Render as: | col1 | col2 | col3 |\n"
" | --- | --- | --- |\n"
" | val | val | val |\n"
"- One source row = one table row. Never collapse multiple rows into one.\n"
"- Preserve numeric values exactly (no rounding, no formatting changes).\n"
"- Bold cells: wrap with ** in the Markdown cell.\n"
"- CRITICAL: the separator row `| --- | --- |` appears EXACTLY ONCE per table, immediately\n"
" after the header row. NEVER put `| --- |` after a data row or between data rows.\n"
" NEVER put a blank line inside a table. All rows (header + data) must be consecutive.\n"
"- Do NOT produce a header-only table followed by a second table with the data rows.\n"
" One logical table = one markdown table block, with header, one separator, then all data.\n\n"
"GROUP HEADERS (label-only rows inside a table):\n"
"- A row is a group header when: the first column has text AND every numeric column is empty.\n"
"- Do NOT render group headers as table rows with empty cells.\n"
"- Break the table, emit the label as **bold text** on its own line,"
" then start a new table for the rows that follow.\n"
"- Example labels: 'Policy functions', 'Non-current assets'.\n\n"
"TOTAL AND SUBTOTAL ROWS:\n"
"- Detect rows whose first cell contains (case-insensitive):"
" total, subtotal, surplus, balance, net, sum.\n"
"- These rows have numeric content — they are NOT group headers.\n"
"- Render the entire row in bold: | **Total income** | **1,234** | **5,678** |\n"
"- Keep total rows attached to the group they summarise.\n\n"
"MULTI-LEVEL TABLES (year or period as a row label):\n"
"- Detect when a row contains only a single label (a year like '2010' or period like 'Q1 2023')"
" with no numeric content, followed by repeated metric rows.\n"
"- Do NOT render the year as a table row.\n"
"- Normalise: add 'Year' as the first column, 'Metric' as the second,"
" and repeat the year value on each metric row.\n\n"
"PROSE REGIONS:\n"
"- Lines where x-positions vary across lines (not repeating columns) are prose.\n"
"- Merge lines at the same x-level into paragraphs. Separate indented lines.\n\n"
"HEADINGS:\n"
"- A line is a heading when it is bold OR font_size ≥2pt above body.\n"
" CRITICAL EXCEPTION: a bold fragment is a TABLE HEADER CELL, not a document heading, when\n"
" the same y-row in page_layout contains other fragments at different x-positions.\n"
" Only classify a bold line as a document heading when it is the SOLE fragment on its y-row.\n"
" Example: 'Non-current assets' at y=120 with '2010'@x=350, '2009'@x=420, '2008'@x=490\n"
" → this is a table header row, NOT a heading. Render it as the first cell of the table.\n"
"- Use ## for section headings, ### for sub-headings. Use # only for the document title.\n\n"
"ORDERING:\n"
"- Process content top-to-bottom as it appears on the page.\n"
"- Interleave prose blocks and table blocks in page order.\n"
"- Do not move text that appears before a table to after it, or vice versa.\n\n"
"FIDELITY:\n"
"- Do NOT invent, summarise, or omit any content.\n"
"- Do NOT add commentary, metadata, or JSON — output Markdown only."
),
model_settings={
**runtime.smart_model_settings,
"temperature": 0.0,
"max_tokens": _OUTPUT_TOKEN_TRUNCATION_THRESHOLD,
},
)
async def orchestrate(self, request: OrchestratorRequest) -> PdfToMarkdownOrchestrateResponse:
"""Entry point for the orchestrator delegate.
First turn: requests PAGE_LAYOUT extraction from Java via NeedContentResponse.
Resume turn: runs the LLM reconstruction and returns a write-file plan step.
"""
layout_artifact = next(
(a for a in request.artifacts if isinstance(a, PageLayoutArtifact)),
None,
)
if layout_artifact is None:
return NeedContentResponse(
resume_with=SupportedCapability.PDF_TO_MARKDOWN,
reason="Page layout data is required to reconstruct the document.",
files=[
NeedContentFileRequest(file=f, content_types=[PdfContentType.PAGE_LAYOUT]) for f in request.files
],
max_pages=self.runtime.settings.max_pages,
max_characters=self.runtime.settings.max_characters,
)
page_layout = [page for entry in layout_artifact.files for page in entry.pages]
file_names = [f.name for f in request.files]
result = await self.handle(
PdfToMarkdownRequest(
user_message=request.user_message,
file_names=file_names,
conversation_history=request.conversation_history,
page_layout=page_layout,
)
)
if isinstance(result, PdfToMarkdownCannotDoResponse):
return EditCannotDoResponse(reason=result.reason)
base = file_names[0].rsplit(".", 1)[0] if file_names else "document"
return GenerateFileResponse(
content=result.markdown,
filename=f"{base}-reconstruction.md",
summary="Reconstructed the document as a Markdown file.",
)
async def handle(self, request: PdfToMarkdownRequest) -> PdfToMarkdownResponse:
total_fragments = sum(len(line.fragments) for page in request.page_layout for line in page.lines)
logger.info(
"[pdf-to-markdown] received layout-pages=%d fragments=%d",
len(request.page_layout),
total_fragments,
)
if not request.page_layout:
logger.warning("[pdf-to-markdown] no content extracted from document; returning cannot_do")
return PdfToMarkdownCannotDoResponse(
reason=(
"No content was extracted from the document. "
"The file may be a scanned image PDF with no readable text. "
"Try running OCR on the document first."
)
)
chunks = _build_page_chunks(request.page_layout)
logger.info("[pdf-to-markdown] chunks=%d (max %d in parallel)", len(chunks), _MAX_PARALLEL_CHUNKS)
if len(chunks) == 1:
return await self._reconstruct_chunk(request, chunks[0], chunk_num=1, total_chunks=1)
total = len(chunks)
results = await asyncio.gather(
*(
self._reconstruct_chunk(request, chunk, chunk_num=i + 1, total_chunks=total)
for i, chunk in enumerate(chunks)
)
)
markdown_parts: list[str] = []
for result in results:
if isinstance(result, PdfToMarkdownSuccessResponse) and result.markdown:
markdown_parts.append(result.markdown)
elif isinstance(result, PdfToMarkdownCannotDoResponse):
logger.warning("[pdf-to-markdown] chunk dropped: %s", result.reason)
if not markdown_parts:
return PdfToMarkdownCannotDoResponse(reason="The document could not be reconstructed. All chunks failed.")
logger.info("[pdf-to-markdown] assembly: %d/%d chunks produced output", len(markdown_parts), len(chunks))
return PdfToMarkdownSuccessResponse(markdown="\n\n".join(markdown_parts))
async def _reconstruct_chunk(
self,
request: PdfToMarkdownRequest,
pages: list[PageLayout],
chunk_num: int,
total_chunks: int,
) -> PdfToMarkdownResponse:
chunk_request = PdfToMarkdownRequest(
user_message=request.user_message,
file_names=request.file_names,
conversation_history=request.conversation_history,
page_layout=pages,
)
try:
async with self._sem:
return await self._reconstruct_document(chunk_request, chunk_num, total_chunks)
except Exception as e:
logger.error("[pdf-to-markdown] chunk %d/%d failed: %s", chunk_num, total_chunks, e, exc_info=True)
return PdfToMarkdownCannotDoResponse(
reason="The document could not be reconstructed. The AI model failed to process it."
)
async def _reconstruct_document(
self, request: PdfToMarkdownRequest, chunk_num: int = 1, total_chunks: int = 1
) -> PdfToMarkdownSuccessResponse:
content = _build_reconstruction_prompt(request)
logger.info("[timing] chunk %d/%d llm-call prompt-chars=%d", chunk_num, total_chunks, len(content))
t0 = time.monotonic()
result = await self._reconstruct_agent.run([content])
llm_ms = int((time.monotonic() - t0) * 1000)
output: _ReconstructionOutput = result.output
usage = result.usage()
logger.info(
"[timing] chunk %d/%d llm-done ms=%d input-tokens=%s output-tokens=%s markdown-chars=%d",
chunk_num,
total_chunks,
llm_ms,
usage.input_tokens,
usage.output_tokens,
len(output.markdown),
)
if usage.output_tokens and usage.output_tokens >= _OUTPUT_TOKEN_TRUNCATION_THRESHOLD:
logger.warning(
"[timing] chunk %d/%d output likely truncated (output-tokens=%d)",
chunk_num,
total_chunks,
usage.output_tokens,
)
markdown = _remove_extra_separators(_fix_markdown_tables(_merge_orphaned_table_rows(output.markdown)))
return PdfToMarkdownSuccessResponse(markdown=markdown)
# ── Chunking ────────────────────────────────────────────────────────────────────────────────────
def _build_page_chunks(pages: list[PageLayout]) -> list[list[PageLayout]]:
chunks: list[list[PageLayout]] = []
current: list[PageLayout] = []
current_fragments = 0
for page in pages:
page_fragments = sum(len(line.fragments) for line in page.lines)
fragment_full = current and current_fragments + page_fragments > _MAX_CHUNK_FRAGMENTS
page_full = len(current) >= _MAX_CHUNK_PAGES
if fragment_full or page_full:
chunks.append(current)
current = []
current_fragments = 0
current.append(page)
current_fragments += page_fragments
if current:
chunks.append(current)
return chunks
# ── Prompt builders (module-level, no state) ────────────────────────────────────────────────────
def _build_reconstruction_prompt(request: PdfToMarkdownRequest) -> str:
history = format_conversation_history(request.conversation_history)
file_names = ", ".join(request.file_names) if request.file_names else "Unknown files"
layout_section = _format_layout(request.page_layout)
return (
f"Files: {file_names}\n\n"
f"User request: {request.user_message}\n\n"
f"Conversation history:\n{history}\n\n"
"PAGE LAYOUT (structural source — x/y fragment positions):\n"
"Each line is: y=NNN | text@(x,y) fs=N text@(x,y) fs=N ...\n"
"- y=NNN is the vertical position (row). Lines close in y are the same visual row.\n"
"- x=NNN is the horizontal position (column). Consistent x across rows = a column.\n"
"- fs=N is font size. Larger = likely a heading.\n"
"- **bold** markers indicate bold text.\n\n"
f"{layout_section}"
)
# ── LLM output post-processing ──────────────────────────────────────────────────────────────────
def _fix_markdown_tables(markdown: str) -> str:
"""Remove blank lines between table rows produced by the LLM."""
lines = markdown.split("\n")
result: list[str] = []
i = 0
while i < len(lines):
result.append(lines[i])
if lines[i].strip().startswith("|"):
j = i + 1
while j < len(lines) and lines[j].strip() == "":
j += 1
if j < len(lines) and lines[j].strip().startswith("|"):
i = j
continue
i += 1
return "\n".join(result)
_SEP_CELL = re.compile(r"^:?-+:?$")
def _is_sep_row(line: str) -> bool:
"""Return True when a pipe row is a Markdown table separator (| --- | --- |)."""
stripped = line.strip()
if not stripped.startswith("|"):
return False
cells = [c.strip() for c in stripped.split("|") if c.strip()]
return bool(cells) and all(_SEP_CELL.match(c) for c in cells)
def _merge_orphaned_table_rows(markdown: str) -> str:
"""Merge pipe-row blocks that lack a separator into the preceding table.
When the LLM incorrectly breaks a table (e.g. on a false group-header), it emits
orphaned pipe rows with no header or separator. These are invalid markdown and get
merged back into the preceding table, discarding the intervening non-table content.
"""
lines = markdown.split("\n")
segments: list[tuple[str, list[str]]] = []
i = 0
while i < len(lines):
if lines[i].strip().startswith("|"):
block: list[str] = []
while i < len(lines) and lines[i].strip().startswith("|"):
block.append(lines[i])
i += 1
has_sep = any(_is_sep_row(row) for row in block)
segments.append(("table" if has_sep else "orphan", block))
else:
block = []
while i < len(lines) and not lines[i].strip().startswith("|"):
block.append(lines[i])
i += 1
segments.append(("prose", block))
result: list[tuple[str, list[str]]] = []
last_table_idx: int | None = None
for seg_type, seg_lines in segments:
if seg_type == "orphan":
if last_table_idx is not None:
result = result[: last_table_idx + 1]
result[-1] = ("table", result[-1][1] + seg_lines)
else:
result.append((seg_type, seg_lines))
else:
if seg_type == "table":
last_table_idx = len(result)
result.append((seg_type, seg_lines))
return "\n".join(line for _, seg_lines in result for line in seg_lines)
def _remove_extra_separators(markdown: str) -> str:
"""Within each contiguous table block, keep only the first separator row."""
lines = markdown.split("\n")
result: list[str] = []
seen_sep = False
for line in lines:
if not line.strip().startswith("|"):
seen_sep = False
result.append(line)
continue
if _is_sep_row(line):
if seen_sep:
continue
seen_sep = True
result.append(line)
return "\n".join(result)
# ── Formatting helpers (module-level, no state) ──────────────────────────────────────────────────
def _format_layout(pages: list[PageLayout]) -> str:
if not pages:
return "None"
parts: list[str] = []
for page in pages:
line_strs: list[str] = []
for line in page.lines:
frags = " ".join(
f"{'**' if f.bold else ''}{f.text}{'**' if f.bold else ''}@({f.x:.0f},{f.y:.0f}) fs={f.font_size:.0f}"
for f in line.fragments
)
line_strs.append(f"y={line.y:.0f} | {frags}")
parts.append(f"--- Page {page.page_number} ---\n" + "\n".join(line_strs))
return "\n\n".join(parts)
+22
View File
@@ -14,6 +14,7 @@ from .common import (
ArtifactKind,
ConversationMessage,
ExtractedFileText,
GenerateFileResponse,
MathAuditorToolReportArtifact,
NeedContentFileRequest,
NeedContentResponse,
@@ -87,6 +88,17 @@ from .pdf_questions import (
PdfQuestionResponse,
PdfQuestionTerminalResponse,
)
from .pdf_to_markdown import (
LayoutFragment,
LayoutLine,
PageLayout,
PageLayoutArtifact,
PageLayoutFileEntry,
PdfToMarkdownCannotDoResponse,
PdfToMarkdownOrchestrateResponse,
PdfToMarkdownRequest,
PdfToMarkdownResponse,
)
from .progress import (
ProgressEvent,
WholeDocCompressionRound,
@@ -114,6 +126,10 @@ __all__ = [
"CompletedExecutionAction",
"ConversationMessage",
"DeleteDocumentResponse",
"PdfToMarkdownCannotDoResponse",
"PdfToMarkdownOrchestrateResponse",
"PdfToMarkdownRequest",
"PdfToMarkdownResponse",
"Discrepancy",
"DiscrepancyKind",
"EditCannotDoResponse",
@@ -129,6 +145,7 @@ __all__ = [
"FolioType",
"format_conversation_history",
"format_file_names",
"GenerateFileResponse",
"HealthResponse",
"IngestDocumentRequest",
"IngestDocumentResponse",
@@ -139,7 +156,12 @@ __all__ = [
"NextExecutionAction",
"OrchestratorRequest",
"OrchestratorResponse",
"LayoutFragment",
"LayoutLine",
"Page",
"PageLayout",
"PageLayoutArtifact",
"PageLayoutFileEntry",
"PageRange",
"PageText",
"PdfCommentInstruction",
+16
View File
@@ -61,6 +61,7 @@ class WorkflowOutcome(StrEnum):
COMPLETED = "completed"
CANNOT_CONTINUE = "cannot_continue"
UNSUPPORTED_CAPABILITY = "unsupported_capability"
GENERATE_FILE = "generate_file"
class ArtifactKind(StrEnum):
@@ -70,6 +71,7 @@ class ArtifactKind(StrEnum):
"""
EXTRACTED_TEXT = "extracted_text"
PAGE_LAYOUT = "page_layout"
TOOL_REPORT = "tool_report"
@@ -89,6 +91,7 @@ class SupportedCapability(StrEnum):
AGENT_REVISE = "agent_revise"
AGENT_NEXT_ACTION = "agent_next_action"
MATH_AUDITOR_AGENT = "math_auditor_agent"
PDF_TO_MARKDOWN = "pdf_to_markdown"
class ConversationMessage(ApiModel):
@@ -200,6 +203,19 @@ class ToolOperationStep(ApiModel):
return self
class GenerateFileResponse(ApiModel):
"""Return generated text content directly to Java for file packaging.
Java converts the content string to bytes and stores it as a result file,
avoiding a round-trip through a write-file tool endpoint.
"""
outcome: Literal[WorkflowOutcome.GENERATE_FILE] = WorkflowOutcome.GENERATE_FILE
content: str
filename: str = Field(pattern=r"^[^/\\]+$", description="Output filename; no path separators.")
summary: str | None = None
def drop_unknown_tool_endpoints(value: Iterable[str | ToolEndpoint]) -> list[ToolEndpoint]:
"""Coerce inbound endpoint identifiers into `ToolEndpoint` members, dropping unknowns.
@@ -12,6 +12,7 @@ from .common import (
ArtifactKind,
ConversationMessage,
ExtractedFileText,
GenerateFileResponse,
NeedContentResponse,
NeedIngestResponse,
SupportedCapability,
@@ -22,6 +23,7 @@ from .common import (
from .execution import NextExecutionAction
from .pdf_edit import PdfEditTerminalResponse
from .pdf_questions import PdfQuestionTerminalResponse
from .pdf_to_markdown import PageLayoutArtifact
class ExtractedTextArtifact(ApiModel):
@@ -29,7 +31,10 @@ class ExtractedTextArtifact(ApiModel):
files: list[ExtractedFileText] = Field(default_factory=list)
WorkflowArtifact = Annotated[ExtractedTextArtifact | ToolReportArtifact, Field(discriminator="kind")]
WorkflowArtifact = Annotated[
ExtractedTextArtifact | PageLayoutArtifact | ToolReportArtifact,
Field(discriminator="kind"),
]
class OrchestratorRequest(ApiModel):
@@ -53,6 +58,7 @@ class UnsupportedCapabilityResponse(ApiModel):
type OrchestratorResponse = Annotated[
PdfEditTerminalResponse
| PdfQuestionTerminalResponse
| GenerateFileResponse
| NeedContentResponse
| NeedIngestResponse
| AgentDraftResponse
@@ -0,0 +1,105 @@
"""Contracts for the PDF to Markdown Agent.
The agent accepts a parsed document and returns a single Markdown document that
faithfully reconstructs the PDF content — headings, paragraphs, and tables in
reading order, using page_layout as the primary source of truth for structure.
Java extracts page layout via PdfIngester and returns it as a PageLayoutArtifact
through the orchestrator resume_with pattern.
"""
from __future__ import annotations
from typing import Annotated, Literal
from pydantic import Field
from stirling.models import ApiModel
from .common import ArtifactKind, ConversationMessage, GenerateFileResponse, NeedContentResponse
from .pdf_edit import EditCannotDoResponse
# ── Input: layout models (mirror Java's RawLine / TextFragment geometry) ────────────────────────
class LayoutFragment(ApiModel):
"""One text fragment with its bounding-box geometry and font properties."""
text: str
x: float
y: float
width: float
font_size: float
bold: bool
class LayoutLine(ApiModel):
"""A visual line on the page: one y-coordinate and all fragments on that line."""
y: float
fragments: list[LayoutFragment]
class PageLayout(ApiModel):
"""All layout lines for a single page, in top-to-bottom order."""
page_number: int
lines: list[LayoutLine]
# ── Artifact: page layout (produced by Java, consumed by orchestrate()) ──────────────────────────
class PageLayoutFileEntry(ApiModel):
"""Page layout data for one file, as extracted by Java's PdfIngester."""
file_name: str
pages: list[PageLayout] = Field(default_factory=list)
class PageLayoutArtifact(ApiModel):
"""Artifact carrying full spatial page layout for all input files."""
kind: Literal[ArtifactKind.PAGE_LAYOUT] = ArtifactKind.PAGE_LAYOUT
files: list[PageLayoutFileEntry] = Field(default_factory=list)
# ── Input: full request ──────────────────────────────────────────────────────────────────────────
class PdfToMarkdownRequest(ApiModel):
"""Request sent by Java after PdfIngester has parsed the document.
page_layout: per-fragment positional data from the original (y-sorted) line order.
Each fragment carries its x/y position, width, font size, and bold flag.
This is the primary source of truth for column detection and heading hierarchy.
"""
user_message: str
file_names: list[str] = Field(default_factory=list)
conversation_history: list[ConversationMessage] = Field(default_factory=list)
page_layout: list[PageLayout] = Field(default_factory=list)
# ── Output: response variants ────────────────────────────────────────────────────────────────────
class PdfToMarkdownSuccessResponse(ApiModel):
outcome: Literal["document_reconstructed"] = "document_reconstructed"
markdown: str
class PdfToMarkdownCannotDoResponse(ApiModel):
outcome: Literal["cannot_do"] = "cannot_do"
reason: str
type PdfToMarkdownResponse = Annotated[
PdfToMarkdownSuccessResponse | PdfToMarkdownCannotDoResponse,
Field(discriminator="outcome"),
]
type PdfToMarkdownOrchestrateResponse = Annotated[
GenerateFileResponse | EditCannotDoResponse | NeedContentResponse,
Field(discriminator="outcome"),
]
+138
View File
@@ -0,0 +1,138 @@
"""Tests for PDF to Markdown agent.
Two cases:
1. Narrative-only page: request validates and routes to reconstruction.
2. Mixed text + table page: layout with table region validates correctly.
"""
from __future__ import annotations
from stirling.contracts.pdf_to_markdown import (
LayoutFragment,
LayoutLine,
PageLayout,
PageLayoutArtifact,
PdfToMarkdownRequest,
PdfToMarkdownSuccessResponse,
)
def _frag(text: str, x: float, y: float, font_size: float = 10.0, bold: bool = False) -> LayoutFragment:
return LayoutFragment(text=text, x=x, y=y, width=float(len(text) * 6), font_size=font_size, bold=bold)
def _line(y: float, *frags: LayoutFragment) -> LayoutLine:
return LayoutLine(y=y, fragments=list(frags))
# ── Test 1: Narrative-only reconstruction ────────────────────────────────────────────────────────
# ── Contract test: Java serialization ↔ Python deserialization ──────────────────────────────────
# This JSON is also asserted field-by-field in PageLayoutArtifactContractTest.java.
# If either side renames a field, one of these tests fails.
_CONTRACT_JSON = (
'{"kind":"page_layout","files":[{"fileName":"test.pdf","pages":'
'[{"pageNumber":1,"lines":[{"y":10.0,"fragments":'
'[{"text":"Hello","x":1.0,"y":2.0,"width":30.0,"fontSize":12.0,"bold":true}]}]}]}]}'
)
def test_page_layout_artifact_deserialises_java_json() -> None:
artifact = PageLayoutArtifact.model_validate_json(_CONTRACT_JSON)
assert artifact.kind == "page_layout"
assert artifact.files[0].file_name == "test.pdf"
page = artifact.files[0].pages[0]
assert page.page_number == 1
line = page.lines[0]
assert line.y == 10.0
frag = line.fragments[0]
assert frag.text == "Hello"
assert frag.x == 1.0
assert frag.y == 2.0
assert frag.width == 30.0
assert frag.font_size == 12.0
assert frag.bold is True
def test_narrative_reconstruction_request_validates() -> None:
"""A prose-only page with no tables produces a valid PdfToMarkdownRequest."""
layout = PageLayout(
page_number=1,
lines=[
_line(72.0, _frag("Annual Report 2023", x=72.0, y=72.0, font_size=18.0, bold=True)),
_line(100.0, _frag("Our revenue grew significantly", x=72.0, y=100.0)),
_line(114.0, _frag("during the fiscal year ended", x=72.0, y=114.0)),
_line(128.0, _frag("December 31, 2023.", x=72.0, y=128.0)),
],
)
request = PdfToMarkdownRequest(
user_message="reconstruct this document",
page_layout=[layout],
)
assert len(request.page_layout) == 1
assert len(request.page_layout[0].lines) == 4
assert request.page_layout[0].lines[0].fragments[0].bold is True
assert request.page_layout[0].lines[0].fragments[0].font_size == 18.0
def test_narrative_reconstruction_response_validates() -> None:
"""PdfToMarkdownSuccessResponse accepts markdown and returns document_reconstructed outcome."""
response = PdfToMarkdownSuccessResponse(
markdown="# Annual Report 2023\n\nOur revenue grew significantly during the fiscal year.",
)
assert response.outcome == "document_reconstructed"
assert response.markdown.startswith("#")
# ── Test 2: Mixed text + table reconstruction ─────────────────────────────────────────────────────
def test_mixed_page_layout_validates() -> None:
"""A page with both prose lines and a table region produces a valid request."""
layout = PageLayout(
page_number=1,
lines=[
# Prose heading
_line(50.0, _frag("Projects in Development", x=72.0, y=50.0, font_size=14.0, bold=True)),
# Table header row
_line(
80.0,
_frag("Project Name", x=72.0, y=80.0, bold=True),
_frag("Location", x=200.0, y=80.0, bold=True),
_frag("Size (MW)", x=290.0, y=80.0, bold=True),
),
# Table data rows
_line(
95.0,
_frag("Chaplin Wind 1", x=72.0, y=95.0),
_frag("Saskatchewan", x=200.0, y=95.0),
_frag("177", x=290.0, y=95.0),
),
_line(
110.0,
_frag("Amherst Island 2", x=72.0, y=110.0),
_frag("Ontario", x=200.0, y=110.0),
_frag("75", x=290.0, y=110.0),
),
# Prose after table
_line(140.0, _frag("Notes:", x=72.0, y=140.0, bold=True)),
_line(154.0, _frag("1 PPA signed", x=85.0, y=154.0)),
],
)
request = PdfToMarkdownRequest(
user_message="markdown",
page_layout=[layout],
)
assert len(request.page_layout[0].lines) == 6
# Header line has 3 fragments at distinct x-positions (column detection)
header_line = request.page_layout[0].lines[1]
xs = [f.x for f in header_line.fragments]
assert xs == [72.0, 200.0, 290.0]
# Data rows have matching x-positions
data_row = request.page_layout[0].lines[2]
assert [f.x for f in data_row.fragments] == [72.0, 200.0, 290.0]
+1460 -1
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -71,9 +71,11 @@
"react-dom": "^19.1.1",
"react-easy-crop": "^5.5.6",
"react-i18next": "^15.7.3",
"react-markdown": "^9.0.3",
"react-rnd": "^10.5.2",
"react-router-dom": "^7.9.1",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1",
"signature_pad": "^5.0.4",
"smol-toml": "^1.4.2",
"tailwindcss": "^4.1.13",
@@ -0,0 +1,108 @@
import React, { useState } from "react";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() =>
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
})
}
style={{
position: "absolute",
top: 8,
right: 8,
background: copied
? "var(--mantine-color-green-0)"
: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 3,
cursor: "pointer",
fontSize: "0.7em",
padding: "2px 8px",
color: copied
? "var(--mantine-color-green-7)"
: "var(--mantine-color-gray-7)",
}}
>
{copied ? "✓ Copied" : "Copy"}
</button>
);
}
const components: Components = {
pre: ({ children }) => {
const codeText = React.isValidElement(children)
? String((children.props as { children?: unknown }).children ?? "")
: String(children ?? "");
return (
<div style={{ position: "relative", margin: "8px 0" }}>
<pre
style={{
background: "var(--mantine-color-gray-1)",
padding: "10px 52px 10px 14px",
borderRadius: 4,
overflowX: "auto",
fontSize: "0.85em",
margin: 0,
}}
>
{children}
</pre>
<CopyButton text={codeText} />
</div>
);
},
table: ({ children }) => (
<div style={{ overflowX: "auto", margin: "10px 0" }}>
<table
style={{
borderCollapse: "collapse",
width: "100%",
fontSize: "0.85em",
}}
>
{children}
</table>
</div>
),
th: ({ children, style }) => (
<th
style={{
border: "1px solid var(--mantine-color-gray-3)",
padding: "6px 10px",
background: "var(--mantine-color-gray-1)",
textAlign: "left",
fontWeight: 600,
whiteSpace: "nowrap",
...style,
}}
>
{children}
</th>
),
td: ({ children, style }) => (
<td
style={{
border: "1px solid var(--mantine-color-gray-3)",
padding: "5px 10px",
...style,
}}
>
{children}
</td>
),
};
export function renderMarkdown(content: string): React.ReactNode[] {
return [
<ReactMarkdown key="md" remarkPlugins={[remarkGfm]} components={components}>
{content}
</ReactMarkdown>,
];
}
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import {
Box,
Center,
@@ -12,153 +12,7 @@ import {
import { useTranslation } from "react-i18next";
import { formatFileSize } from "@app/utils/fileUtils";
// ─── Markdown renderer ────────────────────────────────────────────────────────
function renderInline(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
// Patterns: **bold**, *italic*, `code`, [link](url)
const re = /(\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\))/g;
let last = 0;
let match;
let key = 0;
while ((match = re.exec(text)) !== null) {
if (match.index > last) parts.push(text.slice(last, match.index));
if (match[2]) parts.push(<strong key={key++}>{match[2]}</strong>);
else if (match[3]) parts.push(<em key={key++}>{match[3]}</em>);
else if (match[4])
parts.push(
<code
key={key++}
style={{
background: "var(--mantine-color-gray-1)",
padding: "0 3px",
borderRadius: 3,
fontFamily: "monospace",
fontSize: "0.85em",
}}
>
{match[4]}
</code>,
);
else if (match[5])
parts.push(
<a
key={key++}
href={match[6]}
target="_blank"
rel="noopener noreferrer"
style={{ color: "var(--mantine-color-blue-6)" }}
>
{match[5]}
</a>,
);
last = match.index + match[0].length;
}
if (last < text.length) parts.push(text.slice(last));
return parts;
}
function renderMarkdown(text: string): React.ReactNode[] {
const lines = text.split("\n");
const elements: React.ReactNode[] = [];
let inCodeBlock = false;
let codeLines: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.startsWith("```")) {
if (inCodeBlock) {
elements.push(
<pre
key={i}
style={{
background: "var(--mantine-color-gray-1)",
padding: "8px 12px",
borderRadius: 4,
overflowX: "auto",
fontSize: "0.85em",
margin: "4px 0",
}}
>
<code>{codeLines.join("\n")}</code>
</pre>,
);
codeLines = [];
inCodeBlock = false;
} else {
inCodeBlock = true;
}
i++;
continue;
}
if (inCodeBlock) {
codeLines.push(line);
i++;
continue;
}
if (line.startsWith("### ")) {
elements.push(
<Text key={i} fw={600} size="md" mt="xs" mb={2}>
{renderInline(line.slice(4))}
</Text>,
);
} else if (line.startsWith("## ")) {
elements.push(
<Text key={i} fw={700} size="lg" mt="sm" mb={4}>
{renderInline(line.slice(3))}
</Text>,
);
} else if (line.startsWith("# ")) {
elements.push(
<Text key={i} fw={800} size="xl" mt="md" mb={6}>
{renderInline(line.slice(2))}
</Text>,
);
} else if (line.startsWith("- ") || line.startsWith("* ")) {
elements.push(
<Group key={i} gap={6} align="flex-start" style={{ paddingLeft: 16 }}>
<Text size="sm" style={{ lineHeight: 1.6, flexShrink: 0 }}>
{"\u2022"}
</Text>
<Text size="sm" style={{ lineHeight: 1.6 }}>
{renderInline(line.slice(2))}
</Text>
</Group>,
);
} else if (/^\d+\.\s/.test(line)) {
const num = line.match(/^(\d+)\.\s/)?.[1];
const rest = line.replace(/^\d+\.\s/, "");
elements.push(
<Group key={i} gap={6} align="flex-start" style={{ paddingLeft: 16 }}>
<Text size="sm" style={{ lineHeight: 1.6, flexShrink: 0 }}>
{num}.
</Text>
<Text size="sm" style={{ lineHeight: 1.6 }}>
{renderInline(rest)}
</Text>
</Group>,
);
} else if (line.trim() === "" || line === "---" || line === "***") {
elements.push(<Box key={i} style={{ height: 8 }} />);
} else {
elements.push(
<Text key={i} size="sm" style={{ lineHeight: 1.7 }}>
{renderInline(line)}
</Text>,
);
}
i++;
}
return elements;
}
// ─── Text / Markdown viewer ───────────────────────────────────────────────────
import { renderMarkdown } from "@app/components/viewer/nonpdf/MarkdownRenderer";
interface TextViewerProps {
file: File;
@@ -176,6 +30,13 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
}, [file]);
const lines = content?.split("\n") ?? [];
const renderedMarkdown = useMemo(
() =>
content !== null && isMarkdown && renderMd
? renderMarkdown(content)
: null,
[content, isMarkdown, renderMd],
);
return (
<Stack gap={0} style={{ height: "100%", flex: 1 }}>
@@ -222,9 +83,17 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
{t("viewer.nonPdf.loading")}
</Text>
</Center>
) : isMarkdown && renderMd ? (
<Box style={{ maxWidth: 800, margin: "0 auto", padding: "8px 0" }}>
{renderMarkdown(content)}
) : renderedMarkdown !== null ? (
<Box
style={{
maxWidth: 800,
margin: "0 auto",
padding: "20px 28px",
background: "#ffffff",
borderRadius: 6,
}}
>
{renderedMarkdown}
</Box>
) : (
<Box
@@ -240,8 +109,8 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
width: "100%",
}}
>
{lines.map((line, i) => (
<Box key={i} component="div" style={{ display: "table-row" }}>
{lines.map((line, idx) => (
<Box key={idx} component="div" style={{ display: "table-row" }}>
{showLineNumbers && (
<Box
component="span"
@@ -256,7 +125,7 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
minWidth: `${String(lines.length).length + 1}ch`,
}}
>
{i + 1}
{idx + 1}
</Box>
)}
<Box
@@ -266,7 +135,7 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
paddingLeft: showLineNumbers ? 12 : 0,
}}
>
{line || "\u00A0"}
{line || " "}
</Box>
</Box>
))}