mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Feature/pdf to markdown agent (#6271)
Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
James Brunton
parent
5b9ef852ab
commit
ece1bb6865
@@ -51,4 +51,12 @@ dependencies {
|
|||||||
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
|
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
|
||||||
api 'jakarta.mail:jakarta.mail-api:2.1.5'
|
api 'jakarta.mail:jakarta.mail-api:2.1.5'
|
||||||
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+528
@@ -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 >
|
||||||
|
* 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
|
||||||
|
* >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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+113
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+153
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,12 +87,6 @@ dependencies {
|
|||||||
implementation 'com.sun.xml.bind:jaxb-core:4.0.7'
|
implementation 'com.sun.xml.bind:jaxb-core:4.0.7'
|
||||||
implementation 'org.apache.poi:poi-ooxml:5.5.1'
|
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)
|
// 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 'com.google.code.gson:gson:2.13.2'
|
||||||
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
|
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
|
||||||
|
|||||||
+15
-19
@@ -4,13 +4,13 @@ import java.io.ByteArrayOutputStream;
|
|||||||
import java.io.StringWriter;
|
import java.io.StringWriter;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.zip.ZipEntry;
|
import java.util.zip.ZipEntry;
|
||||||
import java.util.zip.ZipOutputStream;
|
import java.util.zip.ZipOutputStream;
|
||||||
|
|
||||||
import org.apache.commons.csv.CSVFormat;
|
import org.apache.commons.csv.CSVFormat;
|
||||||
|
import org.apache.commons.csv.CSVPrinter;
|
||||||
import org.apache.commons.csv.QuoteMode;
|
import org.apache.commons.csv.QuoteMode;
|
||||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||||
import org.springframework.http.ContentDisposition;
|
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.config.swagger.CsvConversionResponse;
|
||||||
import stirling.software.SPDF.model.api.PDFWithPageNums;
|
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.AutoJobPostMapping;
|
||||||
import stirling.software.common.annotations.api.ConvertApi;
|
import stirling.software.common.annotations.api.ConvertApi;
|
||||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||||
import stirling.software.common.util.GeneralUtils;
|
import stirling.software.common.util.GeneralUtils;
|
||||||
import stirling.software.common.util.WebResponseUtils;
|
import stirling.software.common.util.WebResponseUtils;
|
||||||
|
|
||||||
import technology.tabula.ObjectExtractor;
|
|
||||||
import technology.tabula.Page;
|
|
||||||
import technology.tabula.Table;
|
|
||||||
import technology.tabula.extractors.SpreadsheetExtractionAlgorithm;
|
|
||||||
|
|
||||||
@ConvertApi
|
@ConvertApi
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ExtractCSVController {
|
public class ExtractCSVController {
|
||||||
|
|
||||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||||
|
private final TabulaTableParser tabulaTableParser;
|
||||||
|
|
||||||
@AutoJobPostMapping(value = "/pdf/csv", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@AutoJobPostMapping(value = "/pdf/csv", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
@CsvConversionResponse
|
@CsvConversionResponse
|
||||||
@@ -58,24 +55,23 @@ public class ExtractCSVController {
|
|||||||
|
|
||||||
try (PDDocument document = pdfDocumentFactory.load(request)) {
|
try (PDDocument document = pdfDocumentFactory.load(request)) {
|
||||||
List<Integer> pages = request.getPageNumbersList(document, true);
|
List<Integer> pages = request.getPageNumbersList(document, true);
|
||||||
SpreadsheetExtractionAlgorithm sea = new SpreadsheetExtractionAlgorithm();
|
|
||||||
CSVFormat format =
|
CSVFormat format =
|
||||||
CSVFormat.EXCEL.builder().setEscape('"').setQuoteMode(QuoteMode.ALL).build();
|
CSVFormat.EXCEL.builder().setEscape('"').setQuoteMode(QuoteMode.ALL).build();
|
||||||
|
|
||||||
for (int pageNum : pages) {
|
for (int pageNum : pages) {
|
||||||
try (ObjectExtractor extractor = new ObjectExtractor(document)) {
|
log.info("{}", pageNum);
|
||||||
log.info("{}", pageNum);
|
List<TableFragment> fragments = tabulaTableParser.parse(document, pageNum);
|
||||||
Page page = extractor.extract(pageNum);
|
|
||||||
List<Table> tables = sea.extract(page);
|
|
||||||
|
|
||||||
for (int i = 0; i < tables.size(); i++) {
|
for (int i = 0; i < fragments.size(); i++) {
|
||||||
StringWriter sw = new StringWriter();
|
StringWriter sw = new StringWriter();
|
||||||
FlexibleCSVWriter csvWriter = new FlexibleCSVWriter(format);
|
try (CSVPrinter printer = format.print(sw)) {
|
||||||
csvWriter.write(sw, Collections.singletonList(tables.get(i)));
|
for (List<String> row : fragments.get(i).rawRows()) {
|
||||||
|
printer.printRecord(row);
|
||||||
String entryName = generateEntryName(baseName, pageNum, i + 1);
|
}
|
||||||
csvEntries.add(new CsvEntry(entryName, sw.toString()));
|
|
||||||
}
|
}
|
||||||
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
@@ -20,6 +20,7 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.mock.web.MockMultipartFile;
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
|
||||||
import stirling.software.SPDF.model.api.PDFWithPageNums;
|
import stirling.software.SPDF.model.api.PDFWithPageNums;
|
||||||
|
import stirling.software.SPDF.pdf.parser.TabulaTableParser;
|
||||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||||
import stirling.software.common.util.GeneralUtils;
|
import stirling.software.common.util.GeneralUtils;
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ import stirling.software.common.util.GeneralUtils;
|
|||||||
class ExtractCSVControllerTest {
|
class ExtractCSVControllerTest {
|
||||||
|
|
||||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||||
|
@Mock private TabulaTableParser tabulaTableParser;
|
||||||
|
|
||||||
@InjectMocks private ExtractCSVController controller;
|
@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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -59,12 +59,6 @@ dependencies {
|
|||||||
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
|
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
|
||||||
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
|
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'
|
implementation 'com.google.code.gson:gson:2.13.2'
|
||||||
|
|
||||||
api 'io.micrometer:micrometer-registry-prometheus'
|
api 'io.micrometer:micrometer-registry-prometheus'
|
||||||
|
|||||||
+2
-1
@@ -20,7 +20,8 @@ public enum AiWorkflowOutcome {
|
|||||||
TOOL_CALL("tool_call"),
|
TOOL_CALL("tool_call"),
|
||||||
COMPLETED("completed"),
|
COMPLETED("completed"),
|
||||||
UNSUPPORTED_CAPABILITY("unsupported_capability"),
|
UNSUPPORTED_CAPABILITY("unsupported_capability"),
|
||||||
CANNOT_CONTINUE("cannot_continue");
|
CANNOT_CONTINUE("cannot_continue"),
|
||||||
|
GENERATE_FILE("generate_file");
|
||||||
|
|
||||||
private final String value;
|
private final String value;
|
||||||
|
|
||||||
|
|||||||
+10
@@ -4,6 +4,8 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -20,6 +22,14 @@ public class AiWorkflowResponse {
|
|||||||
@Schema(description = "Answer returned by the AI workflow when applicable")
|
@Schema(description = "Answer returned by the AI workflow when applicable")
|
||||||
private String answer;
|
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")
|
@Schema(description = "Summary returned by the AI workflow when applicable")
|
||||||
private String summary;
|
private String summary;
|
||||||
|
|
||||||
|
|||||||
-17
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+24
@@ -160,6 +160,7 @@ public class AiWorkflowService {
|
|||||||
case TOOL_CALL -> onToolCall(response, filesById, listener);
|
case TOOL_CALL -> onToolCall(response, filesById, listener);
|
||||||
case PLAN -> onPlan(response, filesById, request, listener);
|
case PLAN -> onPlan(response, filesById, request, listener);
|
||||||
case ANSWER -> onAnswer(response, filesById, request, listener);
|
case ANSWER -> onAnswer(response, filesById, request, listener);
|
||||||
|
case GENERATE_FILE -> onGenerateFile(response, listener);
|
||||||
case NOT_FOUND,
|
case NOT_FOUND,
|
||||||
NEED_CLARIFICATION,
|
NEED_CLARIFICATION,
|
||||||
CANNOT_DO,
|
CANNOT_DO,
|
||||||
@@ -364,6 +365,29 @@ public class AiWorkflowService {
|
|||||||
return new WorkflowState.Terminal(response);
|
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")
|
@SuppressWarnings("unchecked")
|
||||||
private WorkflowState runPlan(
|
private WorkflowState runPlan(
|
||||||
List<Map<String, Object>> steps,
|
List<Map<String, Object>> steps,
|
||||||
|
|||||||
+95
-20
@@ -3,7 +3,6 @@ package stirling.software.proprietary.service;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.StringWriter;
|
import java.io.StringWriter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -12,6 +11,7 @@ import java.util.Set;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import org.apache.commons.csv.CSVFormat;
|
import org.apache.commons.csv.CSVFormat;
|
||||||
|
import org.apache.commons.csv.CSVPrinter;
|
||||||
import org.apache.commons.csv.QuoteMode;
|
import org.apache.commons.csv.QuoteMode;
|
||||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||||
import org.apache.pdfbox.text.PDFTextStripper;
|
import org.apache.pdfbox.text.PDFTextStripper;
|
||||||
@@ -21,25 +21,30 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
|
|||||||
import com.fasterxml.jackson.annotation.JsonValue;
|
import com.fasterxml.jackson.annotation.JsonValue;
|
||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.ExceptionUtils;
|
||||||
import stirling.software.common.util.PdfUtils;
|
import stirling.software.common.util.PdfUtils;
|
||||||
import stirling.software.proprietary.model.api.ai.AiPdfContentType;
|
import stirling.software.proprietary.model.api.ai.AiPdfContentType;
|
||||||
import stirling.software.proprietary.model.api.ai.AiWorkflowFileRequest;
|
import stirling.software.proprietary.model.api.ai.AiWorkflowFileRequest;
|
||||||
import stirling.software.proprietary.model.api.ai.AiWorkflowTextSelection;
|
import stirling.software.proprietary.model.api.ai.AiWorkflowTextSelection;
|
||||||
import stirling.software.proprietary.model.api.ai.FolioType;
|
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
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class PdfContentExtractor {
|
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 MAX_CHARACTERS_PER_PAGE = 4_000;
|
||||||
|
|
||||||
private static final int TEXT_PRESENCE_THRESHOLD = 20;
|
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
|
* @return list of CSV strings (one per table), empty if no tables found
|
||||||
*/
|
*/
|
||||||
public List<String> extractTablesAsCsv(PDDocument document, int pageNumber) throws IOException {
|
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 format =
|
||||||
CSVFormat.EXCEL.builder().setEscape('"').setQuoteMode(QuoteMode.ALL).build();
|
CSVFormat.EXCEL.builder().setEscape('"').setQuoteMode(QuoteMode.ALL).build();
|
||||||
List<String> csvStrings = new ArrayList<>();
|
List<String> csvStrings = new ArrayList<>();
|
||||||
|
|
||||||
try (ObjectExtractor extractor = new ObjectExtractor(document)) {
|
for (TableFragment fragment : fragments) {
|
||||||
Page tabulaPage = extractor.extract(pageNumber);
|
StringWriter sw = new StringWriter();
|
||||||
List<Table> tables = sea.extract(tabulaPage);
|
try (CSVPrinter printer = format.print(sw)) {
|
||||||
|
for (List<String> row : fragment.rawRows()) {
|
||||||
for (Table table : tables) {
|
printer.printRecord(row);
|
||||||
StringWriter sw = new StringWriter();
|
}
|
||||||
FlexibleCSVWriter csvWriter = new FlexibleCSVWriter(format);
|
|
||||||
csvWriter.write(sw, Collections.singletonList(table));
|
|
||||||
csvStrings.add(sw.toString());
|
|
||||||
}
|
}
|
||||||
|
csvStrings.add(sw.toString());
|
||||||
}
|
}
|
||||||
return csvStrings;
|
return csvStrings;
|
||||||
}
|
}
|
||||||
@@ -183,6 +188,8 @@ public class PdfContentExtractor {
|
|||||||
case PAGE_TEXT, FULL_TEXT ->
|
case PAGE_TEXT, FULL_TEXT ->
|
||||||
Optional.<PdfContentResult>ofNullable(
|
Optional.<PdfContentResult>ofNullable(
|
||||||
extractText(lf, fileReq, remainingPages, remainingCharacters));
|
extractText(lf, fileReq, remainingPages, remainingCharacters));
|
||||||
|
case PAGE_LAYOUT ->
|
||||||
|
Optional.<PdfContentResult>ofNullable(extractPageLayout(lf, remainingPages));
|
||||||
default -> {
|
default -> {
|
||||||
log.warn(
|
log.warn(
|
||||||
"Content type {} not yet implemented, skipping for {}",
|
"Content type {} not yet implemented, skipping for {}",
|
||||||
@@ -207,6 +214,35 @@ public class PdfContentExtractor {
|
|||||||
return extracted.isEmpty() ? null : buildExtractedFileText(lf.fileName(), extracted);
|
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) {
|
private WorkflowArtifact buildArtifact(ArtifactKind kind, List<PdfContentResult> results) {
|
||||||
return switch (kind) {
|
return switch (kind) {
|
||||||
case EXTRACTED_TEXT -> {
|
case EXTRACTED_TEXT -> {
|
||||||
@@ -214,10 +250,12 @@ public class PdfContentExtractor {
|
|||||||
artifact.setFiles(results.stream().map(ExtractedFileText.class::cast).toList());
|
artifact.setFiles(results.stream().map(ExtractedFileText.class::cast).toList());
|
||||||
yield artifact;
|
yield artifact;
|
||||||
}
|
}
|
||||||
|
case PAGE_LAYOUT -> {
|
||||||
|
PageLayoutArtifact artifact = new PageLayoutArtifact();
|
||||||
|
artifact.setFiles(results.stream().map(PageLayoutFileResult.class::cast).toList());
|
||||||
|
yield artifact;
|
||||||
|
}
|
||||||
case TOOL_REPORT ->
|
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(
|
throw new IllegalArgumentException(
|
||||||
"TOOL_REPORT artifacts are not produced by PdfContentExtractor");
|
"TOOL_REPORT artifacts are not produced by PdfContentExtractor");
|
||||||
};
|
};
|
||||||
@@ -331,6 +369,7 @@ public class PdfContentExtractor {
|
|||||||
*/
|
*/
|
||||||
enum ArtifactKind {
|
enum ArtifactKind {
|
||||||
EXTRACTED_TEXT("extracted_text"),
|
EXTRACTED_TEXT("extracted_text"),
|
||||||
|
PAGE_LAYOUT("page_layout"),
|
||||||
TOOL_REPORT("tool_report");
|
TOOL_REPORT("tool_report");
|
||||||
|
|
||||||
private final String value;
|
private final String value;
|
||||||
@@ -394,4 +433,40 @@ public class PdfContentExtractor {
|
|||||||
this.report = report;
|
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<>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
@@ -408,6 +408,31 @@ class AiWorkflowServiceTest {
|
|||||||
verify(internalApiClient, times(1)).post(eq(COMPRESS_ENDPOINT), any());
|
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
|
@Test
|
||||||
void toolCallWithoutEndpointFallsBackToCannotContinue() throws IOException {
|
void toolCallWithoutEndpointFallsBackToCannotContinue() throws IOException {
|
||||||
MockMultipartFile input = pdf("input.pdf", "bytes");
|
MockMultipartFile input = pdf("input.pdf", "bytes");
|
||||||
|
|||||||
+66
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ from .orchestrator import OrchestratorAgent
|
|||||||
from .pdf_edit import PdfEditAgent, PdfEditParameterSelector, PdfEditPlanSelection
|
from .pdf_edit import PdfEditAgent, PdfEditParameterSelector, PdfEditPlanSelection
|
||||||
from .pdf_questions import PdfQuestionAgent
|
from .pdf_questions import PdfQuestionAgent
|
||||||
from .pdf_review import PdfReviewAgent
|
from .pdf_review import PdfReviewAgent
|
||||||
|
from .pdf_to_markdown import PdfToMarkdownAgent
|
||||||
from .user_spec import UserSpecAgent
|
from .user_spec import UserSpecAgent
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -15,5 +16,6 @@ __all__ = [
|
|||||||
"PdfEditPlanSelection",
|
"PdfEditPlanSelection",
|
||||||
"PdfQuestionAgent",
|
"PdfQuestionAgent",
|
||||||
"PdfReviewAgent",
|
"PdfReviewAgent",
|
||||||
|
"PdfToMarkdownAgent",
|
||||||
"UserSpecAgent",
|
"UserSpecAgent",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ from pydantic_ai.tools import RunContext
|
|||||||
from stirling.agents.pdf_edit import PdfEditAgent
|
from stirling.agents.pdf_edit import PdfEditAgent
|
||||||
from stirling.agents.pdf_questions import PdfQuestionAgent
|
from stirling.agents.pdf_questions import PdfQuestionAgent
|
||||||
from stirling.agents.pdf_review import PdfReviewAgent
|
from stirling.agents.pdf_review import PdfReviewAgent
|
||||||
|
from stirling.agents.pdf_to_markdown import PdfToMarkdownAgent
|
||||||
from stirling.agents.user_spec import UserSpecAgent
|
from stirling.agents.user_spec import UserSpecAgent
|
||||||
from stirling.contracts import (
|
from stirling.contracts import (
|
||||||
AgentDraftWorkflowResponse,
|
AgentDraftWorkflowResponse,
|
||||||
ExtractedTextArtifact,
|
ExtractedTextArtifact,
|
||||||
OrchestratorRequest,
|
OrchestratorRequest,
|
||||||
OrchestratorResponse,
|
OrchestratorResponse,
|
||||||
|
PageLayoutArtifact,
|
||||||
PdfEditResponse,
|
PdfEditResponse,
|
||||||
PdfQuestionOrchestrateResponse,
|
PdfQuestionOrchestrateResponse,
|
||||||
SupportedCapability,
|
SupportedCapability,
|
||||||
@@ -25,6 +27,7 @@ from stirling.contracts import (
|
|||||||
format_file_names,
|
format_file_names,
|
||||||
)
|
)
|
||||||
from stirling.contracts.pdf_edit import EditPlanResponse
|
from stirling.contracts.pdf_edit import EditPlanResponse
|
||||||
|
from stirling.contracts.pdf_to_markdown import PdfToMarkdownOrchestrateResponse
|
||||||
from stirling.services import AppRuntime
|
from stirling.services import AppRuntime
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -68,6 +71,11 @@ class OrchestratorAgent:
|
|||||||
" feedback')."
|
" feedback')."
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
ToolOutput(
|
||||||
|
self.delegate_pdf_to_markdown,
|
||||||
|
name="delegate_pdf_to_markdown",
|
||||||
|
description=("Delegate requests to reconstruct a PDF as a Markdown document."),
|
||||||
|
),
|
||||||
ToolOutput(
|
ToolOutput(
|
||||||
self.unsupported_capability,
|
self.unsupported_capability,
|
||||||
name="unsupported_capability",
|
name="unsupported_capability",
|
||||||
@@ -84,6 +92,8 @@ class OrchestratorAgent:
|
|||||||
"Use delegate_pdf_review when the user wants the PDF returned with review"
|
"Use delegate_pdf_review when the user wants the PDF returned with review"
|
||||||
" comments attached — anything like 'review this', 'annotate with comments',"
|
" comments attached — anything like 'review this', 'annotate with comments',"
|
||||||
" 'leave feedback on the PDF'. "
|
" '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 "
|
"Use unsupported_capability when the user asks about the assistant itself "
|
||||||
"or when none of the other outputs fit; supply a helpful message."
|
"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)
|
return await self._run_pdf_edit(request)
|
||||||
case SupportedCapability.AGENT_DRAFT:
|
case SupportedCapability.AGENT_DRAFT:
|
||||||
return await self._run_agent_draft(request)
|
return await self._run_agent_draft(request)
|
||||||
|
case SupportedCapability.PDF_TO_MARKDOWN:
|
||||||
|
return await self._run_pdf_to_markdown(request)
|
||||||
case (
|
case (
|
||||||
SupportedCapability.ORCHESTRATE
|
SupportedCapability.ORCHESTRATE
|
||||||
| SupportedCapability.AGENT_REVISE
|
| SupportedCapability.AGENT_REVISE
|
||||||
@@ -151,6 +163,12 @@ class OrchestratorAgent:
|
|||||||
async def _run_agent_draft(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse:
|
async def _run_agent_draft(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse:
|
||||||
return await UserSpecAgent(self.runtime).orchestrate(request)
|
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:
|
async def delegate_pdf_review(self, ctx: RunContext[OrchestratorDeps]) -> EditPlanResponse:
|
||||||
return await self._run_pdf_review(ctx.deps.request)
|
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]
|
file_names = [f.file_name for f in artifact.files]
|
||||||
descriptions.append(f"- extracted_text: {total_pages} pages from {file_names}")
|
descriptions.append(f"- extracted_text: {total_pages} pages from {file_names}")
|
||||||
continue
|
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")
|
descriptions.append("- unknown artifact")
|
||||||
return "\n".join(descriptions)
|
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)
|
||||||
@@ -14,6 +14,7 @@ from .common import (
|
|||||||
ArtifactKind,
|
ArtifactKind,
|
||||||
ConversationMessage,
|
ConversationMessage,
|
||||||
ExtractedFileText,
|
ExtractedFileText,
|
||||||
|
GenerateFileResponse,
|
||||||
MathAuditorToolReportArtifact,
|
MathAuditorToolReportArtifact,
|
||||||
NeedContentFileRequest,
|
NeedContentFileRequest,
|
||||||
NeedContentResponse,
|
NeedContentResponse,
|
||||||
@@ -87,6 +88,17 @@ from .pdf_questions import (
|
|||||||
PdfQuestionResponse,
|
PdfQuestionResponse,
|
||||||
PdfQuestionTerminalResponse,
|
PdfQuestionTerminalResponse,
|
||||||
)
|
)
|
||||||
|
from .pdf_to_markdown import (
|
||||||
|
LayoutFragment,
|
||||||
|
LayoutLine,
|
||||||
|
PageLayout,
|
||||||
|
PageLayoutArtifact,
|
||||||
|
PageLayoutFileEntry,
|
||||||
|
PdfToMarkdownCannotDoResponse,
|
||||||
|
PdfToMarkdownOrchestrateResponse,
|
||||||
|
PdfToMarkdownRequest,
|
||||||
|
PdfToMarkdownResponse,
|
||||||
|
)
|
||||||
from .progress import (
|
from .progress import (
|
||||||
ProgressEvent,
|
ProgressEvent,
|
||||||
WholeDocCompressionRound,
|
WholeDocCompressionRound,
|
||||||
@@ -114,6 +126,10 @@ __all__ = [
|
|||||||
"CompletedExecutionAction",
|
"CompletedExecutionAction",
|
||||||
"ConversationMessage",
|
"ConversationMessage",
|
||||||
"DeleteDocumentResponse",
|
"DeleteDocumentResponse",
|
||||||
|
"PdfToMarkdownCannotDoResponse",
|
||||||
|
"PdfToMarkdownOrchestrateResponse",
|
||||||
|
"PdfToMarkdownRequest",
|
||||||
|
"PdfToMarkdownResponse",
|
||||||
"Discrepancy",
|
"Discrepancy",
|
||||||
"DiscrepancyKind",
|
"DiscrepancyKind",
|
||||||
"EditCannotDoResponse",
|
"EditCannotDoResponse",
|
||||||
@@ -129,6 +145,7 @@ __all__ = [
|
|||||||
"FolioType",
|
"FolioType",
|
||||||
"format_conversation_history",
|
"format_conversation_history",
|
||||||
"format_file_names",
|
"format_file_names",
|
||||||
|
"GenerateFileResponse",
|
||||||
"HealthResponse",
|
"HealthResponse",
|
||||||
"IngestDocumentRequest",
|
"IngestDocumentRequest",
|
||||||
"IngestDocumentResponse",
|
"IngestDocumentResponse",
|
||||||
@@ -139,7 +156,12 @@ __all__ = [
|
|||||||
"NextExecutionAction",
|
"NextExecutionAction",
|
||||||
"OrchestratorRequest",
|
"OrchestratorRequest",
|
||||||
"OrchestratorResponse",
|
"OrchestratorResponse",
|
||||||
|
"LayoutFragment",
|
||||||
|
"LayoutLine",
|
||||||
"Page",
|
"Page",
|
||||||
|
"PageLayout",
|
||||||
|
"PageLayoutArtifact",
|
||||||
|
"PageLayoutFileEntry",
|
||||||
"PageRange",
|
"PageRange",
|
||||||
"PageText",
|
"PageText",
|
||||||
"PdfCommentInstruction",
|
"PdfCommentInstruction",
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class WorkflowOutcome(StrEnum):
|
|||||||
COMPLETED = "completed"
|
COMPLETED = "completed"
|
||||||
CANNOT_CONTINUE = "cannot_continue"
|
CANNOT_CONTINUE = "cannot_continue"
|
||||||
UNSUPPORTED_CAPABILITY = "unsupported_capability"
|
UNSUPPORTED_CAPABILITY = "unsupported_capability"
|
||||||
|
GENERATE_FILE = "generate_file"
|
||||||
|
|
||||||
|
|
||||||
class ArtifactKind(StrEnum):
|
class ArtifactKind(StrEnum):
|
||||||
@@ -70,6 +71,7 @@ class ArtifactKind(StrEnum):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
EXTRACTED_TEXT = "extracted_text"
|
EXTRACTED_TEXT = "extracted_text"
|
||||||
|
PAGE_LAYOUT = "page_layout"
|
||||||
TOOL_REPORT = "tool_report"
|
TOOL_REPORT = "tool_report"
|
||||||
|
|
||||||
|
|
||||||
@@ -89,6 +91,7 @@ class SupportedCapability(StrEnum):
|
|||||||
AGENT_REVISE = "agent_revise"
|
AGENT_REVISE = "agent_revise"
|
||||||
AGENT_NEXT_ACTION = "agent_next_action"
|
AGENT_NEXT_ACTION = "agent_next_action"
|
||||||
MATH_AUDITOR_AGENT = "math_auditor_agent"
|
MATH_AUDITOR_AGENT = "math_auditor_agent"
|
||||||
|
PDF_TO_MARKDOWN = "pdf_to_markdown"
|
||||||
|
|
||||||
|
|
||||||
class ConversationMessage(ApiModel):
|
class ConversationMessage(ApiModel):
|
||||||
@@ -200,6 +203,19 @@ class ToolOperationStep(ApiModel):
|
|||||||
return self
|
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]:
|
def drop_unknown_tool_endpoints(value: Iterable[str | ToolEndpoint]) -> list[ToolEndpoint]:
|
||||||
"""Coerce inbound endpoint identifiers into `ToolEndpoint` members, dropping unknowns.
|
"""Coerce inbound endpoint identifiers into `ToolEndpoint` members, dropping unknowns.
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from .common import (
|
|||||||
ArtifactKind,
|
ArtifactKind,
|
||||||
ConversationMessage,
|
ConversationMessage,
|
||||||
ExtractedFileText,
|
ExtractedFileText,
|
||||||
|
GenerateFileResponse,
|
||||||
NeedContentResponse,
|
NeedContentResponse,
|
||||||
NeedIngestResponse,
|
NeedIngestResponse,
|
||||||
SupportedCapability,
|
SupportedCapability,
|
||||||
@@ -22,6 +23,7 @@ from .common import (
|
|||||||
from .execution import NextExecutionAction
|
from .execution import NextExecutionAction
|
||||||
from .pdf_edit import PdfEditTerminalResponse
|
from .pdf_edit import PdfEditTerminalResponse
|
||||||
from .pdf_questions import PdfQuestionTerminalResponse
|
from .pdf_questions import PdfQuestionTerminalResponse
|
||||||
|
from .pdf_to_markdown import PageLayoutArtifact
|
||||||
|
|
||||||
|
|
||||||
class ExtractedTextArtifact(ApiModel):
|
class ExtractedTextArtifact(ApiModel):
|
||||||
@@ -29,7 +31,10 @@ class ExtractedTextArtifact(ApiModel):
|
|||||||
files: list[ExtractedFileText] = Field(default_factory=list)
|
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):
|
class OrchestratorRequest(ApiModel):
|
||||||
@@ -53,6 +58,7 @@ class UnsupportedCapabilityResponse(ApiModel):
|
|||||||
type OrchestratorResponse = Annotated[
|
type OrchestratorResponse = Annotated[
|
||||||
PdfEditTerminalResponse
|
PdfEditTerminalResponse
|
||||||
| PdfQuestionTerminalResponse
|
| PdfQuestionTerminalResponse
|
||||||
|
| GenerateFileResponse
|
||||||
| NeedContentResponse
|
| NeedContentResponse
|
||||||
| NeedIngestResponse
|
| NeedIngestResponse
|
||||||
| AgentDraftResponse
|
| 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"),
|
||||||
|
]
|
||||||
@@ -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]
|
||||||
Generated
+1460
-1
File diff suppressed because it is too large
Load Diff
@@ -71,9 +71,11 @@
|
|||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-easy-crop": "^5.5.6",
|
"react-easy-crop": "^5.5.6",
|
||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^15.7.3",
|
||||||
|
"react-markdown": "^9.0.3",
|
||||||
"react-rnd": "^10.5.2",
|
"react-rnd": "^10.5.2",
|
||||||
"react-router-dom": "^7.9.1",
|
"react-router-dom": "^7.9.1",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"signature_pad": "^5.0.4",
|
"signature_pad": "^5.0.4",
|
||||||
"smol-toml": "^1.4.2",
|
"smol-toml": "^1.4.2",
|
||||||
"tailwindcss": "^4.1.13",
|
"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 {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Center,
|
Center,
|
||||||
@@ -12,153 +12,7 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { formatFileSize } from "@app/utils/fileUtils";
|
import { formatFileSize } from "@app/utils/fileUtils";
|
||||||
|
import { renderMarkdown } from "@app/components/viewer/nonpdf/MarkdownRenderer";
|
||||||
// ─── 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 ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface TextViewerProps {
|
interface TextViewerProps {
|
||||||
file: File;
|
file: File;
|
||||||
@@ -176,6 +30,13 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
|
|||||||
}, [file]);
|
}, [file]);
|
||||||
|
|
||||||
const lines = content?.split("\n") ?? [];
|
const lines = content?.split("\n") ?? [];
|
||||||
|
const renderedMarkdown = useMemo(
|
||||||
|
() =>
|
||||||
|
content !== null && isMarkdown && renderMd
|
||||||
|
? renderMarkdown(content)
|
||||||
|
: null,
|
||||||
|
[content, isMarkdown, renderMd],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap={0} style={{ height: "100%", flex: 1 }}>
|
<Stack gap={0} style={{ height: "100%", flex: 1 }}>
|
||||||
@@ -222,9 +83,17 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
|
|||||||
{t("viewer.nonPdf.loading")}
|
{t("viewer.nonPdf.loading")}
|
||||||
</Text>
|
</Text>
|
||||||
</Center>
|
</Center>
|
||||||
) : isMarkdown && renderMd ? (
|
) : renderedMarkdown !== null ? (
|
||||||
<Box style={{ maxWidth: 800, margin: "0 auto", padding: "8px 0" }}>
|
<Box
|
||||||
{renderMarkdown(content)}
|
style={{
|
||||||
|
maxWidth: 800,
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: "20px 28px",
|
||||||
|
background: "#ffffff",
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderedMarkdown}
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box
|
<Box
|
||||||
@@ -240,8 +109,8 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{lines.map((line, i) => (
|
{lines.map((line, idx) => (
|
||||||
<Box key={i} component="div" style={{ display: "table-row" }}>
|
<Box key={idx} component="div" style={{ display: "table-row" }}>
|
||||||
{showLineNumbers && (
|
{showLineNumbers && (
|
||||||
<Box
|
<Box
|
||||||
component="span"
|
component="span"
|
||||||
@@ -256,7 +125,7 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
|
|||||||
minWidth: `${String(lines.length).length + 1}ch`,
|
minWidth: `${String(lines.length).length + 1}ch`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{i + 1}
|
{idx + 1}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box
|
<Box
|
||||||
@@ -266,7 +135,7 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
|
|||||||
paddingLeft: showLineNumbers ? 12 : 0,
|
paddingLeft: showLineNumbers ? 12 : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{line || "\u00A0"}
|
{line || " "}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user