| 37330 |
amit |
1 |
package com.smartdukaan.cron.offercircular;
|
|
|
2 |
|
|
|
3 |
import java.io.File;
|
|
|
4 |
import java.util.ArrayList;
|
|
|
5 |
import java.util.List;
|
|
|
6 |
import java.util.regex.Matcher;
|
|
|
7 |
import java.util.regex.Pattern;
|
|
|
8 |
|
|
|
9 |
import org.apache.logging.log4j.LogManager;
|
|
|
10 |
import org.apache.logging.log4j.Logger;
|
|
|
11 |
import org.apache.pdfbox.pdmodel.PDDocument;
|
|
|
12 |
import org.springframework.stereotype.Component;
|
|
|
13 |
|
|
|
14 |
import org.apache.pdfbox.text.PDFTextStripper;
|
|
|
15 |
|
|
|
16 |
import technology.tabula.ObjectExtractor;
|
|
|
17 |
import technology.tabula.Page;
|
|
|
18 |
import technology.tabula.RectangularTextContainer;
|
|
|
19 |
import technology.tabula.Table;
|
|
|
20 |
import technology.tabula.extractors.SpreadsheetExtractionAlgorithm;
|
|
|
21 |
|
|
|
22 |
/**
|
|
|
23 |
* Reads the nine verbatim columns out of a Pine Labs affordability circular PDF.
|
|
|
24 |
*
|
|
|
25 |
* Verified against the reference Python implementation (pdfplumber) on the Aug'26
|
|
|
26 |
* circular: 324 rows, six middle cells each, zero field differences.
|
|
|
27 |
*/
|
|
|
28 |
@Component
|
|
|
29 |
public class CircularExtractor {
|
|
|
30 |
|
|
|
31 |
private static final Logger LOGGER = LogManager.getLogger(CircularExtractor.class);
|
|
|
32 |
|
|
|
33 |
/** Every data row ends with two dd-MM-yyyy cells; that pair is the anchor. */
|
|
|
34 |
private static final Pattern DATE = Pattern.compile("^\\d{2}-\\d{2}-\\d{4}$");
|
|
|
35 |
|
|
|
36 |
/** A "# ..." note printed under the table, with the page it applies to. */
|
|
|
37 |
public static class Footnote {
|
|
|
38 |
private final int pageNo;
|
|
|
39 |
private final String text;
|
|
|
40 |
Footnote(int pageNo, String text) { this.pageNo = pageNo; this.text = text; }
|
|
|
41 |
public int getPageNo() { return pageNo; }
|
|
|
42 |
public String getText() { return text; }
|
|
|
43 |
@Override public String toString() { return "p" + pageNo + ": " + text; }
|
|
|
44 |
}
|
|
|
45 |
|
|
|
46 |
public static class Result {
|
|
|
47 |
private final List<CircularRow> rows = new ArrayList<>();
|
|
|
48 |
private final List<Footnote> footnotes = new ArrayList<>();
|
|
|
49 |
private final List<String> warnings = new ArrayList<>();
|
|
|
50 |
public List<CircularRow> getRows() { return rows; }
|
|
|
51 |
public List<Footnote> getFootnotes() { return footnotes; }
|
|
|
52 |
public List<String> getWarnings() { return warnings; }
|
|
|
53 |
}
|
|
|
54 |
|
|
|
55 |
/** Footnote lines start with '#' in the page text below the table. */
|
|
|
56 |
private static final Pattern FOOTNOTE = Pattern.compile("#\\s*(.+)");
|
|
|
57 |
|
|
|
58 |
public Result extract(File pdf) throws Exception {
|
|
|
59 |
Result result = new Result();
|
|
|
60 |
try (PDDocument document = PDDocument.load(pdf)) {
|
|
|
61 |
ObjectExtractor objects = new ObjectExtractor(document);
|
|
|
62 |
SpreadsheetExtractionAlgorithm lattice = new SpreadsheetExtractionAlgorithm();
|
|
|
63 |
|
|
|
64 |
for (int pageNo = 1; pageNo <= document.getNumberOfPages(); pageNo++) {
|
|
|
65 |
Page page = objects.extract(pageNo);
|
|
|
66 |
|
|
|
67 |
// tabula returns several overlapping candidates per page - a
|
|
|
68 |
// page-level bounding box plus the real grid. Keep whichever yields
|
|
|
69 |
// the most data rows. Do NOT dedupe by row content: the circular
|
|
|
70 |
// legitimately repeats identical rows, and content-deduping silently
|
|
|
71 |
// discards them.
|
|
|
72 |
List<CircularRow> best = new ArrayList<>();
|
|
|
73 |
for (Table table : lattice.extract(page)) {
|
|
|
74 |
List<CircularRow> candidate = readTable(pageNo, table, result);
|
|
|
75 |
if (candidate.size() > best.size()) {
|
|
|
76 |
best = candidate;
|
|
|
77 |
}
|
|
|
78 |
}
|
|
|
79 |
if (best.isEmpty()) {
|
|
|
80 |
result.getWarnings().add("page " + pageNo + ": no data rows found");
|
|
|
81 |
}
|
|
|
82 |
LOGGER.debug("circular page {} yielded {} data rows", pageNo, best.size());
|
|
|
83 |
result.getRows().addAll(best);
|
|
|
84 |
result.getFootnotes().addAll(readFootnotes(document, pageNo));
|
|
|
85 |
}
|
|
|
86 |
}
|
|
|
87 |
LOGGER.info("extracted {} circular rows and {} footnote(s) from {} ({} warning(s))",
|
|
|
88 |
result.getRows().size(), result.getFootnotes().size(), pdf.getName(),
|
|
|
89 |
result.getWarnings().size());
|
|
|
90 |
return result;
|
|
|
91 |
}
|
|
|
92 |
|
|
|
93 |
/**
|
|
|
94 |
* Footnotes sit in the page text BELOW the table, so tabula cannot see them -
|
|
|
95 |
* they need a plain text extraction of the same page.
|
|
|
96 |
*/
|
|
|
97 |
private List<Footnote> readFootnotes(PDDocument document, int pageNo) throws Exception {
|
|
|
98 |
PDFTextStripper stripper = new PDFTextStripper();
|
|
|
99 |
stripper.setStartPage(pageNo);
|
|
|
100 |
stripper.setEndPage(pageNo);
|
|
|
101 |
List<Footnote> notes = new ArrayList<>();
|
|
|
102 |
Matcher m = FOOTNOTE.matcher(stripper.getText(document));
|
|
|
103 |
while (m.find()) {
|
|
|
104 |
String text = m.group(1).trim();
|
|
|
105 |
if (!text.isEmpty()) {
|
|
|
106 |
notes.add(new Footnote(pageNo, text));
|
|
|
107 |
}
|
|
|
108 |
}
|
|
|
109 |
return notes;
|
|
|
110 |
}
|
|
|
111 |
|
|
|
112 |
private List<CircularRow> readTable(int pageNo, Table table, Result result) {
|
|
|
113 |
List<CircularRow> rows = new ArrayList<>();
|
|
|
114 |
int rowNo = 0;
|
|
|
115 |
for (List<RectangularTextContainer> raw : table.getRows()) {
|
|
|
116 |
List<String> cells = compact(raw);
|
|
|
117 |
if (cells.size() < 4) {
|
|
|
118 |
continue;
|
|
|
119 |
}
|
|
|
120 |
String start = cells.get(cells.size() - 2);
|
|
|
121 |
String end = cells.get(cells.size() - 1);
|
|
|
122 |
if (!DATE.matcher(start).matches() || !DATE.matcher(end).matches()) {
|
|
|
123 |
continue; // header, footnote or spacer row
|
|
|
124 |
}
|
|
|
125 |
List<String> middle = new ArrayList<>(cells.subList(1, cells.size() - 2));
|
|
|
126 |
if (middle.size() != CircularRow.MIDDLE_CELL_COUNT) {
|
|
|
127 |
result.getWarnings().add("page " + pageNo + ": row with "
|
|
|
128 |
+ middle.size() + " middle cells (expected "
|
|
|
129 |
+ CircularRow.MIDDLE_CELL_COUNT + "): " + cells);
|
|
|
130 |
continue;
|
|
|
131 |
}
|
|
|
132 |
rows.add(new CircularRow(pageNo, ++rowNo, cells.get(0), middle, start, end));
|
|
|
133 |
}
|
|
|
134 |
return rows;
|
|
|
135 |
}
|
|
|
136 |
|
|
|
137 |
/**
|
|
|
138 |
* Flattens a tabula row to non-empty, whitespace-normalised strings.
|
|
|
139 |
*
|
|
|
140 |
* Every empty cell is dropped, not just trailing ones. Pages carry different
|
|
|
141 |
* numbers of vertical rulings (page 8 of the Aug'26 circular has 48 against 34
|
|
|
142 |
* elsewhere), which interleaves blank cells between the real ones - including
|
|
|
143 |
* between the two date cells, which defeats any positional anchor. Empty cells
|
|
|
144 |
* are never meaningful in this document: after compaction a data row is exactly
|
|
|
145 |
* OEM label + six middle cells + two dates.
|
|
|
146 |
*
|
|
|
147 |
* Newlines are replaced with spaces because they are word-wrap points, not
|
|
|
148 |
* structure - they fall mid-product ("iPhone 17" / "Pro Max") and mid-variant
|
|
|
149 |
* ("(12/256GB)" / "(12/512GB)").
|
|
|
150 |
*/
|
|
|
151 |
private List<String> compact(List<RectangularTextContainer> raw) {
|
|
|
152 |
List<String> cells = new ArrayList<>();
|
|
|
153 |
for (RectangularTextContainer cell : raw) {
|
|
|
154 |
String text = cell.getText() == null ? "" : cell.getText();
|
|
|
155 |
text = text.replace('\r', ' ').replace('\n', ' ').trim().replaceAll("\\s+", " ");
|
|
|
156 |
if (!text.isEmpty()) {
|
|
|
157 |
cells.add(text);
|
|
|
158 |
}
|
|
|
159 |
}
|
|
|
160 |
return cells;
|
|
|
161 |
}
|
|
|
162 |
}
|