diff --git a/backend/alembic/versions/l2c3d4e5f6a7_media_source.py b/backend/alembic/versions/l2c3d4e5f6a7_media_source.py new file mode 100644 index 0000000..48c978b --- /dev/null +++ b/backend/alembic/versions/l2c3d4e5f6a7_media_source.py @@ -0,0 +1,40 @@ +"""Where an image came from, and what has been marked on it + +`source` is the citation as it should be read — figure, authors, publication, +licence — and `source_url` is where to check it. `overlay` holds the regions an +educator has marked on the image, as vector shapes in normalised coordinates so +they land in the right place at any size. Both on the asset rather than +typed into each caption: the same figure used in three articles is cited the +same way in all three, and a licence that turns out to be wrong is one row to +fix rather than three paragraphs to find. + +Revision ID: l2c3d4e5f6a7 +Revises: k1b2c3d4e5f6 +""" +import sqlalchemy as sa +from alembic import op + +revision = "l2c3d4e5f6a7" +down_revision = "k1b2c3d4e5f6" +branch_labels = None +depends_on = None + +COLUMNS = { + "source": sa.Column("source", sa.Text(), nullable=True), + "source_url": sa.Column("source_url", sa.String(length=600), nullable=True), + # Vector shapes in normalised coordinates, not a second raster: an overlay + # has to be switchable, correctable and correct at any size. + "overlay": sa.Column("overlay", sa.JSON(), nullable=True), +} + + +def upgrade() -> None: + existing = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("media_assets")} + for name, column in COLUMNS.items(): + if name not in existing: + op.add_column("media_assets", column) + + +def downgrade() -> None: + for name in COLUMNS: + op.drop_column("media_assets", name) diff --git a/backend/app/models/media.py b/backend/app/models/media.py index 082bf69..901289d 100644 --- a/backend/app/models/media.py +++ b/backend/app/models/media.py @@ -1,6 +1,7 @@ from datetime import datetime -from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy import (Column, DateTime, ForeignKey, Integer, JSON, String, Text, + UniqueConstraint) from app.database import Base from app.models.embeddable import Embeddable @@ -21,6 +22,21 @@ class MediaAsset(Base, Embeddable): title = Column(String(300), nullable=True) caption = Column(Text, nullable=True) alt_text = Column(Text, nullable=True) + # Where the image came from, and where to check that. Kept on the asset + # rather than typed into a caption because a citation belongs to the file: + # the same figure used in three articles is cited the same way in all + # three, and a licence that turns out to be wrong is one row to fix. + source = Column(Text, nullable=True) + source_url = Column(String(600), nullable=True) + # Regions an educator has marked on the image, as vector shapes in + # normalised coordinates — see `docs/image-overlays.md`. Vectors rather + # than a second burnt-in picture: a raster overlay cannot be turned off, + # cannot be corrected without redrawing it, doubles the storage, and looks + # wrong at any size but the one it was drawn at. + # + # {"shapes": [{"kind": "path", "points": [[0.31, 0.52], …], + # "color": "#5eead4", "width": 0.006, "label": "lead lines"}]} + overlay = Column(JSON, nullable=True) kind = Column(String(20), default="image") library_id = Column(Integer, ForeignKey("media_libraries.id", ondelete="SET NULL"), nullable=True, index=True) # Which backend holds the bytes; reads fall back to the volume either way. diff --git a/backend/app/routers/media.py b/backend/app/routers/media.py index 7fe3aef..27c31d6 100644 --- a/backend/app/routers/media.py +++ b/backend/app/routers/media.py @@ -70,6 +70,9 @@ def _asset_json(asset: MediaAsset, tags: list[str]) -> dict: "title": asset.title, "caption": asset.caption, "alt_text": asset.alt_text, + "source": asset.source, + "source_url": asset.source_url, + "overlay": asset.overlay, "kind": asset.kind, "library_id": asset.library_id, "category_id": asset.category_id, @@ -191,6 +194,31 @@ def list_media( {**_asset_json(a, tags.get(a.id, [])), "used_by": used.get(a.id, 0)} for a in assets]} +@router.get("/by-path") +def media_by_path(path: str = Query(...), db: Session = Depends(get_db), + current_user: User = Depends(get_current_user)): + """What is known about the image at this path: label, description, source. + + The reader's viewer asks for this when a figure is opened, not when the page + is drawn — a page of prose with six figures in it should cost six requests + only if somebody opens all six. + + Answers 404 rather than 403 for an image in a library this person cannot + use: whether a private library holds a given filename is not a question + this endpoint should answer. + """ + cleaned = (path or "").strip().lstrip("/") + if cleaned.startswith("uploads/"): + cleaned = cleaned[len("uploads/"):] + asset = db.query(MediaAsset).filter(MediaAsset.path == cleaned).first() + if not asset: + raise HTTPException(404, "Image not found") + scope = readable_libraries(db, current_user) + if scope is not None and asset.library_id is not None and asset.library_id not in scope: + raise HTTPException(404, "Image not found") + return _asset_json(asset, _tags_for(db, [asset.id]).get(asset.id, [])) + + @router.post("/upload", status_code=201) def upload_media( file: UploadFile = File(...), @@ -198,6 +226,8 @@ def upload_media( title: str | None = Form(None), caption: str | None = Form(None), alt_text: str | None = Form(None), + source: str | None = Form(None), + source_url: str | None = Form(None), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -246,6 +276,10 @@ class MediaUpdate(BaseModel): title: str | None = None caption: str | None = None alt_text: str | None = None + source: str | None = None + source_url: str | None = None + #: Vector shapes in normalised coordinates; see docs/image-overlays.md. + overlay: dict | None = None library_id: int | None = None category_id: int | None = None tags: list[str] | None = None diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index 96e6205..3d60393 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -4,7 +4,7 @@ import logging import time import os -from sqlalchemy import text as sa_text +from sqlalchemy import or_, text as sa_text from app.tasks import celery_app from app.database import SessionLocal @@ -351,6 +351,131 @@ def extract_quiz( db.close() +#: What each label means, in the prompt and in the docs, so the three words +#: mean the same thing to the model, to the learner reading a filter, and to +#: whoever disagrees with a label later. +DIFFICULTY_RUBRIC = """easy — one step. The stem names a classic presentation and the answer is + recall: a diagnosis with a pathognomonic finding, a first-line drug, a + standard schedule. A prepared candidate answers without working anything out. +medium — two steps, or one step under noise. The finding has to be interpreted + before it can be used, a distractor is genuinely plausible, or the question + asks for the *next* action rather than the diagnosis. +hard — several steps, or a judgement. An atypical presentation, a decision + where two answers are defensible and one is better, an exception to the rule + the candidate learned, or arithmetic on top of interpretation.""" + +DIFFICULTY_PROMPT = """You are labelling pediatric board-exam questions by how hard they are to +answer correctly, for a candidate who has studied the material. + +Judge the *question*, not the topic. A rare disease with a give-away finding is +easy; a common one where two managements are defensible is not. + +""" + DIFFICULTY_RUBRIC + """ + +Return ONLY a JSON array, one object per question, no prose: +[{"id": 12, "difficulty": "easy"}, ...] +Every id you were given must appear exactly once. + +QUESTIONS +""" + +#: Small enough that one bad reply costs little and the JSON stays inside the +#: reply limit; large enough that 3,000 questions is a hundred calls and not +#: three thousand. +DIFFICULTY_BATCH = 25 + + +@celery_app.task(name="classify_question_difficulty", bind=True) +def classify_question_difficulty(self, job_id: str = "", limit: int | None = None, + relabel: bool = False) -> dict: + """Give every question a difficulty, in batches, resumably. + + The column existed and was NULL on all 2,924 rows, which made the filter in + the test builder a control that could only ever empty the bank, and made + "move the session along the difficulty range" impossible to build. Nothing + was ever going to write it by hand. + + Resumable by construction: only rows with no difficulty are asked about + unless `relabel` is set, so a run that dies halfway is continued by running + it again. Each batch is committed on its own — a failure late in a long run + keeps everything the earlier batches decided. + """ + from app.models.question import Question + from app.services.ai_service import get_model_for_task, chat + + r = _redis() + if job_id: + r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS) + db = SessionLocal() + labelled, failed_batches = 0, 0 + try: + model_id, api_key = get_model_for_task(db, "keyword") + query = db.query(Question).order_by(Question.id) + if not relabel: + query = query.filter(or_(Question.difficulty.is_(None), Question.difficulty == "")) + rows = query.limit(limit).all() if limit else query.all() + total = len(rows) + if job_id: + _push_step(r, job_id, "start", f"{total} questions to label") + + for start in range(0, total, DIFFICULTY_BATCH): + batch = rows[start:start + DIFFICULTY_BATCH] + payload = [] + for question in batch: + options = question.options if isinstance(question.options, list) else [] + payload.append({ + "id": question.id, + # Trimmed: the shape of the reasoning is in the first + # paragraph and the options, and a full vignette times + # twenty-five is a reply nobody needs to pay for. + "stem": (question.question_text or "")[:700], + "options": [str(option)[:120] for option in options[:6]], + "answer": (question.correct_answer or "")[:120], + }) + prompt = DIFFICULTY_PROMPT + json.dumps(payload, ensure_ascii=False) + try: + raw = (chat(model=model_id, messages=[{"role": "user", "content": prompt}], + max_tokens=1500, temperature=0, api_key=api_key) or "").strip() + if raw.startswith("```"): + raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:] + raw = raw[:-3] if raw.endswith("```") else raw + verdicts = json.loads(raw.strip()) + except Exception as exc: + failed_batches += 1 + logger.warning("Difficulty batch at %s failed: %s", start, exc) + if job_id: + _push_step(r, job_id, "warn", f"A batch of {len(batch)} could not be read; skipped") + continue + + by_id = {question.id: question for question in batch} + for verdict in verdicts if isinstance(verdicts, list) else []: + question = by_id.get(verdict.get("id")) + level = str(verdict.get("difficulty", "")).strip().lower() + # Anything that is not one of the three words is not a label. + # A model that answers "moderate" has not answered. + if question is None or level not in ("easy", "medium", "hard"): + continue + question.difficulty = level + labelled += 1 + db.commit() + if job_id: + _push_step(r, job_id, "batch", + f"{min(start + DIFFICULTY_BATCH, total)} of {total} · {labelled} labelled") + + if job_id: + r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS) + _push_step(r, job_id, "done", f"{labelled} questions labelled") + return {"labelled": labelled, "considered": total, "failed_batches": failed_batches} + except Exception as exc: + logger.warning("Difficulty run failed: %s", exc) + if job_id: + r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS) + r.set(f"extraction:error:{job_id}", str(exc)[:300], ex=EXPIRE_SECONDS) + raise + finally: + db.close() + + @celery_app.task(name="apply_topic_claims") def apply_topic_claims() -> dict: """Re-apply every article's topic claim. The safety net, not the mechanism. diff --git a/docs/TODO.md b/docs/TODO.md index 5d4c33b..1e6e5f9 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -88,7 +88,15 @@ Captured so nothing is lost while the article writing runs. that system exists to hold them, which is a classification gap rather than a filing error. `Radial Head Subluxation` merged into `Nursemaid's Elbow (Radial Head Subluxation)` — one idea under two names. -- [x] **Adaptive session** — closed 2026-09-12, both remaining halves settled. +- [ ] **Adaptive session: the difficulty ladder** — reopened 2026-09-12, hours + after being closed as unbuildable, because the reason it was unbuildable + is gone: every one of the 2,924 questions now carries a difficulty (622 + easy, 1,634 medium, 668 hard, no failures), labelled in batches of 25 + against a written rubric by `classify_question_difficulty`. The ordering + can now move a session along the range as a learner does well or badly, + which is the thing the design has always been missing. The rest of the + item, below, stays closed. +- [x] **Adaptive session (the other halves)** — closed 2026-09-12. *Shrunk readiness*: already done and now pinned by a test — `CandidateRanking.accuracy()` pulls a topic towards `NEUTRAL_RECALL` by `PRIOR_ANSWERS`, and the recommendations page sorts on `readiness` and @@ -100,8 +108,8 @@ Captured so nothing is lost while the article writing runs. would be scoring noise while looking as though it worked. What was built instead is honesty in the control: the Difficulty facet now counts each level under the other filters and disables one that would empty the - bank, so nobody picks Hard and watches the count fall to zero. Reopen - when something actually writes that column. + bank, so nobody picks Hard and watches the count fall to zero. **That + column is now written** — see the reopened item above. ### Settings and access - [x] **Settings revamp** — done 2026-09-11. A section list beside one panel, @@ -308,7 +316,22 @@ Captured so nothing is lost while the article writing runs. Pillow therefore means re-pinning litellm first, which is a deliberate upgrade of the AI layer and not a side effect to slip into a thumbnail change. -- [ ] ~~Image thumbnails and caching~~ — you mentioned a tool from the ped-ai +- [x] **Image thumbnails** — done, and the note was stale: `services/thumbnails.py` + makes 256px and 640px WebP derivatives with Pillow (not sharp — sharp is + Node and this backend is Python), stores them beside the original under + `thumbs/{width}/{key}` in the same bucket, and `/uploads/...?w=256` + serves them. Only those two widths exist, nothing is ever enlarged, and a + derivative that cannot be made falls back to the original rather than + failing the request. Every figure in prose now asks for the 256 and opens + the original on a click. + *Still open, deliberately:* HTTP caching. Uploads answer + `Cache-Control: private, no-store`, so nothing is cached anywhere. A + shared Caddy cache in front of access-controlled images would be a leak; + what is safe is `private, max-age=…` on the derivatives, which is the + browser's own cache and no one else's. And "make originals hard to pull" + honestly means a signed short-lived URL per view — watermarking and + right-click blocking are not that. +- [ ] ~~Image caching through Caddy~~ — you mentioned a tool from the ped-ai work that generates thumbnails and caches through Caddy so images load fast, click to open full size, same bucket, and no straightforward download of the original. Not started: I need the name of that tool or a diff --git a/docs/image-overlays.md b/docs/image-overlays.md new file mode 100644 index 0000000..822a865 --- /dev/null +++ b/docs/image-overlays.md @@ -0,0 +1,60 @@ +# Image overlays + +An educator can mark regions on an image — the lead lines on a knee film, the +level of a narrowing, the border of a lesion — and a learner sees them only when +they ask. This is the contract for how those marks are stored and drawn. + +## Why vectors and not a second picture + +The obvious implementation is a second PNG with the marks burnt in, shown +instead of the original. It fails four ways: it cannot be turned off (which is +the whole point — the learner should look first and check second), it cannot be +corrected without redrawing, it doubles the storage for every marked image, and +it is correct at exactly one size. Vectors have none of those problems and draw +as crisply on a phone as on a monitor. + +## The shape of the data + +`media_assets.overlay`, JSON, null when nothing is marked: + +```json +{ + "shapes": [ + { + "kind": "path", + "points": [[0.31, 0.52], [0.34, 0.51], [0.38, 0.53]], + "color": "#5eead4", + "width": 0.006, + "label": "Dense metaphyseal bands" + }, + { "kind": "rect", "x": 0.12, "y": 0.4, "w": 0.2, "h": 0.15, "color": "#5eead4" }, + { "kind": "ellipse", "cx": 0.5, "cy": 0.5, "rx": 0.1, "ry": 0.08, "color": "#f0a53d" }, + { "kind": "arrow", "points": [[0.2, 0.2], [0.4, 0.35]], "color": "#5eead4" } + ] +} +``` + +**Every coordinate is normalised to 0–1** against the image's own width and +height — never pixels. That is what makes one drawing correct in a 256px +thumbnail, in the viewer, and on a projector. `width` is normalised too, against +the image width, so a stroke stays the same relative weight. + +Four kinds, and no more without a reason: `path` (freehand or polyline, the one +that traces an anatomical edge), `rect`, `ellipse`, `arrow` (a `path` of exactly +two points, drawn with a head). `label` is optional on any shape and is what a +screen reader is given. + +## Drawing it + +An SVG with `viewBox="0 0 1 1"` and `preserveAspectRatio="none"`, absolutely +positioned over the image at the same size. Because the viewBox is the unit +square, the stored numbers are the SVG's own coordinates and no conversion is +needed at any size. + +## Rules + +- **Off by default.** The learner sees the image, and turns the overlay on. + Marks shown before the learner has looked answer the question for them. +- **The original is never modified.** Nothing here writes to the image file. +- **An overlay is not a caption.** What the marks *mean* belongs in the + description; the overlay says where. diff --git a/frontend/src/components/ImageFigure.css b/frontend/src/components/ImageFigure.css new file mode 100644 index 0000000..b65a102 --- /dev/null +++ b/frontend/src/components/ImageFigure.css @@ -0,0 +1,75 @@ +/* Small until asked for. The caption is the author's own alt text, so what a + reader sees under the figure and what a screen reader is told are the same + sentence — and there is no second field to keep in step with the first. */ +.imgfig { display: inline-flex; flex-direction: column; gap: 5px; margin: 10px 12px 10px 0; vertical-align: top; max-width: 100%; } +.imgfig-thumb { + display: block; padding: 0; cursor: zoom-in; line-height: 0; + border: 1px solid var(--border); border-radius: 8px; overflow: hidden; + background: var(--card-bg); +} +.imgfig-thumb:hover { border-color: var(--primary); } +.imgfig-thumb img { display: block; max-width: 260px; max-height: 200px; width: auto; height: auto; } +.imgfig-label { max-width: 260px; font-size: 0.78rem; line-height: 1.4; color: var(--text-muted); } + +/* ── The viewer ─────────────────────────────────────────────────────── + The image gets the screen, and what is known about it sits beside it. Dark, + because a radiograph read against a white page is read through glare. */ +.imgfig-viewer { + position: fixed; inset: 0; z-index: 1200; + display: flex; flex-direction: column; + background: #0b1220; color: #e2e8f0; +} +.imgfig-bar { + display: flex; align-items: center; gap: 10px; + padding: 10px 14px; border-bottom: 1px solid rgba(255, 255, 255, .1); +} +.imgfig-tool { + display: inline-flex; align-items: center; gap: 7px; + padding: 6px 13px; cursor: pointer; font: inherit; font-size: 0.83rem; font-weight: 600; + border: 1px solid rgba(255, 255, 255, .22); border-radius: 8px; + background: transparent; color: #e2e8f0; +} +.imgfig-tool:hover { border-color: #5eead4; color: #5eead4; } +.imgfig-tool.is-on { border-color: #5eead4; color: #0b1220; background: #5eead4; } +.imgfig-close { + margin-left: auto; width: 34px; height: 34px; cursor: pointer; + border: 0; border-radius: 8px; background: transparent; color: #e2e8f0; font-size: 1rem; +} +.imgfig-close:hover { background: rgba(255, 255, 255, .12); } + +.imgfig-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 320px minmax(0, 1fr); } +.imgfig-desc { + display: flex; flex-direction: column; gap: 14px; + padding: 26px 24px; overflow-y: auto; + border-right: 1px solid rgba(255, 255, 255, .1); +} +.imgfig-desc strong { font-size: 1.05rem; font-weight: 700; } +.imgfig-desc-text { font-size: 0.92rem; line-height: 1.6; color: #cbd5e1; } +.imgfig-source { font-size: 0.78rem; line-height: 1.55; color: #94a3b8; } +.imgfig-source a { color: #94a3b8; } + +.imgfig-stage { + display: flex; align-items: center; justify-content: center; + padding: 20px; overflow: auto; cursor: zoom-out; +} +/* The frame is what the overlay is positioned against, so it must be exactly + the size of the drawn image and no larger — hence a block that shrinks to + its content rather than a flex child that stretches. */ +.imgfig-frame { position: relative; display: block; line-height: 0; cursor: default; } +.imgfig-frame img { display: block; max-width: 100%; max-height: calc(100dvh - 110px); width: auto; height: auto; } +.imgov { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; } + +@media (max-width: 820px) { + /* Stacked, description first: it can be read before scrolling to the + picture rather than after it, and on a phone the picture wants the width + more than the text does. */ + .imgfig-body { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); overflow-y: auto; } + .imgfig-desc { border-right: 0; border-bottom: 1px solid rgba(255, 255, 255, .1); padding: 16px 16px 14px; gap: 10px; } + .imgfig-stage { padding: 12px; } + .imgfig-frame img { max-height: none; } +} + +@media (max-width: 560px) { + .imgfig { margin-right: 0; } + .imgfig-thumb img, .imgfig-label { max-width: 100%; } +} diff --git a/frontend/src/components/ImageFigure.jsx b/frontend/src/components/ImageFigure.jsx new file mode 100644 index 0000000..ba86d13 --- /dev/null +++ b/frontend/src/components/ImageFigure.jsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from 'react' +import api from '../api/client' +import { uploadUrl } from '../utils/uploads' +import ImageOverlay from './ImageOverlay' +import './ImageFigure.css' + +/** + * A figure in prose: small until you ask for it, and then the whole screen. + * + * Every image written into markdown — an article's figure, the illustration on + * an option's explanation, a diagram in a lesson — used to render at whatever + * width it happened to be, which on a 2,000px radiograph meant a wall of + * greyscale in the middle of a sentence and four megabytes to draw it. A + * reader wants to know a figure is there, and then to look at it properly. + * + * So: a 256px derivative with the author's label under it, and on a click a + * viewer that gives the image the screen and puts what is known about it + * beside it — what it shows, and where it came from. What the bank knows is + * asked for when the figure is opened rather than when the page is drawn: six + * figures in an article should cost six requests only if somebody opens all + * six. + * + * Anything an educator has marked on the image is off until the learner turns + * it on. Marks shown before they have looked answer the question for them. + */ +export default function ImageFigure({ src, alt = '', attemptId, className = '' }) { + const [open, setOpen] = useState(false) + const [asset, setAsset] = useState(null) + const [marked, setMarked] = useState(false) + + useEffect(() => { + if (!open) return undefined + const onKey = event => { if (event.key === 'Escape') setOpen(false) } + document.addEventListener('keydown', onKey) + // The page behind must not scroll while the viewer has the screen. + const previous = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.removeEventListener('keydown', onKey) + document.body.style.overflow = previous + } + }, [open]) + + useEffect(() => { + if (!open || asset !== null || !src) return undefined + let live = true + api.get('/media/by-path', { params: { path: src } }) + // `false` rather than null: asked and there is nothing, so do not ask again. + .then(res => { if (live) setAsset(res.data || false) }) + .catch(() => { if (live) setAsset(false) }) + return () => { live = false } + }, [open, asset, src]) + + if (!src) return null + const label = (alt || '').trim() + const title = (asset && asset.title) || label + const description = (asset && (asset.caption || asset.alt_text)) || (title === label ? '' : label) + const overlay = asset && asset.overlay + const hasShapes = !!(overlay && Array.isArray(overlay.shapes) && overlay.shapes.length) + + return ( + <> + + + {label && {label}} + + + {open && ( + + + {hasShapes && ( + + )} + + + + + {/* The description beside the image on a wide screen, above it on a + narrow one — where it can be read before scrolling to the + picture, rather than after it. */} + {(title || description || (asset && asset.source)) && ( + + {title && {title}} + {description && {description}} + {asset && asset.source && ( + + Source: {asset.source_url + ? {asset.source} + : asset.source} + + )} + + )} + { + if (event.target === event.currentTarget) setOpen(false) + }}> + + {title + {marked && hasShapes && } + + + + + )} + + ) +} diff --git a/frontend/src/components/ImageOverlay.jsx b/frontend/src/components/ImageOverlay.jsx new file mode 100644 index 0000000..c873c80 --- /dev/null +++ b/frontend/src/components/ImageOverlay.jsx @@ -0,0 +1,53 @@ +/** + * The regions an educator has marked on an image. + * + * An SVG on the unit square laid over the picture: because the viewBox is + * `0 0 1 1` and the stored coordinates are normalised, the numbers in the + * database *are* the SVG's coordinates, and one drawing is correct in a + * thumbnail, in the viewer and on a projector with no conversion anywhere. + * + * See `docs/image-overlays.md` for the shape of the data. + */ +const DEFAULT_COLOR = '#5eead4' +const DEFAULT_WIDTH = 0.006 + +const points = (shape) => (Array.isArray(shape.points) ? shape.points : []) + .filter(point => Array.isArray(point) && point.length === 2) + +export default function ImageOverlay({ overlay }) { + const shapes = (overlay && Array.isArray(overlay.shapes)) ? overlay.shapes : [] + if (!shapes.length) return null + + return ( + + ) +} diff --git a/frontend/src/components/RichText.jsx b/frontend/src/components/RichText.jsx index 986582b..6b10dba 100644 --- a/frontend/src/components/RichText.jsx +++ b/frontend/src/components/RichText.jsx @@ -9,7 +9,7 @@ import rehypeHighlightOffsets from '../utils/highlightOffsets' import remarkTipTerms from '../utils/tipTerms' import remarkKeyPoints from '../utils/keyPoints' import TipTerm from './TipTerm' -import { markdownImageUrl } from '../utils/uploads' +import ImageFigure from './ImageFigure' import './RichText.css' /** @@ -81,7 +81,12 @@ export default function RichText({ // type every render, and React unmounts and remounts everything it drew — so // an open tip closed itself each time the exam clock ticked. const components = useMemo(() => ({ - img: ({ node, src, ...props }) => , + // Every figure in prose is a thumbnail with the author's label under it, + // full size on a click. A 2,000px radiograph rendered inline was a wall of + // greyscale in the middle of a sentence, and four megabytes to draw it. + img: ({ node, src, alt, ...props }) => ( + + ), // `{{phrase|tip}}` — a teaching point that opens where the phrase is. span: ({ node, children, ...props }) => ( props.className === 'tip-term' diff --git a/frontend/src/components/RichText.test.jsx b/frontend/src/components/RichText.test.jsx index e8f42f2..886a1c1 100644 --- a/frontend/src/components/RichText.test.jsx +++ b/frontend/src/components/RichText.test.jsx @@ -3,9 +3,14 @@ import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter } from 'react-router-dom' import RichText from './RichText' +import api from '../api/client' vi.mock('../api/client', () => ({ default: { get: vi.fn(() => Promise.resolve({ data: {} })) } })) -vi.mock('../utils/uploads', () => ({ markdownImageUrl: (src) => `/uploads/${src}` })) +vi.mock('../utils/uploads', () => ({ + markdownImageUrl: (src) => `/uploads/${src}`, + uploadUrl: (src, attemptId, width) => `/uploads/${src}${width ? `?w=${width}` : ''}`, + THUMB_WIDTHS: [256, 640], +})) const mount = (props) => render() @@ -161,3 +166,60 @@ describe('==key points==', () => { expect(container.querySelector('mark')).toBeNull() }) }) + +describe('figures in prose', () => { + it('renders a thumbnail with the label under it, and the full image on a click', async () => { + const { container } = mount({ value: '![Lateral neck film showing the thumb sign](radiograph.png)' }) + const thumb = container.querySelector('.imgfig-thumb img') + // A derivative, not the original: a 2MB radiograph to draw a postage stamp + // is what this exists to stop. + expect(thumb).toHaveAttribute('src', expect.stringContaining('w=256')) + expect(thumb).toHaveAttribute('alt', 'Lateral neck film showing the thumb sign') + // The author's own alt text is the caption, so the sighted reader and the + // screen reader are told the same thing. + expect(screen.getByText('Lateral neck film showing the thumb sign')).toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: /Open the figure/ })) + const viewer = screen.getByRole('dialog') + // Full size in the viewer — no width asked for. + expect(within(viewer).getByRole('img')).toHaveAttribute('src', expect.not.stringContaining('w=')) + await userEvent.click(within(viewer).getByRole('button', { name: 'Close' })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('leaves an unlabelled figure without a caption', () => { + const { container } = mount({ value: '![](chart.png)' }) + expect(container.querySelector('.imgfig-thumb')).toBeInTheDocument() + expect(container.querySelector('.imgfig-label')).toBeNull() + }) +}) + +describe('the figure viewer', () => { + it('shows what the bank knows: the label, the description and the source', async () => { + api.get.mockResolvedValue({ data: { + title: 'Lead poisoning', caption: 'X-ray of both knees showing dense metaphyseal bands.', + source: 'Ying et al., BMC Pediatrics, CC BY 4.0', source_url: 'https://example.test/paper', + overlay: { shapes: [{ kind: 'path', points: [[0.3, 0.5], [0.6, 0.5]] }] }, + } }) + mount({ value: '![Knee film](lead.png)' }) + await userEvent.click(screen.getByRole('button', { name: /Open the figure/ })) + const viewer = await screen.findByRole('dialog', { name: 'Lead poisoning' }) + expect(within(viewer).getByText('X-ray of both knees showing dense metaphyseal bands.')).toBeInTheDocument() + expect(within(viewer).getByRole('link', { name: /Ying et al/ })).toHaveAttribute('href', 'https://example.test/paper') + + // What an educator marked is off until it is asked for: marks shown before + // the learner has looked answer the question for them. + expect(document.querySelector('.imgov')).toBeNull() + await userEvent.click(within(viewer).getByRole('button', { name: /Overlay/ })) + expect(document.querySelector('.imgov path')).toBeInTheDocument() + }) + + it('offers no overlay control when nothing has been marked', async () => { + api.get.mockResolvedValue({ data: { title: 'A plain figure' } }) + mount({ value: '![Plain](plain.png)' }) + await userEvent.click(screen.getByRole('button', { name: /Open the figure/ })) + const viewer = await screen.findByRole('dialog') + expect(within(viewer).queryByRole('button', { name: /Overlay/ })).toBeNull() + }) +})