340 lines
13 KiB
Python
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
|