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
|
||||
# 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,
|
||||
# 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
|
||||
# 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")
|
||||
|
|
@ -363,7 +363,7 @@ def separator_barriers(png_path, regions, near=80, min_overlap=0.3,
|
|||
continue
|
||||
b = r["bbox"]
|
||||
if (b[2] - b[0]) < multicol_frac * W:
|
||||
continue # single-column header → skip
|
||||
continue # single-column header -> skip
|
||||
yb = _faint_rule_above(gray, b)
|
||||
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],
|
||||
|
|
@ -377,7 +377,7 @@ def separator_barriers(png_path, regions, near=80, min_overlap=0.3,
|
|||
continue
|
||||
b = r["bbox"]
|
||||
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)
|
||||
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]})
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ def run(pdir, pg, paper, tag):
|
|||
and max(0, min(y2, w["y2"]) - max(y1, w["y1"])) >= 0.4 * bh]
|
||||
right = min(rcand) if rcand else cright
|
||||
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 = min(top, y1) # never shrink downward
|
||||
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)
|
||||
recs = se.crop_political_articles(png, political, regs, OUT, pg,
|
||||
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():
|
||||
if any(k in l for k in ("Block-snap", "Cropped:", "Clipped", "Floored", "Grew",
|
||||
"Banner", "block-snap unavailable")):
|
||||
|
|
|
|||
11
app.py
11
app.py
|
|
@ -33,7 +33,16 @@ from flask import (
|
|||
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
|
||||
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 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 argparse
|
||||
from pathlib import Path
|
||||
|
|
@ -87,7 +93,7 @@ def main():
|
|||
timestamp = datetime.now().strftime("%H%M%S")
|
||||
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}")
|
||||
|
||||
# Check if the PDF report was generated
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
|
||||
<section class="card">
|
||||
<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">
|
||||
<label class="file">
|
||||
<span>PDF file</span>
|
||||
|
|
@ -26,26 +27,33 @@
|
|||
<section class="card">
|
||||
<h2>Recent jobs</h2>
|
||||
{% if jobs %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>File</th><th>Pages</th><th>Articles</th><th>Status</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for j in jobs %}
|
||||
<tr>
|
||||
<td><code>{{ j.id }}</code></td>
|
||||
<td>{{ j.filename }}</td>
|
||||
<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>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>File</th>
|
||||
<th>Pages</th>
|
||||
<th>Articles</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for j in jobs %}
|
||||
<tr>
|
||||
<td><code>{{ j.id }}</code></td>
|
||||
<td>{{ j.filename }}</td>
|
||||
<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 %}
|
||||
<p class="muted">No jobs yet.</p>
|
||||
<p class="muted">No jobs yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
<div class="art">
|
||||
<div class="art-img">
|
||||
{% 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 %}
|
||||
</div>
|
||||
<div class="art-meta">
|
||||
|
|
@ -35,7 +35,8 @@
|
|||
{% if a.dateline %}<div class="dateline">{{ a.dateline }}</div>{% endif %}
|
||||
{% if a.headline_preview %}<div class="head">{{ a.headline_preview }}</div>{% endif %}
|
||||
<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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -46,31 +47,31 @@
|
|||
{% endif %}
|
||||
|
||||
<script>
|
||||
const status = document.getElementById('status');
|
||||
const msg = document.getElementById('message');
|
||||
const pagecount = document.getElementById('pagecount');
|
||||
const artcount = document.getElementById('artcount');
|
||||
const status = document.getElementById('status');
|
||||
const msg = document.getElementById('message');
|
||||
const pagecount = document.getElementById('pagecount');
|
||||
const artcount = document.getElementById('artcount');
|
||||
|
||||
async function poll() {
|
||||
if (status.textContent === 'done' || status.textContent === 'error') return;
|
||||
try {
|
||||
const r = await fetch("{{ url_for('job_status', job_id=job_id) }}");
|
||||
const data = await r.json();
|
||||
status.textContent = data.status || 'unknown';
|
||||
status.className = 'status status-' + (data.status || 'unknown');
|
||||
msg.textContent = data.message ? '· ' + data.message : '';
|
||||
if (data.meta) {
|
||||
if (data.meta.pages) pagecount.textContent = data.meta.pages;
|
||||
if (data.meta.articles) artcount.textContent = data.meta.articles;
|
||||
}
|
||||
if (data.status === 'done') {
|
||||
setTimeout(() => location.reload(), 800);
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
setTimeout(poll, 1500);
|
||||
}
|
||||
poll();
|
||||
async function poll() {
|
||||
if (status.textContent === 'done' || status.textContent === 'error') return;
|
||||
try {
|
||||
const r = await fetch("{{ url_for('job_status', job_id=job_id) }}");
|
||||
const data = await r.json();
|
||||
status.textContent = data.status || 'unknown';
|
||||
status.className = 'status status-' + (data.status || 'unknown');
|
||||
msg.textContent = data.message ? '· ' + data.message : '';
|
||||
if (data.meta) {
|
||||
if (data.meta.pages) pagecount.textContent = data.meta.pages;
|
||||
if (data.meta.articles) artcount.textContent = data.meta.articles;
|
||||
}
|
||||
if (data.status === 'done') {
|
||||
setTimeout(() => location.reload(), 800);
|
||||
return;
|
||||
}
|
||||
} catch (e) { }
|
||||
setTimeout(poll, 1500);
|
||||
}
|
||||
poll();
|
||||
</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