feat: user code changes merged
This commit is contained in:
parent
33f06cd51d
commit
817931fdbf
|
|
@ -312,7 +312,7 @@ def separator_barriers(png_path, regions, near=80, min_overlap=0.3,
|
||||||
# Column width (median text-region width). A horizontal line counts as an article
|
# Column width (median text-region width). A horizontal line counts as an article
|
||||||
# separator only when it spans most of a column. NOTE: a strict ≥1.0× column floor is
|
# separator only when it spans most of a column. NOTE: a strict ≥1.0× column floor is
|
||||||
# too aggressive — a SINGLE-column article's own boundary rule is itself <1 column wide,
|
# too aggressive — a SINGLE-column article's own boundary rule is itself <1 column wide,
|
||||||
# so ≥1.0× drops real separators and merges articles (measured: NT pg4 R121 → 2 datelines).
|
# so ≥1.0× drops real separators and merges articles (measured: NT pg4 R121 -> 2 datelines).
|
||||||
# 0.6× column is the safe floor: it removes only the short caption / decorative underlines
|
# 0.6× column is the safe floor: it removes only the short caption / decorative underlines
|
||||||
# and merges NO articles on AJ / NT / JG.
|
# and merges NO articles on AJ / NT / JG.
|
||||||
_twid = sorted((r["bbox"][2] - r["bbox"][0]) for r in regions if r.get("type") == "text")
|
_twid = sorted((r["bbox"][2] - r["bbox"][0]) for r in regions if r.get("type") == "text")
|
||||||
|
|
@ -363,7 +363,7 @@ def separator_barriers(png_path, regions, near=80, min_overlap=0.3,
|
||||||
continue
|
continue
|
||||||
b = r["bbox"]
|
b = r["bbox"]
|
||||||
if (b[2] - b[0]) < multicol_frac * W:
|
if (b[2] - b[0]) < multicol_frac * W:
|
||||||
continue # single-column header → skip
|
continue # single-column header -> skip
|
||||||
yb = _faint_rule_above(gray, b)
|
yb = _faint_rule_above(gray, b)
|
||||||
if yb is not None and not _lead_photo_above(yb, b[0], b[2]):
|
if yb is not None and not _lead_photo_above(yb, b[0], b[2]):
|
||||||
bars.append({"y": yb, "x1": b[0], "x2": b[2],
|
bars.append({"y": yb, "x1": b[0], "x2": b[2],
|
||||||
|
|
@ -377,7 +377,7 @@ def separator_barriers(png_path, regions, near=80, min_overlap=0.3,
|
||||||
continue
|
continue
|
||||||
b = r["bbox"]
|
b = r["bbox"]
|
||||||
if (b[2] - b[0]) >= multicol_frac * W:
|
if (b[2] - b[0]) >= multicol_frac * W:
|
||||||
continue # multi-column → handled above
|
continue # multi-column -> handled above
|
||||||
yb = _faint_grey_band_above(gray, b)
|
yb = _faint_grey_band_above(gray, b)
|
||||||
if yb is not None and not _lead_photo_above(yb, b[0], b[2]):
|
if yb is not None and not _lead_photo_above(yb, b[0], b[2]):
|
||||||
bars.append({"y": yb, "x1": b[0], "x2": b[2]})
|
bars.append({"y": yb, "x1": b[0], "x2": b[2]})
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ def run(pdir, pg, paper, tag):
|
||||||
and max(0, min(y2, w["y2"]) - max(y1, w["y1"])) >= 0.4 * bh]
|
and max(0, min(y2, w["y2"]) - max(y1, w["y1"])) >= 0.4 * bh]
|
||||||
right = min(rcand) if rcand else cright
|
right = min(rcand) if rcand else cright
|
||||||
right = max(right, x2) # never shrink
|
right = max(right, x2) # never shrink
|
||||||
# topmost dead-end: top-row article → up to masthead bottom
|
# topmost dead-end: top-row article -> up to masthead bottom
|
||||||
top = mh if (y1 <= top_row_cut and mh > 0) else y1
|
top = mh if (y1 <= top_row_cut and mh > 0) else y1
|
||||||
top = min(top, y1) # never shrink downward
|
top = min(top, y1) # never shrink downward
|
||||||
return [x1, top, right, y2]
|
return [x1, top, right, y2]
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ for paper, RUN, pages in RUNS:
|
||||||
ds = se.find_article_starts_by_dateline(regs, png, paper)
|
ds = se.find_article_starts_by_dateline(regs, png, paper)
|
||||||
recs = se.crop_political_articles(png, political, regs, OUT, pg,
|
recs = se.crop_political_articles(png, political, regs, OUT, pg,
|
||||||
dateline_starts=ds, sep_lines=bars)
|
dateline_starts=ds, sep_lines=bars)
|
||||||
print(f"=== {RUN.name} page {pg}: {len(political)} political → {len(recs)} crops")
|
print(f"=== {RUN.name} page {pg}: {len(political)} political -> {len(recs)} crops")
|
||||||
for l in buf.getvalue().splitlines():
|
for l in buf.getvalue().splitlines():
|
||||||
if any(k in l for k in ("Block-snap", "Cropped:", "Clipped", "Floored", "Grew",
|
if any(k in l for k in ("Block-snap", "Cropped:", "Clipped", "Floored", "Grew",
|
||||||
"Banner", "block-snap unavailable")):
|
"Banner", "block-snap unavailable")):
|
||||||
|
|
|
||||||
11
app.py
11
app.py
|
|
@ -33,7 +33,16 @@ from flask import (
|
||||||
send_from_directory, send_file, abort, jsonify,
|
send_from_directory, send_file, abort, jsonify,
|
||||||
)
|
)
|
||||||
|
|
||||||
from extractor import process_pdf, JOBS, JOB_LOCK
|
import sys
|
||||||
|
if hasattr(sys.stdout, 'reconfigure'):
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
if hasattr(sys.stderr, 'reconfigure'):
|
||||||
|
sys.stderr.reconfigure(encoding='utf-8')
|
||||||
|
|
||||||
|
from extractor import process_pdf
|
||||||
|
|
||||||
|
JOBS = {}
|
||||||
|
JOB_LOCK = threading.Lock()
|
||||||
|
|
||||||
ROOT = Path(__file__).parent
|
ROOT = Path(__file__).parent
|
||||||
UPLOAD_DIR = ROOT / "uploads"
|
UPLOAD_DIR = ROOT / "uploads"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
None
|
||||||
884
extractor.py
884
extractor.py
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,11 @@
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from generate_pdf import generate_political_pdf
|
||||||
|
|
||||||
|
job_dir = Path(r'c:\Users\sunde\proxmox\news-scan\output\20260612_131909')
|
||||||
|
articles = json.loads((job_dir / 'political_articles.json').read_text(encoding='utf-8'))
|
||||||
|
for a in articles:
|
||||||
|
a['image_file'] = f"{a['id']}.png"
|
||||||
|
|
||||||
|
generate_political_pdf(articles, job_dir)
|
||||||
|
print("Regenerated PDF with images!")
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from fpdf import FPDF
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
def clean_text(text):
|
||||||
|
if not text:
|
||||||
|
return "N/A"
|
||||||
|
# Replace unicode quotes and dashes with ascii equivalents to avoid FPDF errors with Latin-1
|
||||||
|
text = str(text)
|
||||||
|
text = text.replace('"', '"').replace('"', '"').replace('"', "'").replace('"', "'")
|
||||||
|
text = text.replace('—', '-').replace('–', '-')
|
||||||
|
# Normalize to nearest ascii character
|
||||||
|
res = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode('ascii')
|
||||||
|
res = res.strip()
|
||||||
|
if not res:
|
||||||
|
return "N/A"
|
||||||
|
# Break up extremely long words that crash FPDF
|
||||||
|
res = " ".join([word[:50] for word in res.split()])
|
||||||
|
return res
|
||||||
|
|
||||||
|
def generate_political_pdf(selected_articles, job_dir):
|
||||||
|
job_dir = Path(job_dir)
|
||||||
|
images_dir = job_dir / "all_political_images"
|
||||||
|
|
||||||
|
if not images_dir.exists() or not selected_articles:
|
||||||
|
return
|
||||||
|
|
||||||
|
pdf = FPDF()
|
||||||
|
pdf.set_auto_page_break(auto=True, margin=15)
|
||||||
|
|
||||||
|
# Sort articles: high priority first
|
||||||
|
high = [a for a in selected_articles if a.get("priority") == "high"]
|
||||||
|
medium = [a for a in selected_articles if a.get("priority") == "medium"]
|
||||||
|
sorted_articles = high + medium
|
||||||
|
|
||||||
|
for i, art in enumerate(sorted_articles, 1):
|
||||||
|
pdf.add_page()
|
||||||
|
|
||||||
|
# Add Header
|
||||||
|
pdf.set_font("helvetica", "B", 16)
|
||||||
|
priority = str(art.get("priority", "N/A")).upper()
|
||||||
|
pdf.cell(0, 10, f"Article {i} - {priority} PRIORITY", new_x="LMARGIN", new_y="NEXT", align="C")
|
||||||
|
pdf.ln(5)
|
||||||
|
|
||||||
|
# Details
|
||||||
|
pdf.set_font("helvetica", "B", 12)
|
||||||
|
pdf.multi_cell(0, 8, f"Headline: {clean_text(art.get('headline_english'))}", new_x="LMARGIN", new_y="NEXT")
|
||||||
|
pdf.multi_cell(0, 8, f"Category: {clean_text(art.get('category')).upper()}", new_x="LMARGIN", new_y="NEXT")
|
||||||
|
|
||||||
|
pdf.set_font("helvetica", "", 12)
|
||||||
|
pdf.multi_cell(0, 8, f"Why: {clean_text(art.get('reasoning'))}", new_x="LMARGIN", new_y="NEXT")
|
||||||
|
pdf.ln(5)
|
||||||
|
|
||||||
|
# Image
|
||||||
|
img_name = art.get("image_file")
|
||||||
|
if img_name:
|
||||||
|
img_path = images_dir / img_name
|
||||||
|
if img_path.exists():
|
||||||
|
# Get image dimensions to scale it properly if needed
|
||||||
|
try:
|
||||||
|
pdf.image(str(img_path), w=180)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error adding image to PDF: {e}")
|
||||||
|
|
||||||
|
output_path = job_dir / "political_articles_insights.pdf"
|
||||||
|
try:
|
||||||
|
pdf.output(str(output_path))
|
||||||
|
print(f"Successfully generated PDF report at {output_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to generate PDF: {e}")
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
import math
|
||||||
|
|
||||||
|
def get_horizontal_overlap(bbox1, bbox2):
|
||||||
|
x_left = max(bbox1[0], bbox2[0])
|
||||||
|
x_right = min(bbox1[2], bbox2[2])
|
||||||
|
return max(0, x_right - x_left)
|
||||||
|
|
||||||
|
def group_surya_regions_geometrically(regions):
|
||||||
|
"""
|
||||||
|
Groups regions spatially.
|
||||||
|
Instead of a linear reading order, this associates text and images
|
||||||
|
with the headline that is directly above them in the same column.
|
||||||
|
"""
|
||||||
|
if not regions:
|
||||||
|
return []
|
||||||
|
|
||||||
|
titles = [r for r in regions if r['type'] in ('doc_title', 'paragraph_title')]
|
||||||
|
non_titles = [r for r in regions if r['type'] not in ('doc_title', 'paragraph_title')]
|
||||||
|
|
||||||
|
# If no titles exist, return everything as one article
|
||||||
|
if not titles:
|
||||||
|
member_ids = [r['id'] for r in regions]
|
||||||
|
min_x = min(r['bbox'][0] for r in regions)
|
||||||
|
min_y = min(r['bbox'][1] for r in regions)
|
||||||
|
max_x = max(r['bbox'][2] for r in regions)
|
||||||
|
max_y = max(r['bbox'][3] for r in regions)
|
||||||
|
return [{
|
||||||
|
"article_id": 1,
|
||||||
|
"title_region": None,
|
||||||
|
"member_regions": member_ids,
|
||||||
|
"bbox": [min_x, min_y, max_x, max_y],
|
||||||
|
"grouped_by": "geometric_fallback"
|
||||||
|
}]
|
||||||
|
|
||||||
|
article_groups = {t['id']: [t] for t in titles}
|
||||||
|
|
||||||
|
for item in non_titles:
|
||||||
|
item_bbox = item['bbox']
|
||||||
|
item_cy = (item_bbox[1] + item_bbox[3]) / 2
|
||||||
|
item_cx = (item_bbox[0] + item_bbox[2]) / 2
|
||||||
|
item_width = item_bbox[2] - item_bbox[0]
|
||||||
|
|
||||||
|
best_title = None
|
||||||
|
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
# Rule 1: Find titles ABOVE the item that share the same column
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
candidates_above = []
|
||||||
|
for t in titles:
|
||||||
|
t_bbox = t['bbox']
|
||||||
|
if t_bbox[1] <= item_cy:
|
||||||
|
overlap = get_horizontal_overlap(item_bbox, t_bbox)
|
||||||
|
t_width = t_bbox[2] - t_bbox[0]
|
||||||
|
t_cx = (t_bbox[0] + t_bbox[2]) / 2
|
||||||
|
|
||||||
|
if overlap > (item_width * 0.1) or overlap > (t_width * 0.1) or abs(item_cx - t_cx) < 150:
|
||||||
|
distance = item_bbox[1] - t_bbox[3]
|
||||||
|
if distance < 1500: # MAX DISTANCE ABOVE
|
||||||
|
candidates_above.append(t)
|
||||||
|
|
||||||
|
if candidates_above:
|
||||||
|
best_title = min(candidates_above, key=lambda t: abs(item_bbox[1] - t['bbox'][3]))
|
||||||
|
else:
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
# Rule 2: If no title is above, find titles BELOW the item
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
candidates_below = []
|
||||||
|
for t in titles:
|
||||||
|
t_bbox = t['bbox']
|
||||||
|
if t_bbox[1] > item_cy:
|
||||||
|
overlap = get_horizontal_overlap(item_bbox, t_bbox)
|
||||||
|
t_width = t_bbox[2] - t_bbox[0]
|
||||||
|
t_cx = (t_bbox[0] + t_bbox[2]) / 2
|
||||||
|
|
||||||
|
if overlap > (item_width * 0.1) or overlap > (t_width * 0.1) or abs(item_cx - t_cx) < 150:
|
||||||
|
distance = t_bbox[1] - item_bbox[3]
|
||||||
|
if distance < 800: # MAX DISTANCE BELOW (stricter because text usually flows down, not up)
|
||||||
|
candidates_below.append(t)
|
||||||
|
|
||||||
|
if candidates_below:
|
||||||
|
best_title = min(candidates_below, key=lambda t: abs(t['bbox'][1] - item_bbox[3]))
|
||||||
|
else:
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
# Rule 3: Absolute fallback. No column overlap at all.
|
||||||
|
# Find the absolute closest title via Euclidean distance.
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
closest_t = min(titles, key=lambda t: math.hypot(
|
||||||
|
item_cx - ((t['bbox'][0] + t['bbox'][2]) / 2),
|
||||||
|
item_cy - ((t['bbox'][1] + t['bbox'][3]) / 2)
|
||||||
|
))
|
||||||
|
# Only attach if it's reasonably close, else leave as orphan
|
||||||
|
dist = math.hypot(item_cx - ((closest_t['bbox'][0] + closest_t['bbox'][2]) / 2),
|
||||||
|
item_cy - ((closest_t['bbox'][1] + closest_t['bbox'][3]) / 2))
|
||||||
|
if dist < 1500:
|
||||||
|
best_title = closest_t
|
||||||
|
|
||||||
|
if best_title:
|
||||||
|
article_groups[best_title['id']].append(item)
|
||||||
|
else:
|
||||||
|
# Treat as an orphan (create a new article group for it, or group orphans together)
|
||||||
|
# For simplicity, we'll assign it a virtual title ID based on its own ID so it becomes a standalone article
|
||||||
|
virtual_id = f"orphan_{item['id']}"
|
||||||
|
article_groups[virtual_id] = [item]
|
||||||
|
|
||||||
|
# Format output
|
||||||
|
articles = []
|
||||||
|
# Sort groups top-to-bottom, left-to-right based on title position
|
||||||
|
sorted_titles = sorted(titles, key=lambda t: (t['bbox'][1], t['bbox'][0]))
|
||||||
|
|
||||||
|
for idx, (title_id, group_regions) in enumerate(article_groups.items(), start=1):
|
||||||
|
if not group_regions:
|
||||||
|
continue
|
||||||
|
|
||||||
|
min_x = min(r['bbox'][0] for r in group_regions)
|
||||||
|
min_y = min(r['bbox'][1] for r in group_regions)
|
||||||
|
max_x = max(r['bbox'][2] for r in group_regions)
|
||||||
|
max_y = max(r['bbox'][3] for r in group_regions)
|
||||||
|
|
||||||
|
# Determine if it's an orphan group
|
||||||
|
is_orphan = str(title_id).startswith("orphan_")
|
||||||
|
|
||||||
|
articles.append({
|
||||||
|
"article_id": idx,
|
||||||
|
"title_region": None if is_orphan else title_id,
|
||||||
|
"member_regions": [r['id'] for r in group_regions],
|
||||||
|
"bbox": [min_x, min_y, max_x, max_y],
|
||||||
|
"grouped_by": "geometric_orphan" if is_orphan else "geometric"
|
||||||
|
})
|
||||||
|
|
||||||
|
# We should merge orphans that are vertically adjacent into the same orphan group to prevent shattering
|
||||||
|
# Simple vertical merge for orphans
|
||||||
|
orphan_articles = [a for a in articles if a['grouped_by'] == 'geometric_orphan']
|
||||||
|
valid_articles = [a for a in articles if a['grouped_by'] == 'geometric']
|
||||||
|
|
||||||
|
# Sort orphans top-to-bottom
|
||||||
|
orphan_articles.sort(key=lambda a: a['bbox'][1])
|
||||||
|
|
||||||
|
merged_orphans = []
|
||||||
|
current_orphan = None
|
||||||
|
|
||||||
|
for o in orphan_articles:
|
||||||
|
if not current_orphan:
|
||||||
|
current_orphan = o
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if they overlap horizontally and are close vertically
|
||||||
|
overlap = get_horizontal_overlap(current_orphan['bbox'], o['bbox'])
|
||||||
|
vertical_dist = o['bbox'][1] - current_orphan['bbox'][3]
|
||||||
|
|
||||||
|
if overlap > 0 and vertical_dist < 400:
|
||||||
|
# Merge
|
||||||
|
current_orphan['member_regions'].extend(o['member_regions'])
|
||||||
|
current_orphan['bbox'][0] = min(current_orphan['bbox'][0], o['bbox'][0])
|
||||||
|
current_orphan['bbox'][1] = min(current_orphan['bbox'][1], o['bbox'][1])
|
||||||
|
current_orphan['bbox'][2] = max(current_orphan['bbox'][2], o['bbox'][2])
|
||||||
|
current_orphan['bbox'][3] = max(current_orphan['bbox'][3], o['bbox'][3])
|
||||||
|
else:
|
||||||
|
merged_orphans.append(current_orphan)
|
||||||
|
current_orphan = o
|
||||||
|
|
||||||
|
if current_orphan:
|
||||||
|
merged_orphans.append(current_orphan)
|
||||||
|
|
||||||
|
final_articles = valid_articles + merged_orphans
|
||||||
|
|
||||||
|
# Reassign IDs
|
||||||
|
for idx, a in enumerate(final_articles, start=1):
|
||||||
|
a['article_id'] = idx
|
||||||
|
|
||||||
|
return final_articles
|
||||||
Binary file not shown.
|
|
@ -1,4 +1,10 @@
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
if hasattr(sys.stdout, 'reconfigure'):
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
if hasattr(sys.stderr, 'reconfigure'):
|
||||||
|
sys.stderr.reconfigure(encoding='utf-8')
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
import argparse
|
import argparse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -87,7 +93,7 @@ def main():
|
||||||
timestamp = datetime.now().strftime("%H%M%S")
|
timestamp = datetime.now().strftime("%H%M%S")
|
||||||
dest_path = processed_dir / f"{pdf_path.stem}_{timestamp}{pdf_path.suffix}"
|
dest_path = processed_dir / f"{pdf_path.stem}_{timestamp}{pdf_path.suffix}"
|
||||||
|
|
||||||
shutil.move(str(pdf_path), str(dest_path))
|
shutil.move(str(pdf_path.absolute()), str(dest_path.absolute()))
|
||||||
print(f"\nMoved processed file to: {dest_path}")
|
print(f"\nMoved processed file to: {dest_path}")
|
||||||
|
|
||||||
# Check if the PDF report was generated
|
# Check if the PDF report was generated
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h1>Upload a PDF</h1>
|
<h1>Upload a PDF</h1>
|
||||||
<p class="muted">Each page will be rendered to fit inside the pixel box below, then split into per-article images and text.</p>
|
<p class="muted">Each page will be rendered to fit inside the pixel box below, then split into per-article images and
|
||||||
|
text.</p>
|
||||||
<form action="{{ url_for('upload') }}" method="post" enctype="multipart/form-data">
|
<form action="{{ url_for('upload') }}" method="post" enctype="multipart/form-data">
|
||||||
<label class="file">
|
<label class="file">
|
||||||
<span>PDF file</span>
|
<span>PDF file</span>
|
||||||
|
|
@ -26,26 +27,33 @@
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Recent jobs</h2>
|
<h2>Recent jobs</h2>
|
||||||
{% if jobs %}
|
{% if jobs %}
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>ID</th><th>File</th><th>Pages</th><th>Articles</th><th>Status</th><th></th></tr>
|
<tr>
|
||||||
</thead>
|
<th>ID</th>
|
||||||
<tbody>
|
<th>File</th>
|
||||||
{% for j in jobs %}
|
<th>Pages</th>
|
||||||
<tr>
|
<th>Articles</th>
|
||||||
<td><code>{{ j.id }}</code></td>
|
<th>Status</th>
|
||||||
<td>{{ j.filename }}</td>
|
<th></th>
|
||||||
<td>{{ j.pages }}</td>
|
</tr>
|
||||||
<td>{{ j.articles }}</td>
|
</thead>
|
||||||
<td><span class="status status-{{ j.status }}">{{ j.status }}</span></td>
|
<tbody>
|
||||||
<td><a href="{{ url_for('job_view', job_id=j.id) }}">Open</a></td>
|
{% for j in jobs %}
|
||||||
</tr>
|
<tr>
|
||||||
{% endfor %}
|
<td><code>{{ j.id }}</code></td>
|
||||||
</tbody>
|
<td>{{ j.filename }}</td>
|
||||||
</table>
|
<td>{{ j.pages }}</td>
|
||||||
|
<td>{{ j.articles }}</td>
|
||||||
|
<td><span class="status status-{{ j.status }}">{{ j.status }}</span></td>
|
||||||
|
<td><a href="{{ url_for('job_view', job_id=j.id) }}">Open</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted">No jobs yet.</p>
|
<p class="muted">No jobs yet.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
<div class="art">
|
<div class="art">
|
||||||
<div class="art-img">
|
<div class="art-img">
|
||||||
{% if a.has_image %}
|
{% if a.has_image %}
|
||||||
<img src="{{ url_for('article_image', job_id=job_id, art_name=a.name) }}" alt="">
|
<img src="{{ url_for('article_image', job_id=job_id, art_name=a.name) }}" alt="">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="art-meta">
|
<div class="art-meta">
|
||||||
|
|
@ -35,7 +35,8 @@
|
||||||
{% if a.dateline %}<div class="dateline">{{ a.dateline }}</div>{% endif %}
|
{% if a.dateline %}<div class="dateline">{{ a.dateline }}</div>{% endif %}
|
||||||
{% if a.headline_preview %}<div class="head">{{ a.headline_preview }}</div>{% endif %}
|
{% if a.headline_preview %}<div class="head">{{ a.headline_preview }}</div>{% endif %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{% if a.has_image %}<a href="{{ url_for('article_image', job_id=job_id, art_name=a.name) }}">image</a>{% endif %}
|
{% if a.has_image %}<a href="{{ url_for('article_image', job_id=job_id, art_name=a.name) }}">image</a>{% endif
|
||||||
|
%}
|
||||||
{% if a.has_text %}<a href="{{ url_for('article_text', job_id=job_id, art_name=a.name) }}">text</a>{% endif %}
|
{% if a.has_text %}<a href="{{ url_for('article_text', job_id=job_id, art_name=a.name) }}">text</a>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -46,31 +47,31 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const status = document.getElementById('status');
|
const status = document.getElementById('status');
|
||||||
const msg = document.getElementById('message');
|
const msg = document.getElementById('message');
|
||||||
const pagecount = document.getElementById('pagecount');
|
const pagecount = document.getElementById('pagecount');
|
||||||
const artcount = document.getElementById('artcount');
|
const artcount = document.getElementById('artcount');
|
||||||
|
|
||||||
async function poll() {
|
async function poll() {
|
||||||
if (status.textContent === 'done' || status.textContent === 'error') return;
|
if (status.textContent === 'done' || status.textContent === 'error') return;
|
||||||
try {
|
try {
|
||||||
const r = await fetch("{{ url_for('job_status', job_id=job_id) }}");
|
const r = await fetch("{{ url_for('job_status', job_id=job_id) }}");
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
status.textContent = data.status || 'unknown';
|
status.textContent = data.status || 'unknown';
|
||||||
status.className = 'status status-' + (data.status || 'unknown');
|
status.className = 'status status-' + (data.status || 'unknown');
|
||||||
msg.textContent = data.message ? '· ' + data.message : '';
|
msg.textContent = data.message ? '· ' + data.message : '';
|
||||||
if (data.meta) {
|
if (data.meta) {
|
||||||
if (data.meta.pages) pagecount.textContent = data.meta.pages;
|
if (data.meta.pages) pagecount.textContent = data.meta.pages;
|
||||||
if (data.meta.articles) artcount.textContent = data.meta.articles;
|
if (data.meta.articles) artcount.textContent = data.meta.articles;
|
||||||
}
|
}
|
||||||
if (data.status === 'done') {
|
if (data.status === 'done') {
|
||||||
setTimeout(() => location.reload(), 800);
|
setTimeout(() => location.reload(), 800);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) { }
|
||||||
setTimeout(poll, 1500);
|
setTimeout(poll, 1500);
|
||||||
}
|
}
|
||||||
poll();
|
poll();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import json
|
||||||
|
import glob
|
||||||
|
from fpdf import FPDF
|
||||||
|
|
||||||
|
pdf = FPDF()
|
||||||
|
pdf.set_auto_page_break(auto=True, margin=15)
|
||||||
|
|
||||||
|
for f in glob.glob('output/20260612_110447/articles/*/info.json'):
|
||||||
|
info = json.loads(open(f, encoding='utf-8').read())
|
||||||
|
if not info.get('priority'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
pdf.add_page()
|
||||||
|
pdf.set_font("helvetica", "B", 16)
|
||||||
|
pdf.cell(0, 10, "Header", ln=True)
|
||||||
|
|
||||||
|
pdf.set_font("helvetica", "B", 12)
|
||||||
|
pdf.multi_cell(0, 8, f"Headline: {info.get('headline_english', '')}")
|
||||||
|
print(f"Testing Category: {info.get('category', '')}")
|
||||||
|
pdf.multi_cell(0, 8, f"Category: {str(info.get('category', '')).upper()}")
|
||||||
|
pdf.multi_cell(0, 8, f"Why: {info.get('reasoning', '')}")
|
||||||
|
|
||||||
|
pdf.output('test_real.pdf')
|
||||||
|
print("SUCCESS!")
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"from pathlib import Path\nfrom generate_pdf import generate_political_pdf\n\njob_dir = Path(r\"c:\\Users\\sunde\\proxmox\\news-scan\\output\\20260611_161537\")\n\nmock_selected = [\n {\n \"id\": \"p001_a002\",\n \"headline_english\": \"Mock Headline: Government Fails to Deliver Promises on Agriculture\",\n \"priority\": \"high\",\n \"category\": \"farmer_issues\",\n \"political_significance\": \"Shows direct failure of government to support farmers leading to massive distress.\",\n \"attack_angle\": \"The ruling party claimed they are pro-farmer, but this report proves they are letting farmers suffer without MSP.\"\n },\n {\n \"id\": \"p001_a003\",\n \"headline_english\": \"Corruption Allegations in Irrigation Project\",\n \"priority\": \"medium\",\n \"category\": \"corruption\",\n \"political_significance\": \"Highlights massive irregularities in the tender process.\",\n \"attack_angle\": \"Demand an immediate CBI probe into the 500-crore scam.\"\n }\n]\n\nprint(\"Generating mock PDF...\")\ngenerate_political_pdf(mock_selected, job_dir)\nprint(\"Done!\")\n"
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
class Box:
|
||||||
|
def __init__(self, region):
|
||||||
|
self.id = region['id']
|
||||||
|
self.type = region['type']
|
||||||
|
self.bbox = region['bbox']
|
||||||
|
self.x1, self.y1, self.x2, self.y2 = self.bbox
|
||||||
|
self.region = region
|
||||||
|
|
||||||
|
def recursive_xy_cut(boxes, min_x_gap=20, min_y_gap=20):
|
||||||
|
if not boxes:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if len(boxes) == 1:
|
||||||
|
return [boxes]
|
||||||
|
|
||||||
|
# Find bounding box of all boxes
|
||||||
|
min_x = min(b.x1 for b in boxes)
|
||||||
|
max_x = max(b.x2 for b in boxes)
|
||||||
|
min_y = min(b.y1 for b in boxes)
|
||||||
|
max_y = max(b.y2 for b in boxes)
|
||||||
|
|
||||||
|
# Projection profiles
|
||||||
|
x_profile = np.zeros(max_x - min_x + 1, dtype=int)
|
||||||
|
y_profile = np.zeros(max_y - min_y + 1, dtype=int)
|
||||||
|
|
||||||
|
for b in boxes:
|
||||||
|
x_profile[max(0, b.x1 - min_x):b.x2 - min_x + 1] = 1
|
||||||
|
y_profile[max(0, b.y1 - min_y):b.y2 - min_y + 1] = 1
|
||||||
|
|
||||||
|
# Find gaps
|
||||||
|
def find_gaps(profile):
|
||||||
|
gaps = []
|
||||||
|
in_gap = profile[0] == 0
|
||||||
|
start = 0 if in_gap else -1
|
||||||
|
for i, val in enumerate(profile):
|
||||||
|
if val == 0 and not in_gap:
|
||||||
|
in_gap = True
|
||||||
|
start = i
|
||||||
|
elif val == 1 and in_gap:
|
||||||
|
in_gap = False
|
||||||
|
gaps.append((start, i))
|
||||||
|
if in_gap:
|
||||||
|
gaps.append((start, len(profile)))
|
||||||
|
return gaps
|
||||||
|
|
||||||
|
x_gaps = find_gaps(x_profile)
|
||||||
|
y_gaps = find_gaps(y_profile)
|
||||||
|
|
||||||
|
# Filter gaps by threshold
|
||||||
|
valid_x_gaps = [g for g in x_gaps if g[1] - g[0] >= min_x_gap]
|
||||||
|
valid_y_gaps = [g for g in y_gaps if g[1] - g[0] >= min_y_gap]
|
||||||
|
|
||||||
|
# Choose best cut (prefer Y cuts to separate vertical sections, then X cuts for columns)
|
||||||
|
# Actually, in newspapers, Y cut (horizontal line) separates main stories or headlines from columns
|
||||||
|
# X cut (vertical line) separates columns.
|
||||||
|
# Standard XY cut alternates or picks the widest gap.
|
||||||
|
best_cut = None
|
||||||
|
axis = None
|
||||||
|
|
||||||
|
if valid_y_gaps:
|
||||||
|
# Pick the widest Y gap
|
||||||
|
widest = max(valid_y_gaps, key=lambda g: g[1] - g[0])
|
||||||
|
best_cut = widest[0] + (widest[1] - widest[0]) // 2 + min_y
|
||||||
|
axis = 'y'
|
||||||
|
elif valid_x_gaps:
|
||||||
|
# Pick the widest X gap
|
||||||
|
widest = max(valid_x_gaps, key=lambda g: g[1] - g[0])
|
||||||
|
best_cut = widest[0] + (widest[1] - widest[0]) // 2 + min_x
|
||||||
|
axis = 'x'
|
||||||
|
|
||||||
|
if best_cut is None:
|
||||||
|
# No cuts can be made, this is a leaf node
|
||||||
|
return [boxes]
|
||||||
|
|
||||||
|
# Split boxes
|
||||||
|
left_top = []
|
||||||
|
right_bottom = []
|
||||||
|
|
||||||
|
if axis == 'y':
|
||||||
|
for b in boxes:
|
||||||
|
if b.y1 + (b.y2 - b.y1)//2 < best_cut:
|
||||||
|
left_top.append(b)
|
||||||
|
else:
|
||||||
|
right_bottom.append(b)
|
||||||
|
else:
|
||||||
|
for b in boxes:
|
||||||
|
if b.x1 + (b.x2 - b.x1)//2 < best_cut:
|
||||||
|
left_top.append(b)
|
||||||
|
else:
|
||||||
|
right_bottom.append(b)
|
||||||
|
|
||||||
|
# Recurse
|
||||||
|
# If a cut didn't actually split anything (due to bounding box overlap centers), stop
|
||||||
|
if not left_top or not right_bottom:
|
||||||
|
return [boxes]
|
||||||
|
|
||||||
|
return recursive_xy_cut(left_top, min_x_gap, min_y_gap) + recursive_xy_cut(right_bottom, min_x_gap, min_y_gap)
|
||||||
|
|
||||||
|
def group_surya_regions_xycut(regions):
|
||||||
|
"""
|
||||||
|
Groups regions by using XY-Cut to establish reading order,
|
||||||
|
then grouping by paragraph_title / doc_title.
|
||||||
|
"""
|
||||||
|
if not regions:
|
||||||
|
return []
|
||||||
|
|
||||||
|
boxes = [Box(r) for r in regions]
|
||||||
|
|
||||||
|
# Step 1: XY-Cut
|
||||||
|
leaves = recursive_xy_cut(boxes, min_x_gap=30, min_y_gap=25)
|
||||||
|
|
||||||
|
# Step 2: Establish Reading order
|
||||||
|
# Leaves are already in a roughly top-to-bottom, left-to-right order because
|
||||||
|
# recursive_xy_cut processes Y cuts then X cuts and appends left_top before right_bottom.
|
||||||
|
|
||||||
|
# Flatten boxes in reading order
|
||||||
|
ordered_boxes = []
|
||||||
|
for leaf in leaves:
|
||||||
|
# Sort within leaf just in case
|
||||||
|
leaf.sort(key=lambda b: (b.y1, b.x1))
|
||||||
|
ordered_boxes.extend(leaf)
|
||||||
|
|
||||||
|
# Step 3: Group into articles
|
||||||
|
articles = []
|
||||||
|
current_article = []
|
||||||
|
current_title = None
|
||||||
|
|
||||||
|
for box in ordered_boxes:
|
||||||
|
# Start a new article if we hit a title
|
||||||
|
if box.type in ('paragraph_title', 'doc_title'):
|
||||||
|
# If the current article ONLY contains images, these images likely belong to this new title!
|
||||||
|
# Or if it contains images at the very end, wait, let's just check if it's ONLY images.
|
||||||
|
if current_article and all(b.type == 'image' for b in current_article):
|
||||||
|
current_article.append(box)
|
||||||
|
else:
|
||||||
|
if current_article:
|
||||||
|
articles.append(current_article)
|
||||||
|
current_article = [box]
|
||||||
|
else:
|
||||||
|
current_article.append(box)
|
||||||
|
|
||||||
|
if current_article:
|
||||||
|
articles.append(current_article)
|
||||||
|
|
||||||
|
# Format output
|
||||||
|
output = []
|
||||||
|
for i, art_boxes in enumerate(articles, start=1):
|
||||||
|
member_ids = [b.id for b in art_boxes]
|
||||||
|
|
||||||
|
# Calculate union bbox
|
||||||
|
min_x = min(b.x1 for b in art_boxes)
|
||||||
|
min_y = min(b.y1 for b in art_boxes)
|
||||||
|
max_x = max(b.x2 for b in art_boxes)
|
||||||
|
max_y = max(b.y2 for b in art_boxes)
|
||||||
|
|
||||||
|
output.append({
|
||||||
|
"article_id": i,
|
||||||
|
"title_region": member_ids[0] if art_boxes[0].type in ('paragraph_title', 'doc_title') else None,
|
||||||
|
"member_regions": member_ids,
|
||||||
|
"bbox": [min_x, min_y, max_x, max_y],
|
||||||
|
"dateline": None,
|
||||||
|
"grouped_by": "xy_cut"
|
||||||
|
})
|
||||||
|
|
||||||
|
return output
|
||||||
Loading…
Reference in New Issue