Coverage for src / quber / review / html.py: 44%
73 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
1"""Before/after extraction review HTML.
3One row per table, two columns: the page cropped around the table with its
4Set-of-Mark bounding box drawn (``before``), beside the final corrected
5markdown (``after``). The tables are supplied already-extracted, so this does
6no extraction of its own -- it only renders what the caller passes in.
7"""
9from __future__ import annotations
11import base64
12import io
13from html import escape
14from pathlib import Path
15from typing import Dict, List, Sequence, Tuple
17from PIL import ImageDraw
18from PIL.Image import Image
20from quber.core.extractors import ExtractedTable
22Box = Tuple[float, float, float, float]
23DEFAULT_DPI = 150
25CSS = """
26body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:24px;color:#1a1a1a;background:#fafafa}
27h1{font-size:22px} h2{margin-top:40px;border-bottom:2px solid #ccc;padding-bottom:4px}
28h3{margin:28px 0 6px;font-size:15px}
29.meta{color:#666;font-size:12px;margin-bottom:6px}
30.row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px;align-items:start;
31 border:1px solid #e0e0e0;border-radius:8px;padding:12px;background:#fff;margin-bottom:18px}
32.col{min-width:0;overflow-x:auto}
33.col h4{margin:0 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:#888}
34.col img{max-width:100%;border:1px solid #ddd}
35table.t{border-collapse:collapse;font-size:11px;width:100%}
36table.t th,table.t td{border:1px solid #ccc;padding:2px 5px;text-align:right;vertical-align:top}
37table.t th{background:#f0f0f0;font-weight:600}
38.legend{font-size:12px;color:#555;margin:8px 0 20px}
39.nomatch{color:#b00;font-style:italic}
40.units{color:#0a5;font-weight:600}
41@media(max-width:900px){.row{grid-template-columns:1fr}.col img{max-width:none;width:100%}}
42"""
45def render_pages(pdf: Path, dpi: int) -> Dict[int, Image]:
46 """Rasterize every page of the PDF to a PIL image keyed by 1-indexed page."""
47 from pdf2image import convert_from_path
49 imgs = convert_from_path(str(pdf), dpi=dpi, fmt="png")
50 return dict(enumerate(imgs, start=1))
53def before_b64(img: Image, box: Box, crop_region: Box) -> str:
54 """Draw `box` on the page and crop around `crop_region` (both normalized
55 0..1, top-left), with a small margin so the box is inset and visible.
57 `box` is the table's final boundary (content_region — the corrected end of
58 the table at its last data row). The crop is taken around the broader
59 `crop_region` (som_region) so the area just below the box, where footnotes
60 sit, stays visible and the box is seen to correctly exclude it."""
61 w_px, h_px = img.size
62 cx0, cy0, cx1, cy1 = crop_region
63 cleft, cright = min(cx0, cx1), max(cx0, cx1)
64 ctop, cbottom = min(cy0, cy1), max(cy0, cy1)
65 mx, my = 0.025, 0.012 # crop margin as a fraction of the page
66 crop_box = (
67 int(max(0.0, cleft - mx) * w_px),
68 int(max(0.0, ctop - my) * h_px),
69 int(min(1.0, cright + mx) * w_px),
70 int(min(1.0, cbottom + my) * h_px),
71 )
72 bx0, by0, bx1, by1 = box
73 bleft, bright = min(bx0, bx1), max(bx0, bx1)
74 btop, bbottom = min(by0, by1), max(by0, by1)
75 canvas = img.copy()
76 ImageDraw.Draw(canvas).rectangle(
77 [bleft * w_px, btop * h_px, bright * w_px, bbottom * h_px], outline=(0, 90, 235), width=3
78 )
79 crop = canvas.crop(crop_box) if crop_box[2] > crop_box[0] and crop_box[3] > crop_box[1] else canvas
80 buf = io.BytesIO()
81 crop.save(buf, "PNG")
82 return base64.b64encode(buf.getvalue()).decode()
85def grid_html(cells: List[List[str]]) -> str:
86 if not cells:
87 return "<em>(empty)</em>"
88 width = max(len(r) for r in cells)
89 rows = []
90 for i, r in enumerate(cells):
91 padded = [escape(str(c)) for c in r] + [""] * (width - len(r))
92 tag = "th" if i == 0 else "td"
93 rows.append("<tr>" + "".join(f"<{tag}>{c}</{tag}>" for c in padded) + "</tr>")
94 return '<table class="t">' + "".join(rows) + "</table>"
97def md_to_html(md: str) -> str:
98 rows = []
99 for line in md.splitlines():
100 line = line.strip()
101 if not line.startswith("|"):
102 continue
103 cells = [c.strip() for c in line.strip("|").split("|")]
104 if cells and all(set(c) <= {"-", ":", " "} for c in cells):
105 continue # delimiter row
106 rows.append(cells)
107 return grid_html(rows)
110def render_review_html(
111 items: Sequence[Tuple[Path, Sequence[ExtractedTable]]],
112 out_path: Path,
113 *,
114 dpi: int = DEFAULT_DPI,
115) -> Path:
116 """Render the before/after review HTML for one or more already-extracted PDFs.
118 ``items`` pairs each source PDF with the tables already extracted from it.
119 One ``<h2>`` section per PDF, one row per table. Returns ``out_path``.
120 """
121 parts = [
122 "<!DOCTYPE html><html><head><meta charset='utf-8'>",
123 "<meta name='viewport' content='width=device-width, initial-scale=1'>",
124 f"<style>{CSS}</style></head><body>",
125 "<h1>Set-of-Mark extraction review — before (table + bounding box) | after (final markdown)</h1>",
126 "<div class='legend'>Left: the page cropped around the table with its final boundary "
127 "(content_region, the corrected end of the table) drawn; the crop extends a little below so "
128 "any footnotes sit visibly outside the box. Right: the final corrected markdown. One row per table.</div>",
129 ]
131 for pdf, tables in items:
132 images = render_pages(pdf, dpi)
133 parts.append(f"<h2>{escape(pdf.name)}</h2>")
134 parts.append(f"<div class='meta'>{len(tables)} tables</div>")
136 for idx, ft in enumerate(tables, start=1):
137 img = images.get(ft.page)
138 # Draw the final boundary (content_region); crop around the broader
139 # som_region so the footnote area below the box stays visible.
140 box = ft.content_region or ft.som_region
141 crop_region = ft.som_region or ft.content_region
142 before = before_b64(img, box, crop_region) if (img is not None and box and crop_region) else None
143 title = escape(ft.title or "(untitled)")
144 parts.append(f"<h3>p{ft.page} · table {idx} · {title}</h3>")
145 units = f"<span class='units'>{escape(ft.units)}</span>" if ft.units else "(none)"
146 parts.append(
147 f"<div class='meta'>units={units} · flavor={ft.flavor} · "
148 f"camelot_accuracy={ft.camelot_accuracy:.1f} · llm_corrected={ft.llm_corrected}</div>"
149 )
150 img_html = (
151 f"<img src='data:image/png;base64,{before}'>"
152 if before
153 else "<span class='nomatch'>no Set-of-Mark region</span>"
154 )
155 parts.append(
156 "<div class='row'>"
157 f"<div class='col'><h4>before — table + bounding box</h4>{img_html}</div>"
158 f"<div class='col'><h4>after — final markdown</h4>{md_to_html(ft.markdown)}</div>"
159 "</div>"
160 )
162 parts.append("</body></html>")
163 out_path.write_text("".join(parts))
164 return out_path