Subversion Repositories SmartDukaan

Rev

Details | Last modification | View Log | RSS feed

Rev Author Line No. Line
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
}