package api import ( "net/http" "strconv" ) // ocrWordResponse is the wire shape of one OCR word box. It is a dedicated DTO // (not storage.OCRWord, which carries no json tags and exposes the internal row // id / document_id) so the overlay renderer gets a compact, stable payload. // Coordinates are in the original file's coordinate space — see // internal/ocr/coords.go. type ocrWordResponse struct { Text string `json:"text"` Left int `json:"left"` Top int `json:"top"` Width int `json:"width"` Height int `json:"height"` Confidence float64 `json:"confidence"` Page int `json:"page"` Block int `json:"block"` Par int `json:"par"` Line int `json:"line"` } // handleListDocumentOCRWords returns the stored word-level bounding boxes of a // document (GET /api/documents/{id}/ocr-words), Phase 3 of the OCR // text-highlight/overlay feature. // // Tenant/ACL: ocr_words has no tenant_id column, access is only ever mediated // through document_id. The handler therefore performs the exact same ownership // check as handleDocumentAuditLog / handleGetDocumentFile — GetDocument(id, // tenantID) filters WHERE tenant_id and yields 404 for both "unknown id" and // "foreign tenant", so the endpoint never reveals whether a document exists // outside the caller's tenant — BEFORE any ocr_words row is read. // // Pure read: no audit entry, consistent with the other document GET handlers. // Empty result serializes as [] (never null). func (s *Server) handleListDocumentOCRWords(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || sess.TenantID == nil { writeError(w, http.StatusBadRequest, "invalid document id") return } // ACL/tenant check first — must precede the ocr_words lookup. if _, err := s.store.GetDocument(r.Context(), id, *sess.TenantID); err != nil { writeError(w, http.StatusNotFound, "document not found") return } words, err := s.store.ListOCRWords(r.Context(), id) if err != nil { s.reqLog(r.Context()).Error("list ocr words failed", "document_id", id, "tenant_id", *sess.TenantID, "err", err) writeError(w, http.StatusInternalServerError, "list ocr words failed") return } out := make([]ocrWordResponse, 0, len(words)) for _, wd := range words { out = append(out, ocrWordResponse{ Text: wd.Word, Left: wd.Left, Top: wd.Top, Width: wd.Width, Height: wd.Height, Confidence: wd.Confidence, Page: wd.Page, Block: wd.Block, Par: wd.Par, Line: wd.Line, }) } writeJSON(w, http.StatusOK, out) }