Files
AI_portal/contract-check/pdf_annotator.py
2026-07-07 13:16:26 +02:00

340 lines
13 KiB
Python

"""Add color-coded highlights and a Czech-correct summary page to a PDF."""
import logging
from pathlib import Path
import fitz # PyMuPDF
logger = logging.getLogger(__name__)
# RGB 0-1 for PyMuPDF
COLORS = {
"ok": (0.69, 0.91, 0.69), # green
"warning": (1.00, 0.90, 0.45), # yellow
"problem": (1.00, 0.65, 0.65), # red
"missing": (0.85, 0.85, 0.85), # grey
}
# DejaVu Sans is shipped via fonts-dejavu-core; supports full Czech glyph set.
FONT_PATH_SANS = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
FONT_PATH_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
def annotate(input_pdf: Path, output_pdf: Path, items: list[dict],
overall_summary: str = "", risk_level: str = "",
skip_highlights: bool = False,
contract_name: str = "",
supplier: dict | None = None,
advice: dict | None = None,
chat: list | None = None) -> Path:
"""Open input_pdf, highlight excerpts, prepend a summary page.
Optional sections appended to the summary: supplier reputation report,
per-clause improvement advice, and the contract Q&A transcript."""
doc = fitz.open(str(input_pdf))
highlighted_count = 0
not_found_count = 0
items_to_annotate = [] if skip_highlights else items
if skip_highlights:
logger.info("Skipping per-excerpt highlights (OCR'd PDF — no text layer)")
for item in items_to_annotate:
color = COLORS.get(item.get("status", "warning"), COLORS["warning"])
title = item.get("title") or item.get("label") or item.get("id", "")
for ex in item.get("excerpts") or []:
quote = (ex.get("text") or "").strip()
comment = (ex.get("comment") or "").strip()
if not quote:
continue
found_any = False
for page in doc:
rects = page.search_for(quote, quads=False)
if not rects and len(quote) > 20:
rects = page.search_for(quote[:20], quads=False)
if not rects:
continue
for rect in rects:
annot = page.add_highlight_annot(rect)
annot.set_colors(stroke=color)
annot.set_info(
title=title,
content=f"{title}\n\n{comment}" if comment else title,
)
annot.update()
found_any = True
if found_any:
highlighted_count += 1
else:
not_found_count += 1
logger.info("Quote not found in PDF: %r", quote[:60])
# Build & prepend summary
summary = _build_summary_pdf(doc, items, overall_summary, risk_level,
contract_name, supplier, advice, chat)
if summary:
doc.insert_pdf(summary, start_at=0)
summary.close()
# Set PDF metadata title for nice display in viewers
if contract_name:
meta = doc.metadata or {}
meta["title"] = f"Kontrola: {contract_name}"
doc.set_metadata(meta)
doc.save(str(output_pdf), garbage=4, deflate=True)
doc.close()
logger.info("Annotated PDF: highlighted=%d not_found=%d", highlighted_count, not_found_count)
return output_pdf
def _build_summary_pdf(orig_doc, items: list[dict],
overall_summary: str, risk_level: str,
contract_name: str,
supplier: dict | None = None,
advice: dict | None = None,
chat: list | None = None):
"""Build a 1-N page summary PDF using a Czech-supporting font."""
if not orig_doc.page_count:
return None
src_rect = orig_doc[0].rect
width = max(float(src_rect.width), 595.0) # ensure at least A4
height = max(float(src_rect.height), 842.0)
new = fitz.open()
page = new.new_page(width=width, height=height)
_register_fonts(page)
margin_x = 50.0
y = 50.0
title_size = 18
body_size = 10.5
line_h = body_size * 1.45
# Header line: filename
if contract_name:
_draw_text(page, contract_name, margin_x, y, font="sans",
size=11, color=(0.40, 0.45, 0.55))
y += 18
# Title
_draw_text(page, "Kontrola smluvních podmínek", margin_x, y,
font="bold", size=title_size, color=(0.06, 0.10, 0.20))
y += title_size * 1.6
# Risk badge line
if risk_level:
labels = {"low": "NÍZKÉ", "medium": "STŘEDNÍ", "high": "VYSOKÉ"}
colors = {
"low": (0.20, 0.65, 0.32),
"medium": (0.85, 0.55, 0.10),
"high": (0.80, 0.20, 0.20),
}
_draw_text(page, f"Celková míra rizika: {labels.get(risk_level, risk_level.upper())}",
margin_x, y, font="bold", size=12,
color=colors.get(risk_level, (0.4, 0.4, 0.4)))
y += 22
# Overall summary
if overall_summary:
y = _wrap_text(page, overall_summary, margin_x, y,
width - 2 * margin_x, body_size, font="sans")
y += 12
y += 6
page.draw_line((margin_x, y), (width - margin_x, y),
color=(0.85, 0.85, 0.85))
y += 16
_draw_text(page, "Položky kontroly", margin_x, y,
font="bold", size=12, color=(0.15, 0.20, 0.30))
y += 18
status_labels = {"ok": "OK", "warning": "POZOR",
"problem": "PROBLÉM", "missing": "CHYBÍ"}
for item in items:
# Need new page?
if y > height - 80:
page = new.new_page(width=width, height=height)
_register_fonts(page)
y = 50
status = item.get("status", "")
color = COLORS.get(status, (0.6, 0.6, 0.6))
label = status_labels.get(status, status.upper())
title = item.get("title") or item.get("label") or item.get("id", "")
# Colored bullet square
page.draw_rect(
fitz.Rect(margin_x, y, margin_x + 10, y + 10),
color=color, fill=color,
)
_draw_text(page, f"[{label}] {title}",
margin_x + 18, y, font="bold", size=11,
color=(0.06, 0.10, 0.20))
y += line_h + 4
summary = item.get("summary", "")
if summary:
y = _wrap_text(page, summary, margin_x + 18, y,
width - 2 * margin_x - 18, body_size,
font="sans", color=(0.30, 0.35, 0.45))
# List page references for each excerpt
excerpts = item.get("excerpts") or []
if excerpts:
for ex in excerpts:
pg = ex.get("page")
text = (ex.get("text") or "").strip()
if not text:
continue
pg_str = f"str. {pg}: " if pg else ""
snippet = text if len(text) <= 90 else text[:87] + ""
y = _wrap_text(page, f"{pg_str}{snippet}\"",
margin_x + 18, y,
width - 2 * margin_x - 18, body_size - 0.5,
font="sans", color=(0.25, 0.30, 0.40))
cmt = (ex.get("comment") or "").strip()
if cmt:
y = _wrap_text(page, f"{cmt}",
margin_x + 18, y,
width - 2 * margin_x - 18, body_size - 0.5,
font="sans", color=(0.45, 0.50, 0.60))
y += 12
# Holder so the nested helpers can swap to a fresh page.
state = {"page": page, "y": y}
def _space(min_room=90):
if state["y"] > height - min_room:
state["page"] = new.new_page(width=width, height=height)
_register_fonts(state["page"])
state["y"] = 50.0
def _section(title):
_space(110)
state["page"].draw_line((margin_x, state["y"]),
(width - margin_x, state["y"]),
color=(0.85, 0.85, 0.85))
state["y"] += 14
_draw_text(state["page"], title, margin_x, state["y"],
font="bold", size=12, color=(0.15, 0.20, 0.30))
state["y"] += 20
def _line(text, indent=18, size=body_size, color=(0.30, 0.35, 0.45), bold=False):
_space()
state["y"] = _wrap_text(state["page"], text, margin_x + indent,
state["y"], width - 2 * margin_x - indent,
size, font=("bold" if bold else "sans"), color=color)
# ── Supplier reputation ──
if supplier and supplier.get("report"):
_section("Ověření protistrany v rejstřících (ARES + MFČR)")
a = supplier["report"].get("ares", {})
v = supplier["report"].get("vat", {})
for label, val in (
("Název", a.get("name")),
("IČO / DIČ", " / ".join(x for x in (a.get("ico"), a.get("dic")) if x)),
("Adresa", a.get("address")),
("Vznik", a.get("founded")),
("Zánik", a.get("dissolved")),
("Aktivní v rejstříku", "ano" if a.get("active") else "ne"),
("Plátce DPH", "ano" if a.get("vat_registered") else "ne"),
("Insolvence", "ANO — pozor" if a.get("in_insolvency") else "ne"),
("Nespolehlivý plátce DPH",
"ANO — pozor" if v.get("unreliable") is True
else ("ne" if v.get("unreliable") is False else "neověřeno")),
):
if val:
_line(f"{label}: {val}")
accs = v.get("accounts") or []
if accs:
_line("Zveřejněné účty: " + ", ".join(accs[:6]) + ("" if len(accs) > 6 else ""))
state["y"] += 12
# ── Improvement advice ──
if advice:
_section("Návrhy úprav smluvních podmínek")
for adv in advice.values():
_line(adv.get("title") or "", bold=True, color=(0.10, 0.14, 0.24))
for ln in (adv.get("text") or "").split("\n"):
ln = ln.replace("**", "").strip()
if ln:
_line(ln, indent=26, size=body_size - 0.5)
state["y"] += 8
# ── Contract Q&A ──
if chat:
_section("Dotazy ke smlouvě")
for m in chat:
role = "Dotaz" if m.get("role") == "user" else "Odpověď"
_line(f"{role}:", bold=True, color=(0.10, 0.14, 0.24))
for ln in str(m.get("content") or "").split("\n"):
ln = ln.replace("**", "").strip()
if ln:
_line(ln, indent=26, size=body_size - 0.5)
state["y"] += 8
return new
# ── font helpers ─────────────────────────────────────────
def _register_fonts(page):
"""Insert DejaVu Sans (regular + bold) on the page if available."""
try:
page.insert_font(fontname="sans", fontfile=FONT_PATH_SANS)
except Exception as e:
logger.warning("Could not register DejaVuSans: %s", e)
try:
page.insert_font(fontname="bold", fontfile=FONT_PATH_BOLD)
except Exception:
# Fall back to regular for bold
try:
page.insert_font(fontname="bold", fontfile=FONT_PATH_SANS)
except Exception:
pass
def _draw_text(page, text: str, x: float, y: float,
font: str = "sans", size: float = 10.5,
color: tuple = (0.06, 0.10, 0.20)):
"""Render a single line at baseline y+size."""
try:
page.insert_text((x, y + size), text,
fontname=font, fontsize=size, color=color)
except Exception:
# Fallback to PyMuPDF built-in (may mangle diacritics but won't crash)
page.insert_text((x, y + size), text,
fontsize=size, color=color)
def _wrap_text(page, text: str, x: float, y: float, max_width: float,
font_size: float, font: str = "sans",
color: tuple = (0.06, 0.10, 0.20)) -> float:
"""Word-wrap `text` and return the new y position."""
line_h = font_size * 1.45
# PyMuPDF has Page.get_text_length() for width calculation
def measure(s: str) -> float:
try:
return fitz.get_text_length(s, fontname=font, fontsize=font_size)
except Exception:
return len(s) * font_size * 0.50
words = text.split()
if not words:
return y
line = ""
for word in words:
candidate = (line + " " + word).strip()
if measure(candidate) > max_width and line:
_draw_text(page, line, x, y, font=font,
size=font_size, color=color)
y += line_h
line = word
else:
line = candidate
if line:
_draw_text(page, line, x, y, font=font, size=font_size, color=color)
y += line_h
return y