danielhjerresen commited on
Commit
b5a01dd
·
verified ·
1 Parent(s): b965d7d

Upload pdf_counter.py

Browse files
Files changed (1) hide show
  1. src/pdf_counter.py +326 -0
src/pdf_counter.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # pdf_counter.py
2
+ import re
3
+ from collections import Counter
4
+ import fitz
5
+
6
+
7
+ # ============================================================
8
+ # REGEX PATTERNS
9
+ # ============================================================
10
+ # These patterns are used to identify page numbers and
11
+ # running headers that should not be counted as content.
12
+
13
+ PAGE_NUMBER_RE = re.compile(
14
+ r"^\s*(side\s*)?\d+\s*(/|af|-)?\s*\d*\s*$",
15
+ re.IGNORECASE,
16
+ )
17
+
18
+ RUNNING_HEADER_RE = re.compile(
19
+ r"^\d+(\.\d+)+\.?\s+.+\s+([ivxlcdm]+|\d+)$",
20
+ re.IGNORECASE,
21
+ )
22
+
23
+
24
+ # ============================================================
25
+ # TEXT NORMALIZATION
26
+ # ============================================================
27
+ # Cleans extracted text by replacing multiple whitespace
28
+ # characters (spaces, tabs, line breaks) with a single space.
29
+ # This ensures consistent comparison and character counting.
30
+
31
+ def normalize(text: str) -> str:
32
+ return re.sub(r"\s+", " ", text).strip()
33
+
34
+
35
+ # ============================================================
36
+ # PDF EXTRACTION
37
+ # ============================================================
38
+ # Reads the PDF and extracts all text blocks from each page.
39
+ #
40
+ # For every block we store:
41
+ # - Page number
42
+ # - Original text
43
+ # - Lowercase version for comparisons
44
+ # - Vertical coordinates on the page
45
+ # - Page height
46
+ #
47
+ # The position data is later used to detect headers/footers.
48
+
49
+ def extract_pages(pdf_bytes: bytes):
50
+ doc = fitz.open(stream=pdf_bytes, filetype="pdf")
51
+ pages = []
52
+
53
+ for page_no, page in enumerate(doc, start=1):
54
+ blocks = []
55
+
56
+ for block in page.get_text("blocks", sort=True):
57
+ x0, y0, x1, y1, text, *_ = block
58
+
59
+ text = normalize(text)
60
+
61
+ if text:
62
+ blocks.append({
63
+ "page": page_no,
64
+ "text": text,
65
+ "text_key": text.lower(),
66
+ "y0": y0,
67
+ "y1": y1,
68
+ "height": page.rect.height,
69
+ })
70
+
71
+ pages.append(blocks)
72
+
73
+ return pages
74
+
75
+
76
+ # ============================================================
77
+ # PAGE NUMBER DETECTION
78
+ # ============================================================
79
+ # Checks whether a text block looks like a page number.
80
+
81
+ def is_page_number(text: str) -> bool:
82
+ return PAGE_NUMBER_RE.match(text) is not None
83
+
84
+
85
+ # ============================================================
86
+ # PAGE POSITION HELPERS
87
+ # ============================================================
88
+ # Determines whether a text block is located near the top
89
+ # or bottom of the page.
90
+ #
91
+ # Top area = top 15%
92
+ # Bottom area = bottom 15%
93
+ #
94
+ # These areas are where headers and footers are expected.
95
+
96
+ def is_top_area(block: dict) -> bool:
97
+ return block["y1"] <= block["height"] * 0.15
98
+
99
+
100
+ def is_bottom_area(block: dict) -> bool:
101
+ return block["y0"] >= block["height"] * 0.85
102
+
103
+
104
+ # ============================================================
105
+ # RUNNING HEADER DETECTION
106
+ # ============================================================
107
+ # Identifies chapter-style running headers such as:
108
+ #
109
+ # 2.1 Methods 12
110
+ # 4.3 Results iv
111
+ #
112
+ # They typically appear near the top of each page and
113
+ # follow a numbering pattern.
114
+ #
115
+ # "Chapter X" headings are excluded because they are often
116
+ # actual content rather than page headers.
117
+
118
+ def is_running_header(block: dict) -> bool:
119
+ text = block["text"]
120
+
121
+ if text.lower().startswith("chapter "):
122
+ return False
123
+
124
+ return is_top_area(block) and RUNNING_HEADER_RE.match(text) is not None
125
+
126
+
127
+ # ============================================================
128
+ # HEADER / FOOTER DETECTION
129
+ # ============================================================
130
+ # Finds text that appears repeatedly in the top or bottom
131
+ # regions of many pages.
132
+ #
133
+ # Repeated top text -> header candidate
134
+ # Repeated bottom text -> footer candidate
135
+ #
136
+ # A text must appear on at least min_ratio of pages before
137
+ # it is classified as a header/footer.
138
+ #
139
+ # Default: 50% of pages.
140
+
141
+ def detect_headers_and_footers(pages, min_ratio=0.5):
142
+ header_counter = Counter()
143
+ footer_counter = Counter()
144
+
145
+ running_headers = set()
146
+ page_numbers = set()
147
+
148
+ for blocks in pages:
149
+ headers_seen = set()
150
+ footers_seen = set()
151
+
152
+ for block in blocks:
153
+ text = block["text"]
154
+ text_key = block["text_key"]
155
+
156
+ # Collect page numbers separately
157
+ if is_page_number(text):
158
+ page_numbers.add(text)
159
+ continue
160
+
161
+ # Collect running headers separately
162
+ if is_running_header(block):
163
+ running_headers.add(text)
164
+ continue
165
+
166
+ # Potential header candidate
167
+ if is_top_area(block):
168
+ headers_seen.add(text_key)
169
+
170
+ # Potential footer candidate
171
+ if is_bottom_area(block):
172
+ footers_seen.add(text_key)
173
+
174
+ # Count once per page
175
+ header_counter.update(headers_seen)
176
+ footer_counter.update(footers_seen)
177
+
178
+ min_count = max(2, int(len(pages) * min_ratio))
179
+
180
+ detected_headers = {
181
+ text for text, count in header_counter.items()
182
+ if count >= min_count
183
+ }
184
+
185
+ detected_footers = {
186
+ text for text, count in footer_counter.items()
187
+ if count >= min_count
188
+ }
189
+
190
+ return (
191
+ detected_headers,
192
+ detected_footers,
193
+ running_headers,
194
+ page_numbers,
195
+ )
196
+
197
+
198
+ # ============================================================
199
+ # CHARACTER COUNTING ENGINE
200
+ # ============================================================
201
+ # Main workflow:
202
+ #
203
+ # 1. Extract all text blocks from the PDF.
204
+ # 2. Detect repeated headers and footers.
205
+ # 3. Detect page numbers.
206
+ # 4. Remove unwanted elements.
207
+ # 5. Count characters in remaining content.
208
+ # 6. Return detailed results and diagnostics.
209
+
210
+ def count_characters(
211
+ pdf_bytes: bytes,
212
+ excluded_pages: set[int] | None = None,
213
+ remove_headers: bool = True,
214
+ remove_footers: bool = True,
215
+ remove_page_numbers: bool = True,
216
+ ):
217
+ excluded_pages = excluded_pages or set()
218
+
219
+ # Extract all page data
220
+ pages = extract_pages(pdf_bytes)
221
+
222
+ # Detect recurring elements
223
+ (
224
+ detected_headers,
225
+ detected_footers,
226
+ running_headers,
227
+ detected_page_numbers,
228
+ ) = detect_headers_and_footers(pages)
229
+
230
+ included_text_parts = []
231
+ page_results = []
232
+ removed_items = []
233
+
234
+ # Process each page individually
235
+ for page_no, blocks in enumerate(pages, start=1):
236
+
237
+ # Skip pages excluded by the user
238
+ if page_no in excluded_pages:
239
+ page_results.append({
240
+ "Side": page_no,
241
+ "Tegn": 0,
242
+ "Status": "Fravalgt",
243
+ })
244
+ continue
245
+
246
+ kept_text = []
247
+
248
+ # Evaluate every text block
249
+ for block in blocks:
250
+ text = block["text"]
251
+ text_key = block["text_key"]
252
+
253
+ # Remove page numbers
254
+ if remove_page_numbers and is_page_number(text):
255
+ removed_items.append({
256
+ "Side": page_no,
257
+ "Type": "Sidetal",
258
+ "Tekst": text,
259
+ })
260
+ continue
261
+
262
+ # Remove repeated headers
263
+ if remove_headers and text_key in detected_headers:
264
+ removed_items.append({
265
+ "Side": page_no,
266
+ "Type": "Sidehoved",
267
+ "Tekst": text,
268
+ })
269
+ continue
270
+
271
+ # Remove running chapter headers
272
+ if remove_headers and is_running_header(block):
273
+ removed_items.append({
274
+ "Side": page_no,
275
+ "Type": "Løbende sidehoved",
276
+ "Tekst": text,
277
+ })
278
+ continue
279
+
280
+ # Remove repeated footers
281
+ if remove_footers and text_key in detected_footers:
282
+ removed_items.append({
283
+ "Side": page_no,
284
+ "Type": "Sidefod",
285
+ "Tekst": text,
286
+ })
287
+ continue
288
+
289
+ # Keep everything else
290
+ kept_text.append(text)
291
+
292
+ # Combine all remaining text on the page
293
+ page_text = " ".join(kept_text)
294
+
295
+ included_text_parts.append(page_text)
296
+
297
+ # Store page statistics
298
+ page_results.append({
299
+ "Side": page_no,
300
+ "Tegn": len(page_text),
301
+ "Status": "Talt med",
302
+ })
303
+
304
+ # Combine text from all included pages
305
+ full_text = " ".join(
306
+ t for t in included_text_parts if t
307
+ )
308
+
309
+ # Return complete result package
310
+ return {
311
+ "total_characters": len(full_text),
312
+ "page_results": page_results,
313
+ "included_text": full_text,
314
+
315
+ # Diagnostic information
316
+ "detected_headers": sorted(detected_headers),
317
+ "detected_footers": sorted(detected_footers),
318
+ "detected_running_headers": sorted(running_headers),
319
+ "detected_page_numbers": sorted(detected_page_numbers),
320
+
321
+ # Log of removed items
322
+ "removed_items": removed_items,
323
+
324
+ # Total pages in document
325
+ "page_count": len(pages),
326
+ }