import io, json from fastapi import FastAPI, Request, Query from fastapi.responses import JSONResponse, HTMLResponse, Response from PIL import Image app = FastAPI() IMG = Image.open("page.jpg").convert("RGB") W, H = IMG.size LINES = [l for l in json.load(open("lines.json")) if l.get("text")] CORS = {"Access-Control-Allow-Origin": "*"} def base(req): return str(req.base_url).rstrip("/") def canvas(b): return f"{b}/canvas/p1" # ---- minimal dynamic IIIF Image API 2.1 (level1) ---- def parse_region(r): if r in ("full", "square"): return (0, 0, W, H) if r.startswith("pct:"): x, y, w, h = [float(v) for v in r[4:].split(",")] return (int(x/100*W), int(y/100*H), int(w/100*W), int(h/100*H)) x, y, w, h = [int(float(v)) for v in r.split(",")] return (x, y, w, h) def parse_size(s, rw, rh): if s in ("full", "max", "^max"): return (rw, rh) if s.startswith("pct:"): p = float(s[4:]) / 100; return (max(1, int(rw*p)), max(1, int(rh*p))) fit = s.startswith("!"); s = s[1:] if fit else s w, h = (s.split(",") + [""])[:2] if w and not h: w = int(w); return (w, max(1, round(rh*w/rw))) if h and not w: h = int(h); return (max(1, round(rw*h/rh)), h) w, h = int(w), int(h) if fit: sc = min(w/rw, h/rh); return (max(1, int(rw*sc)), max(1, int(rh*sc))) return (w, h) @app.get("/iiif/page/info.json") def info(req: Request): b = base(req) return JSONResponse({"@context": "http://iiif.io/api/image/2/context.json", "@id": f"{b}/iiif/page", "@type": "iiif:Image", "protocol": "http://iiif.io/api/image", "width": W, "height": H, "profile": ["http://iiif.io/api/image/2/level1.json"], "tiles": [{"width": 1024, "scaleFactors": [1, 2, 4, 8, 16, 32]}]}, headers=CORS) @app.get("/iiif/page/{region}/{size}/{rotation}/{quality}.{fmt}") def tile(region: str, size: str, rotation: str, quality: str, fmt: str): x, y, w, h = parse_region(region) crop = IMG.crop((x, y, x + w, y + h)) tw, th = parse_size(size, w, h) crop = crop.resize((max(1, tw), max(1, th))) if quality == "gray": crop = crop.convert("L").convert("RGB") buf = io.BytesIO(); crop.save(buf, "JPEG", quality=85) return Response(buf.getvalue(), media_type="image/jpeg", headers=CORS) # ---- Presentation 3.0 manifest (image service + OCR annotations + search service) ---- @app.get("/manifest.json") def manifest(req: Request): b = base(req); c = canvas(b) anns = [] for i, ln in enumerate(LINES): x0, y0, x1, y1 = [int(round(v)) for v in ln["bbox"]] anns.append({"id": f"{b}/anno/l{i}", "type": "Annotation", "motivation": "supplementing", "body": {"type": "TextualBody", "format": "text/plain", "language": "en", "value": ln["text"]}, "target": f"{c}#xywh={x0},{y0},{max(1,x1-x0)},{max(1,y1-y0)}"}) m = {"@context": "http://iiif.io/api/presentation/3/context.json", "id": f"{b}/manifest.json", "type": "Manifest", "label": {"en": ["The Commoner, 1901-01-23, p.1 — Surya OCR 2 (line-level)"]}, "service": [{"id": f"{b}/search", "type": "SearchService1", "profile": "http://iiif.io/api/search/1/search"}], "items": [{"id": c, "type": "Canvas", "width": W, "height": H, "items": [{"id": f"{c}/page", "type": "AnnotationPage", "items": [ {"id": f"{c}/painting", "type": "Annotation", "motivation": "painting", "body": {"id": f"{b}/iiif/page/full/max/0/default.jpg", "type": "Image", "format": "image/jpeg", "width": W, "height": H, "service": [{"id": f"{b}/iiif/page", "type": "ImageService2", "profile": "level1"}]}, "target": c}]}], "annotations": [{"id": f"{c}/ocr", "type": "AnnotationPage", "items": anns}]}]} return JSONResponse(m, headers=CORS) # ---- IIIF Content Search 1.0 (Mirador's search box queries this) ---- @app.get("/search") def search(req: Request, q: str = Query("")): b = base(req); c = canvas(b); ql = q.lower().strip() res = [] for i, ln in enumerate(LINES): if ql and ql in ln["text"].lower(): x0, y0, x1, y1 = [int(round(v)) for v in ln["bbox"]] res.append({"@id": f"{b}/anno/s{i}", "@type": "oa:Annotation", "motivation": "sc:painting", "resource": {"@type": "cnt:ContentAsText", "chars": ln["text"]}, "on": f"{c}#xywh={x0},{y0},{max(1,x1-x0)},{max(1,y1-y0)}"}) return JSONResponse({"@context": "http://iiif.io/api/presentation/2/context.json", "@id": f"{b}/search?q={q}", "@type": "sc:AnnotationList", "within": {"@type": "sc:Layer", "total": len(res)}, "resources": res}, headers=CORS) @app.get("/", response_class=HTMLResponse) def index(): return """