danielhjerresen commited on
Commit
eea1d94
·
verified ·
1 Parent(s): b7a15bf

Upload 3 files

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