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

1"""Before/after extraction review HTML. 

2 

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""" 

8 

9from __future__ import annotations 

10 

11import base64 

12import io 

13from html import escape 

14from pathlib import Path 

15from typing import Dict, List, Sequence, Tuple 

16 

17from PIL import ImageDraw 

18from PIL.Image import Image 

19 

20from quber.core.extractors import ExtractedTable 

21 

22Box = Tuple[float, float, float, float] 

23DEFAULT_DPI = 150 

24 

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""" 

43 

44 

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 

48 

49 imgs = convert_from_path(str(pdf), dpi=dpi, fmt="png") 

50 return dict(enumerate(imgs, start=1)) 

51 

52 

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. 

56 

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() 

83 

84 

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>" 

95 

96 

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) 

108 

109 

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. 

117 

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 &mdash; 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 ] 

130 

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>") 

135 

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} &middot; table {idx} &middot; {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} &middot; flavor={ft.flavor} &middot; " 

148 f"camelot_accuracy={ft.camelot_accuracy:.1f} &middot; 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 &mdash; table + bounding box</h4>{img_html}</div>" 

158 f"<div class='col'><h4>after &mdash; final markdown</h4>{md_to_html(ft.markdown)}</div>" 

159 "</div>" 

160 ) 

161 

162 parts.append("</body></html>") 

163 out_path.write_text("".join(parts)) 

164 return out_path