File size: 17,312 Bytes
024d0a0
 
f9784ca
 
024d0a0
f9784ca
 
52301a7
 
f9784ca
 
024d0a0
 
 
 
 
f9784ca
 
 
 
 
 
52301a7
f9784ca
 
52301a7
 
 
 
 
 
024d0a0
f9784ca
52301a7
 
 
 
 
 
 
 
 
 
 
 
 
 
f9784ca
52301a7
f9784ca
024d0a0
f9784ca
 
 
 
 
 
 
 
 
 
024d0a0
 
f9784ca
024d0a0
f9784ca
 
 
 
 
 
 
 
 
 
 
 
024d0a0
52301a7
 
 
 
 
024d0a0
f9784ca
 
 
 
 
 
 
52301a7
 
f9784ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
024d0a0
52301a7
 
024d0a0
c758621
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52301a7
f9784ca
 
 
 
 
 
 
 
 
 
 
 
 
c758621
 
 
f9784ca
 
23e9b61
 
 
52301a7
f9784ca
 
52301a7
f9784ca
badb82d
 
 
f9784ca
 
 
 
 
 
 
 
 
23e9b61
f9784ca
 
23e9b61
 
 
 
 
c758621
 
 
 
 
23e9b61
c758621
 
f9784ca
52301a7
 
f9784ca
 
 
 
 
 
52301a7
c758621
 
f9784ca
 
 
 
 
52301a7
f9784ca
52301a7
f9784ca
 
52301a7
c758621
 
 
f9784ca
 
 
 
52301a7
c758621
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f9784ca
 
 
 
c758621
f9784ca
 
 
 
 
024d0a0
badb82d
024d0a0
 
 
52301a7
024d0a0
 
badb82d
 
 
 
 
 
f9784ca
 
badb82d
 
f9784ca
 
 
c758621
 
 
 
 
 
23e9b61
 
 
c758621
 
 
 
 
 
 
 
 
 
 
 
 
52301a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f9784ca
badb82d
f9784ca
 
badb82d
 
 
 
f9784ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c758621
 
 
 
 
 
 
 
 
 
 
f9784ca
 
23e9b61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
badb82d
f9784ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
024d0a0
f9784ca
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
"""In-memory adapter for Track A's deterministic annotation renderer."""

from __future__ import annotations

import hashlib
import logging
import math
import random
from collections import Counter
from dataclasses import dataclass, field
from typing import Any

import fitz

from app import scribe
from app.pipeline import Margins, find_quote
from engine.validation import sanitize_annotation

logger = logging.getLogger(__name__)


@dataclass
class RenderReport:
    """Content-free diagnostics safe to expose in request metadata."""

    dropped: list[dict[str, Any]] = field(default_factory=list)
    errors: list[dict[str, Any]] = field(default_factory=list)
    annotations_received: int = 0
    quote_annotations: int = 0
    quotes_matched: int = 0
    blank_page_doodles: int = 0

    def metadata(self) -> dict[str, Any]:
        quote_match_percent = (
            round(self.quotes_matched * 100 / self.quote_annotations, 2)
            if self.quote_annotations
            else 100.0
        )
        return {
            "annotations_received": self.annotations_received,
            "quote_annotations": self.quote_annotations,
            "quotes_matched": self.quotes_matched,
            "quote_match_percent": quote_match_percent,
            "dropped_count": len(self.dropped),
            "dropped": self.dropped,
            "drop_reasons": dict(Counter(item["reason"] for item in self.dropped)),
            "error_count": len(self.errors),
            "errors": self.errors,
            "blank_page_doodles": self.blank_page_doodles,
        }


def annotate_bytes(

    pdf_bytes: bytes, annotations: list[dict[str, Any]]

) -> tuple[bytes, RenderReport]:
    """Render safely without writing the uploaded PDF or result to disk.



    Cached/model annotations are validated again here.  Every annotation uses a

    fresh Shape and is exception-isolated; page setup is isolated as well.  A

    malformed mark can therefore be dropped, but cannot fail the document.

    """
    doc = fitz.open(stream=pdf_bytes, filetype="pdf")
    rng = random.Random(hashlib.sha256(pdf_bytes).hexdigest())
    report = RenderReport()

    try:
        by_page: dict[int, list[dict[str, Any]]] = {}
        for candidate in annotations:
            page = candidate.get("page") if isinstance(candidate, dict) else None
            clean = sanitize_annotation(candidate, enforce_quote_words=False)
            if (
                clean is None
                or not isinstance(page, int)
                or isinstance(page, bool)
                or not 1 <= page <= doc.page_count
            ):
                _render_error(report, page, _kind(candidate), "validation_error")
                continue
            clean["page"] = page
            report.annotations_received += 1
            if clean["type"] != "diagram":
                report.quote_annotations += 1
            by_page.setdefault(page - 1, []).append(clean)

        for page_number, page_annotations in sorted(by_page.items()):
            try:
                _render_page(doc[page_number], page_number + 1, page_annotations, rng, report)
            except Exception as exc:  # page-level safety net
                category = _error_category(exc)
                _render_error(report, page_number + 1, "page", category)

        _decorate_blank_interior_pages(doc, rng, report)

        rendered = doc.tobytes(garbage=3, deflate=True, no_new_id=True)
        return rendered, report
    finally:
        doc.close()


def _render_page(page, page_number, annotations, rng, report):
    bounds = fitz.Rect(page.rect)
    margins = Margins(page)
    _clamp_margins(margins, bounds)

    resolved = []
    for annotation in annotations:
        kind = annotation["type"]
        try:
            if kind == "diagram":
                # Reserve scarce margin/bottom space before notes consume it.
                resolved.append((float("-inf"), annotation, None))
                continue
            rects = [
                rect
                for found in find_quote(page, annotation["quote"])
                if (rect := _clamp_rect(found, bounds, _mark_padding(kind))) is not None
            ]
            if not rects:
                _drop(report, page_number, kind, "unmatched_quote")
                continue
            if kind == "bracket":
                start = rects[0]
                end_quote = annotation.get("end_quote")
                if end_quote:
                    endings = [
                        rect
                        for found in find_quote(page, end_quote)
                        if (rect := _clamp_rect(found, bounds, _mark_padding(kind)))
                        is not None
                        and rect.y1 >= start.y0
                    ]
                    if not endings:
                        _drop(report, page_number, kind, "unmatched_end_quote")
                        continue
                    rects = [start, endings[-1]]
                else:
                    rects = [start]
            report.quotes_matched += 1
            resolved.append((rects[0].y0, annotation, rects))
        except Exception as exc:
            _render_error(report, page_number, kind, _error_category(exc))

    for _, annotation, rects in sorted(resolved, key=lambda item: item[0]):
        try:
            _render_one(page, bounds, margins, annotation, rects, rng, report)
        except Exception as exc:  # per-annotation isolation
            _render_error(
                report, page_number, annotation.get("type", "unknown"), _error_category(exc)
            )


def _render_one(page, bounds, margins, annotation, rects, rng, report):
    page_number = page.number + 1
    kind = annotation["type"]
    color = _annotation_color(annotation, rng)
    shape = page.new_shape()  # never share partially-built drawing commands

    if kind == "diagram":
        diagram_layout, diagram_title = _diagram_layout(annotation.get("title"))
        area = _clamp_rect(margins.bottom, bounds, 10)
        diagram_side = "bottom"
        if area is None or area.height < 55:
            side, side_box = margins.side_box()
            diagram_side = side
            box = _clamp_rect(side_box, bounds, 10)
            needed = scribe.diagram_height(
                annotation["labels"], diagram_title, diagram_layout, width=box.width - 4
            )
            if box is None:
                _drop(report, page_number, kind, "no_space")
                return
            y0 = max(margins.cursor[side], box.y1 - needed)
            area = _clamp_rect(fitz.Rect(box.x0 + 2, y0, box.x1 - 2, box.y1), bounds, 10)
            if box.width < 48 or area is None or area.height < needed:
                _drop(report, page_number, kind, "no_space")
                return
            margins.commit(side, area.y1)
        if not _diagram_fits(area, annotation["labels"], diagram_title, diagram_layout):
            _drop(report, page_number, kind, "unsafe_geometry")
            return
        diagram_renderer = {
            "tree": scribe.tree_diagram,
            "math": scribe.equation_diagram,
        }.get(diagram_layout, scribe.chain_diagram)
        diagram_renderer(
            page,
            shape,
            area,
            annotation["labels"],
            rng,
            diagram_title,
            color=color,
            text_color=scribe.INK,
        )
        if diagram_side == "bottom":
            margins.commit("bottom", area.y1)
        shape.commit()
        return

    first = rects[0]
    if kind == "strike":
        for rect in rects:
            scribe.strike(shape, rect, rng, color=color)
        correction = scribe.correction_text(
            page, first, annotation["correction"], rng, page_rect=bounds, color=scribe.INK
        )
        if correction is None:
            _drop(report, page_number, "correction", "no_space")
    elif kind == "underline":
        for rect in rects:
            scribe.underline(shape, rect, rng, double=annotation.get("double", False), color=color)
    elif kind == "circle":
        scribe.circle(shape, first, rng, color=color)
    elif kind == "highlight":
        for rect in rects:
            scribe.highlight(shape, rect, rng, color=color)
    elif kind == "scribble":
        for rect in rects:
            scribe.strike(shape, rect, rng, color=color)
    elif kind == "doodle":
        center = _clamp_point(
            (margins.gutter_x(first), first.y0 + first.height / 2), bounds, 8
        )
        scribe.doodle(shape, center, rng, annotation["symbol"], color=color)
    elif kind == "margin":
        scribe.underline(shape, first, rng, color=color)
    elif kind == "bracket":
        span = fitz.Rect(first)
        for rect in rects[1:]:
            span.include_rect(rect)
        bracket_side = "left" if margins.left.width >= margins.right.width else "right"
        scribe.bracket(shape, span, rng, side=bracket_side, color=color)
    elif kind == "list":
        center = _clamp_point(
            (margins.gutter_x(first), first.y0 + first.height / 2), bounds, 8
        )
        scribe.list_marker(shape, center, rng, color=color)
    elif kind == "checkmark":
        center = _clamp_point(
            (margins.gutter_x(first), first.y0 + first.height / 2), bounds, 8
        )
        scribe.checkmark(shape, center, rng, color=color)
    elif kind == "callout":
        center = _clamp_point(
            (margins.gutter_x(first), first.y0 + first.height / 2), bounds, 8
        )
        scribe.callout_icon(shape, center, rng, annotation["icon"], color=color)

    note = annotation.get("note")
    if kind == "list":
        parts = list(annotation["items"])
        if annotation.get("title"):
            parts.insert(0, annotation["title"])
        note = " | ".join(parts)
    elif kind == "checkmark":
        note = annotation.get("counter")
    if note:
        box, side = margins.place(first.y0, note, rng=rng)
        box = _clamp_rect(box, bounds, 8) if box is not None else None
        if box is None or box.width < 12 or box.height < 8:
            _drop(report, page_number, "note", "no_space")
        else:
            used = scribe.note_text(page, box, note, rng, color=scribe.INK)
            if used is not None:
                used = _clamp_rect(used, bounds, 3)
            if used is None:
                _drop(report, page_number, "note", "unsafe_geometry")
            else:
                margins.commit(side, used.y1)
                if kind in {"circle", "margin", "bracket", "list", "callout"} or len(note.split()) >= 14:
                    if side == "left":
                        src = (used.x1 - 2, used.y0 + 6)
                        dst = (first.x0 - 7, first.y0 + first.height / 2)
                    elif side == "right":
                        src = (used.x0 + 2, used.y0 + 6)
                        dst = (first.x1 + 7, first.y0 + first.height / 2)
                    elif side == "bottom":
                        src = (used.x0 + used.width / 2, used.y0 + 1)
                        dst = (first.x0 + first.width / 2, first.y1 + 5)
                    else:  # top
                        src = (used.x0 + used.width / 2, used.y1 - 1)
                        dst = (first.x0 + first.width / 2, first.y0 - 5)
                    src = _clamp_point(src, bounds, 3)
                    dst = _clamp_point(dst, bounds, 3)
                    if abs(dst[0] - src[0]) < 300 and abs(dst[1] - src[1]) < 220:
                        scribe.arrow(shape, src, dst, rng, color=color)
    shape.commit()


def _annotation_color(annotation: dict[str, Any], rng):
    kind = annotation["type"]
    if kind == "strike":
        return scribe.INK_RED
    if kind == "highlight":
        return {
            "key": scribe.HIGHLIGHT,
            "theory": scribe.HIGHLIGHT_ORANGE,
            "example": scribe.HIGHLIGHT_ORANGE,
            "definition": scribe.HIGHLIGHT_BLUE,
            "evidence": scribe.HIGHLIGHT_GREEN,
            "caution": scribe.HIGHLIGHT_RED,
        }.get(annotation.get("meaning", "key"), scribe.HIGHLIGHT)
    if kind == "diagram":
        return scribe.INK_BLUE
    if kind == "checkmark":
        return scribe.INK_GREEN
    if kind in {"circle", "doodle", "bracket", "list", "callout"}:
        return rng.choice([scribe.INK_BLUE, scribe.INK_GREEN, scribe.INK_PURPLE])
    if kind in {"underline", "margin"}:
        return rng.choice([scribe.INK, scribe.INK_BLUE, scribe.INK_PURPLE])
    return scribe.INK


def _decorate_blank_interior_pages(doc, rng, report: RenderReport) -> None:
    for index in range(1, max(1, doc.page_count - 1)):
        page = doc[index]
        if page.get_text("text").strip():
            continue
        if page.get_images(full=True) or page.get_drawings():
            continue
        shape = page.new_shape()
        scribe.blank_page_doodle(page, shape, fitz.Rect(page.rect), rng)
        shape.commit()
        report.blank_page_doodles += 1


def _clamp_margins(margins: Margins, bounds: fitz.Rect) -> None:
    for name in ("left", "right", "top", "bottom"):
        rect = _clamp_rect(getattr(margins, name), bounds, 8)
        setattr(margins, name, rect or fitz.Rect(bounds.x0, bounds.y0, bounds.x0, bounds.y0))
    margins.cursor["left"] = margins.left.y0
    margins.cursor["right"] = margins.right.y0
    margins.cursor["top"] = margins.top.y0
    margins.cursor["bottom"] = margins.bottom.y0


def _clamp_rect(rect, bounds: fitz.Rect, padding: float = 0) -> fitz.Rect | None:
    if rect is None:
        return None
    candidate = fitz.Rect(rect)
    values = (candidate.x0, candidate.y0, candidate.x1, candidate.y1)
    if not all(math.isfinite(value) for value in values):
        return None
    inner = fitz.Rect(
        bounds.x0 + padding,
        bounds.y0 + padding,
        bounds.x1 - padding,
        bounds.y1 - padding,
    )
    if inner.width <= 0 or inner.height <= 0:
        return None
    clamped = fitz.Rect(
        max(min(candidate.x0, candidate.x1), inner.x0),
        max(min(candidate.y0, candidate.y1), inner.y0),
        min(max(candidate.x0, candidate.x1), inner.x1),
        min(max(candidate.y0, candidate.y1), inner.y1),
    )
    if clamped.width < 0.5 or clamped.height < 0.5:
        return None
    return clamped


def _clamp_point(point, bounds: fitz.Rect, padding: float) -> tuple[float, float]:
    return (
        min(max(float(point[0]), bounds.x0 + padding), bounds.x1 - padding),
        min(max(float(point[1]), bounds.y0 + padding), bounds.y1 - padding),
    )


def _mark_padding(kind: str) -> float:
    return {
        "circle": 24,
        "bracket": 10,
        "doodle": 8,
        "list": 8,
        "checkmark": 8,
        "callout": 8,
        "underline": 4,
        "highlight": 3,
    }.get(kind, 3)


def _diagram_layout(title: str | None) -> tuple[str, str | None]:
    if title:
        for prefix, layout in (("TREE:", "tree"), ("MATH:", "math")):
            if title.upper().startswith(prefix):
                clean_title = title[len(prefix):].strip()
                return layout, clean_title or None
    return "chain", title


def _diagram_fits(
    area: fitz.Rect,
    labels: list[str],
    title: str | None = None,
    layout: str = "chain",
) -> bool:
    if area.width < 48 or area.height < 30:
        return False
    if area.width >= area.height:
        if layout in {"tree", "math"}:
            return area.width >= 150 and area.height >= 48
        widths = [scribe._note_font.text_length(label, 6.5) + 14 for label in labels]
        return sum(widths) + 24 * (len(labels) - 1) <= area.width
    return scribe.diagram_height(labels, title, layout, width=area.width) <= area.height


def _drop(report: RenderReport, page: int, kind: str, reason: str) -> None:
    report.dropped.append({"page": page, "type": kind, "reason": reason})


def _render_error(report: RenderReport, page, kind: str, category: str) -> None:
    safe_page = page if isinstance(page, int) and not isinstance(page, bool) else None
    safe_kind = kind if kind in {
        "underline", "strike", "circle", "highlight", "scribble", "doodle",
        "margin", "diagram", "correction", "note", "page",
    } else "unknown"
    issue = {"page": safe_page, "type": safe_kind, "category": category}
    report.errors.append(issue)
    logger.warning(
        "renderer_annotation_failed page=%s type=%s category=%s",
        safe_page,
        safe_kind,
        category,
    )


def _kind(candidate: Any) -> str:
    if isinstance(candidate, dict) and isinstance(candidate.get("type"), str):
        return candidate["type"]
    return "unknown"


def _error_category(exc: Exception) -> str:
    if isinstance(exc, (TypeError, ValueError, KeyError, IndexError)):
        return "validation_error"
    if isinstance(exc, (MemoryError, OverflowError)):
        return "resource_error"
    return "renderer_error"