feat: figures are thumbnails that open into a viewer, with source and overlay

A 2,000px radiograph written into an article rendered at whatever width it
happened to be — a wall of greyscale in the middle of a sentence, four
megabytes to draw it, and no way to look at it properly. Every image in prose
is now a 256px thumbnail with the author's label under it, and a click gives it
the screen.

The viewer puts what is known about the figure beside it: its title, what it
shows, and where it came from. `media_assets` gains `source` and `source_url`
for that — a citation belongs to the file, because 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. Asked for
when the figure is opened, not when the page is drawn.

And `overlay`: the regions an educator has marked, as vector shapes in
normalised coordinates on the unit square, so one drawing is correct in a
thumbnail, in the viewer and on a projector. Off until the learner turns it on
— marks shown before they have looked answer the question for them. Vectors
rather than a second burnt-in picture, for four reasons written down in
docs/image-overlays.md. The tool that draws them is next; this is the storage,
the contract and the reader's half.

On a narrow screen the description stacks above the image rather than beside
it, where it can be read before scrolling to the picture.

Also here: `classify_question_difficulty`, which labelled all 2,924 questions
in batches of twenty-five against a written rubric — 622 easy, 1,634 medium,
668 hard, no failed batches. The column had been NULL on every row since it
existed. Migration l2c3d4e5f6a7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 20:33:00 +02:00
parent 0d41179b30
commit 809ac2fca6
11 changed files with 618 additions and 9 deletions

View file

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

View file

@ -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.

View file

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

View file

@ -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.

View file

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

60
docs/image-overlays.md Normal file
View file

@ -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 01** 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.

View file

@ -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%; }
}

View file

@ -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 (
<>
<span className={`imgfig ${className}`.trim()}>
<button type="button" className="imgfig-thumb" onClick={() => setOpen(true)}
aria-label={label ? `Open the figure: ${label}` : 'Open the figure'}>
{/* Lazy: a page of prose with six figures should not fetch six
images to show the first paragraph. */}
<img src={uploadUrl(src, attemptId, 256)} alt={label} loading="lazy" decoding="async" />
</button>
{label && <span className="imgfig-label">{label}</span>}
</span>
{open && (
<span className="imgfig-viewer" role="dialog" aria-modal="true" aria-label={title || 'Figure'}>
<span className="imgfig-bar">
{hasShapes && (
<button type="button" className={`imgfig-tool${marked ? ' is-on' : ''}`}
aria-pressed={marked} onClick={() => setMarked(v => !v)}>
Overlay
</button>
)}
<button type="button" className="imgfig-close" aria-label="Close"
onClick={() => setOpen(false)}></button>
</span>
<span className="imgfig-body">
{/* 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)) && (
<span className="imgfig-desc">
{title && <strong>{title}</strong>}
{description && <span className="imgfig-desc-text">{description}</span>}
{asset && asset.source && (
<span className="imgfig-source">
Source: {asset.source_url
? <a href={asset.source_url} target="_blank" rel="noopener noreferrer">{asset.source}</a>
: asset.source}
</span>
)}
</span>
)}
<span className="imgfig-stage" onClick={event => {
if (event.target === event.currentTarget) setOpen(false)
}}>
<span className="imgfig-frame">
<img src={uploadUrl(src, attemptId)} alt={title || label} />
{marked && hasShapes && <ImageOverlay overlay={overlay} />}
</span>
</span>
</span>
</span>
)}
</>
)
}

View file

@ -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 (
<svg className="imgov" viewBox="0 0 1 1" preserveAspectRatio="none" aria-hidden="true">
<defs>
<marker id="imgov-head" viewBox="0 0 10 10" refX="8" refY="5"
markerWidth="5" markerHeight="5" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="context-stroke" />
</marker>
</defs>
{shapes.map((shape, index) => {
const stroke = shape.color || DEFAULT_COLOR
const width = Number(shape.width) > 0 ? Number(shape.width) : DEFAULT_WIDTH
const common = {
key: index, stroke, strokeWidth: width, fill: 'none',
strokeLinecap: 'round', strokeLinejoin: 'round',
vectorEffect: 'non-scaling-stroke',
}
if (shape.kind === 'rect') {
return <rect {...common} x={shape.x} y={shape.y} width={shape.w} height={shape.h} rx={0.008} />
}
if (shape.kind === 'ellipse') {
return <ellipse {...common} cx={shape.cx} cy={shape.cy} rx={shape.rx} ry={shape.ry} />
}
const list = points(shape)
if (list.length < 2) return null
const d = list.map(([x, y], i) => `${i ? 'L' : 'M'} ${x} ${y}`).join(' ')
if (shape.kind === 'arrow') {
return <path {...common} d={d} markerEnd="url(#imgov-head)" />
}
return <path {...common} d={d} />
})}
</svg>
)
}

View file

@ -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 }) => <img {...props} src={markdownImageUrl(src, attemptId)} />,
// 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 }) => (
<ImageFigure src={src} alt={alt} attemptId={attemptId} />
),
// `{{phrase|tip}}` a teaching point that opens where the phrase is.
span: ({ node, children, ...props }) => (
props.className === 'tip-term'

View file

@ -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(<MemoryRouter><RichText {...props} /></MemoryRouter>)
@ -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()
})
})