diff --git a/backend/scripts/seed_illustrations.py b/backend/scripts/seed_illustrations.py
new file mode 100644
index 0000000..448f3cd
--- /dev/null
+++ b/backend/scripts/seed_illustrations.py
@@ -0,0 +1,222 @@
+"""Draw a set of schematic teaching illustrations and put them in the image bank.
+
+These are *diagrams*, not clinical references: drawn from scratch as SVG, labelled
+as schematic, and deliberately carrying no numbers that anyone should dose or
+diagnose from. They exist so that the media library, the image picker, article
+figures and question attachment can be exercised against real files rather than
+against placeholders — and because an article with no figure looks unfinished
+even when the prose is complete.
+
+Every drawing is generated here in code, so nothing is copied from anywhere.
+
+ docker compose exec backend python -m scripts.seed_illustrations
+ docker compose exec backend python -m scripts.seed_illustrations --apply
+"""
+import sys
+import textwrap
+
+from sqlalchemy import text as sa_text
+
+from app.database import SessionLocal
+from app.models.media import MediaAsset, MediaLibrary, MediaTagLink
+from app.services import embedding_service, storage_service
+
+W, H = 640, 400
+INK = "#0f172a"
+MUTED = "#64748b"
+LINE = "#cbd5e1"
+ACCENT = "#2563eb"
+WARN = "#dc2626"
+GOOD = "#059669"
+
+
+def _frame(title: str, body: str) -> str:
+ """Every figure shares one frame, so a set of them looks like a set."""
+ return f"""
+
+ {title}
+ {body}
+ Schematic — not to scale. Teaching diagram, not a clinical reference.
+ """
+
+
+def _label(x, y, s, size=12, fill=MUTED, weight="400", anchor="start"):
+ return (f'{s} ')
+
+
+def _axes(x0, y0, x1, y1, xlabel, ylabel):
+ return f"""
+
+
+ {_label((x0 + x1) / 2, y1 + 30, xlabel, 12, MUTED, "600", "middle")}
+ {ylabel} """
+
+
+def bilirubin_zones() -> str:
+ body = _axes(70, 60, 590, 320, "Age (hours)", "Serum bilirubin")
+ for index, (offset, colour, name) in enumerate(
+ [(0, WARN, "High risk"), (44, "#f59e0b", "High-intermediate"),
+ (88, "#eab308", "Low-intermediate"), (132, GOOD, "Low risk")]):
+ y_start = 120 + offset
+ body += (f' ')
+ body += _label(596, y_start + 4, name, 11, colour, "600")
+ for hour, x in [(24, 200), (48, 330), (72, 460), (96, 585)]:
+ body += f' ' + \
+ _label(x, 340, str(hour), 11, MUTED, "400", "middle")
+ return _frame("Bilirubin risk zones by age", body)
+
+
+def airway_obstruction() -> str:
+ body = ""
+ for index, (x, label, narrow, colour) in enumerate(
+ [(120, "Normal", 0, GOOD), (320, "Subglottic narrowing", 20, ACCENT),
+ (520, "Supraglottic swelling", 26, WARN)]):
+ body += f' '
+ body += (f' ')
+ body += _label(x, 330, label, 12, colour, "600", "middle")
+ body += _label(x, 78, "airway lumen", 10, MUTED, "400", "middle")
+ body += _label(24, 62, "Where the narrowing sits changes the sound and the urgency.", 12, MUTED)
+ return _frame("Upper airway narrowing: level and lumen", body)
+
+
+def dehydration_scale() -> str:
+ body = _label(24, 62, "Signs accumulate as deficit grows; each band adds to the one before it.", 12, MUTED)
+ bands = [("Minimal", GOOD, ["alert", "moist mucosa", "normal pulse"]),
+ ("Mild to moderate", "#f59e0b", ["restless", "dry mucosa", "reduced urine"]),
+ ("Severe", WARN, ["lethargic", "sunken eyes", "weak pulse", "prolonged refill"])]
+ for index, (name, colour, signs) in enumerate(bands):
+ y = 90 + index * 80
+ body += f' '
+ body += _label(86, y + 24, name, 13, colour, "700")
+ body += _label(86, y + 43, " · ".join(signs), 11, MUTED)
+ return _frame("Dehydration: severity bands", body)
+
+
+def fluid_pathway() -> str:
+ steps = [("Assess perfusion", ACCENT), ("Shock?", WARN), ("Bolus, reassess", WARN),
+ ("Maintenance + deficit", GOOD), ("Reassess hourly", ACCENT)]
+ body = ""
+ for index, (label, colour) in enumerate(steps):
+ y = 80 + index * 56
+ body += f' '
+ body += _label(320, y + 25, label, 13, colour, "600", "middle")
+ if index < len(steps) - 1:
+ body += f' '
+ body = (''
+ f' ') + body
+ return _frame("Fluid resuscitation: order of decisions", body)
+
+
+def target_sign() -> str:
+ body = _label(24, 62, "Bowel within bowel: concentric rings on the transverse view.", 12, MUTED)
+ for radius, colour, opacity in [(110, ACCENT, 0.10), (78, ACCENT, 0.16), (46, ACCENT, 0.24), (18, WARN, 0.30)]:
+ body += (f' ')
+ for index, (label, radius) in enumerate([("outer wall", 110), ("intussuscipiens", 78),
+ ("intussusceptum", 46), ("mesenteric fat", 18)]):
+ y = 130 + index * 46
+ body += f' '
+ body += _label(428, y + 4, label, 11, MUTED, "600")
+ return _frame("Target sign: concentric bowel layers", body)
+
+
+def growth_velocity() -> str:
+ body = _axes(70, 60, 590, 320, "Age (years)", "Growth velocity")
+ body += (' ')
+ for x, label in [(110, "infancy"), (300, "childhood"), (490, "puberty")]:
+ body += _label(x, 344, label, 11, MUTED, "600", "middle")
+ body += f' '
+ body += _label(24, 62, "Three phases, each driven by something different.", 12, MUTED)
+ return _frame("Growth velocity across childhood", body)
+
+
+FIGURES = [
+ ("bilirubin-risk-zones", "Bilirubin risk zones by age", bilirubin_zones,
+ "Schematic of serum bilirubin risk bands plotted against age in hours.",
+ ["neonatal jaundice", "hyperbilirubinemia", "newborn"]),
+ ("airway-narrowing-levels", "Upper airway narrowing by level", airway_obstruction,
+ "Schematic comparing a normal airway lumen with subglottic and supraglottic narrowing.",
+ ["stridor", "croup", "epiglottitis", "airway"]),
+ ("dehydration-severity-bands", "Dehydration severity bands", dehydration_scale,
+ "Schematic of clinical signs grouped by dehydration severity.",
+ ["dehydration", "gastroenteritis", "fluid"]),
+ ("fluid-resuscitation-pathway", "Fluid resuscitation pathway", fluid_pathway,
+ "Schematic order of decisions in paediatric fluid resuscitation.",
+ ["shock", "fluid", "resuscitation"]),
+ ("target-sign-intussusception", "Target sign: concentric bowel layers", target_sign,
+ "Schematic cross-section showing bowel within bowel as concentric rings.",
+ ["intussusception", "ultrasound", "abdominal pain"]),
+ ("growth-velocity-phases", "Growth velocity across childhood", growth_velocity,
+ "Schematic growth velocity curve showing infancy, childhood and pubertal phases.",
+ ["growth", "puberty", "development"]),
+]
+
+
+def main():
+ apply_changes = "--apply" in sys.argv
+ db = SessionLocal()
+ try:
+ library = db.query(MediaLibrary).order_by(MediaLibrary.id).first()
+ if library is None:
+ print(" No image library exists; create one first.")
+ return 1
+
+ made = skipped = 0
+ for slug, title, draw, caption, tags in FIGURES:
+ key = f"media/illustrations/{slug}.svg"
+ if db.query(MediaAsset.id).filter(MediaAsset.path == key).first():
+ print(f" exists {slug}")
+ skipped += 1
+ continue
+ svg = draw().encode()
+ print(f" draw {slug} ({len(svg)} bytes)")
+ made += 1
+ if not apply_changes:
+ continue
+
+ storage_service.save(key, svg, "image/svg+xml")
+ asset = MediaAsset(
+ path=key, title=title, caption=caption,
+ alt_text=caption, kind="image", library_id=library.id,
+ storage="s3" if storage_service.using_s3() else "local",
+ byte_size=len(svg),
+ )
+ db.add(asset)
+ db.flush()
+ for name in tags:
+ row = db.execute(sa_text(
+ "SELECT id FROM question_tags WHERE lower(name) = lower(:n) ORDER BY id LIMIT 1"
+ ), {"n": name}).first()
+ tag_id = row[0] if row else db.execute(sa_text(
+ "INSERT INTO question_tags (name, type) VALUES (:n, 'keyword') RETURNING id"
+ ), {"n": name}).scalar()
+ db.add(MediaTagLink(media_id=asset.id, tag_id=tag_id))
+ db.commit()
+ try:
+ if embedding_service.embed_record(asset, "media"):
+ db.commit()
+ except Exception:
+ db.rollback()
+
+ print(f"\n drawn: {made} already there: {skipped}")
+ if not apply_changes:
+ print(" Re-run with --apply to store them in the image bank.")
+ else:
+ print(textwrap.dedent("""
+ In the bank now, searchable by what they show. Attach one to an
+ article with a Markdown image, or to a question from the picker.
+ """).strip())
+ finally:
+ db.close()
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/frontend/src/components/RichText.css b/frontend/src/components/RichText.css
new file mode 100644
index 0000000..5954e75
--- /dev/null
+++ b/frontend/src/components/RichText.css
@@ -0,0 +1,32 @@
+/* Rendered educator prose, wherever it appears. */
+
+.rich-text { line-height: 1.6; overflow-wrap: anywhere; }
+.rich-text > :first-child { margin-top: 0; }
+.rich-text > :last-child { margin-bottom: 0; }
+.rich-text p { margin: 0 0 0.7em; }
+.rich-text ul, .rich-text ol { margin: 0 0 0.7em; padding-left: 1.4em; }
+.rich-text li { margin-bottom: 0.25em; }
+.rich-text code {
+ font-size: 0.9em; padding: 1px 5px; border-radius: 4px;
+ background: var(--bg); border: 1px solid var(--border);
+}
+.rich-text pre { overflow-x: auto; padding: 12px; border-radius: 8px; background: var(--bg); border: 1px solid var(--border); }
+.rich-text pre code { border: 0; padding: 0; background: none; }
+.rich-text blockquote {
+ margin: 0 0 0.7em; padding-left: 12px;
+ border-left: 3px solid var(--border); color: var(--text-muted);
+}
+.rich-text img { max-width: 100%; border-radius: 8px; }
+
+/* A lab panel is the common case: many short rows, numbers that must line up. */
+.rich-table-wrap { overflow-x: auto; margin: 0 0 0.8em; }
+.rich-text table { border-collapse: collapse; width: 100%; font-size: 0.92em; }
+.rich-text th, .rich-text td {
+ border: 1px solid var(--border); padding: 6px 10px; text-align: left; vertical-align: top;
+}
+.rich-text th { background: var(--bg); font-weight: 650; }
+.rich-text td:nth-child(n+2) { font-variant-numeric: tabular-nums; white-space: nowrap; }
+
+/* KaTeX paints its own colours; these keep it on the page's. */
+.rich-text .katex { font-size: 1.02em; color: inherit; }
+.rich-text .katex-display { overflow-x: auto; overflow-y: hidden; padding: 2px 0; }
diff --git a/frontend/src/components/RichText.jsx b/frontend/src/components/RichText.jsx
new file mode 100644
index 0000000..cae2c25
--- /dev/null
+++ b/frontend/src/components/RichText.jsx
@@ -0,0 +1,84 @@
+import ReactMarkdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import remarkMath from 'remark-math'
+import rehypeKatex from 'rehype-katex'
+import 'katex/dist/katex.min.css'
+import ArticleLink from './ArticleLink'
+import rehypeHighlightOffsets from '../utils/highlightOffsets'
+import { markdownImageUrl } from '../utils/uploads'
+import './RichText.css'
+
+/**
+ * The one place educator-written text becomes rendered content.
+ *
+ * Everything the platform stores as prose — question stems and options,
+ * explanations, articles, lesson text — is Markdown, and until now only
+ * articles rendered it. A lab panel written as a table showed up in the quiz
+ * player as a row of literal pipes, which is how this component came about.
+ *
+ * Markdown only: raw HTML is escaped rather than executed, so a stem can never
+ * inject markup into the page around it. Maths is written as `$…$` or `$$…$$`
+ * and rendered by KaTeX, because a serum osmolality formula set as plain text
+ * is a different sentence from the one the author wrote.
+ */
+
+// [[febrile-seizures]] and [[Febrile seizures|febrile-seizures]] are how an
+// educator writes a cross-reference without needing an article's numeric id.
+const WIKI_LINK = /\[\[(?:(\d+)\|([^\]]+)|([^\]|]+?)(?:\|([a-z0-9-]+))?)\]\]/g
+
+const expandWikiLinks = (text) => (text || '').replace(
+ WIKI_LINK,
+ (_m, id, idLabel, label, slug) => (id
+ ? `[${idLabel.trim()}](/articles/${id})`
+ : `[${label.trim()}](/articles/${(slug || label).trim().toLowerCase()})`),
+)
+
+const internalArticle = (href) => {
+ const match = /^\/articles\/(?:s\/)?([a-z0-9-]+|\d+)\/?$/.exec(href || '')
+ return match ? match[1] : null
+}
+
+export default function RichText({
+ value,
+ attemptId,
+ className = '',
+ linkArticles = false,
+ // Passing a textId turns on the highlight layer: text keeps the source
+ // offsets that highlights and the read-aloud cursor are stored against.
+ textId = null,
+ highlights = [],
+ speechRange = null,
+ onRemoveHighlight = null,
+}) {
+ const rehypePlugins = [rehypeKatex]
+ if (textId) rehypePlugins.unshift([rehypeHighlightOffsets, { textId, highlights, speechRange }])
+
+ return (
+
{
+ const span = event.target?.closest?.('[data-highlighted="true"]')
+ if (!span) return
+ event.preventDefault()
+ onRemoveHighlight(span.dataset.manualHighlightId, Number(span.dataset.start))
+ } : undefined}>
+
,
+ a: ({ node, href, children, ...props }) => {
+ const target = linkArticles && internalArticle(href)
+ if (target) return {children}
+ return {children}
+ },
+ // A wide table is the reason this exists; it scrolls inside its own
+ // box rather than pushing the page sideways.
+ table: ({ node, ...props }) => (
+
+ ),
+ }}>
+ {linkArticles ? expandWikiLinks(value) : (value || '')}
+
+
+ )
+}
diff --git a/frontend/src/components/RichText.test.jsx b/frontend/src/components/RichText.test.jsx
new file mode 100644
index 0000000..9f6bbfe
--- /dev/null
+++ b/frontend/src/components/RichText.test.jsx
@@ -0,0 +1,118 @@
+import { describe, expect, it, vi } from 'vitest'
+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'
+
+vi.mock('../api/client', () => ({ default: { get: vi.fn(() => Promise.resolve({ data: {} })) } }))
+vi.mock('../utils/uploads', () => ({ markdownImageUrl: (src) => `/uploads/${src}` }))
+
+const mount = (props) => render( )
+
+const LAB_TABLE = `Initial studies:
+
+| Analyte | Value | SI |
+| --- | --- | --- |
+| Sodium | 139 mEq/L | 139 mmol/L |
+| Potassium | 2.8 mEq/L | 2.8 mmol/L |`
+
+describe('rendered prose', () => {
+ it('renders a lab panel as a table, which is the reason this exists', () => {
+ mount({ value: LAB_TABLE })
+ const table = screen.getByRole('table')
+ expect(within(table).getByRole('columnheader', { name: 'Analyte' })).toBeInTheDocument()
+ expect(within(table).getByRole('cell', { name: '2.8 mEq/L' })).toBeInTheDocument()
+ // A wide table scrolls in its own box rather than pushing the page sideways.
+ expect(table.closest('.rich-table-wrap')).toBeTruthy()
+ expect(screen.queryByText(/\|/)).not.toBeInTheDocument()
+ })
+
+ it('renders maths rather than printing the source', () => {
+ const { container } = mount({ value: 'Osmolality is $2 \\times Na$ roughly.' })
+ expect(container.querySelector('.katex')).toBeTruthy()
+ // What a reader sees is the typeset half; the TeX survives only in the
+ // MathML annotation, which is there for screen readers and is not visible.
+ expect(container.querySelector('.katex-html').textContent).toContain('×')
+ expect(container.querySelector('.katex-html').textContent).not.toContain('times')
+ })
+
+ it('escapes raw HTML instead of executing it', () => {
+ const { container } = mount({ value: ' A stem' })
+ expect(container.querySelector('img')).toBeNull()
+ expect(screen.getByText(/A stem/)).toBeInTheDocument()
+ })
+
+ it('sends an external link away in a new tab, safely', () => {
+ mount({ value: '[AAP](https://aap.org)' })
+ const link = screen.getByRole('link', { name: 'AAP' })
+ expect(link).toHaveAttribute('target', '_blank')
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'))
+ })
+
+ it('leaves article cross-references alone unless asked to link them', () => {
+ mount({ value: 'See [[febrile-seizures]] for more.' })
+ expect(screen.getByText(/\[\[febrile-seizures\]\]/)).toBeInTheDocument()
+ })
+
+ it('turns a cross-reference into a preview link when asked', () => {
+ mount({ value: 'See [[7|Febrile seizures]] for more.', linkArticles: true })
+ expect(screen.getByRole('link', { name: 'Febrile seizures' }))
+ .toHaveAttribute('href', '/articles/s/7')
+ })
+})
+
+describe('highlights across rendered markdown', () => {
+ const stem = 'A 9-year-old boy is brought to the office.'
+
+ it('keeps the source offsets a highlight is stored against', () => {
+ const { container } = mount({
+ value: stem, textId: 'q1::question',
+ highlights: [{ start: 2, end: 12 }],
+ })
+ const active = container.querySelector('.manual-highlight-active')
+ expect(active).toHaveTextContent('9-year-old')
+ expect(active.dataset.start).toBe('2')
+ expect(active.dataset.end).toBe('12')
+ expect(active.dataset.manualHighlightId).toBe('q1::question')
+ })
+
+ it('marks the read-aloud cursor separately from a highlight', () => {
+ const { container } = mount({
+ value: stem, textId: 'q1::question',
+ highlights: [{ start: 0, end: 1 }],
+ speechRange: { start: 2, end: 12 },
+ })
+ expect(container.querySelector('.speech-highlight-active')).toHaveTextContent('9-year-old')
+ expect(container.querySelector('.manual-highlight-active')).toHaveTextContent('A')
+ })
+
+ it('offsets survive a stem that also contains a table', () => {
+ const { container } = mount({
+ value: LAB_TABLE, textId: 'q2::question',
+ highlights: [{ start: 0, end: 8 }],
+ })
+ expect(screen.getByRole('table')).toBeInTheDocument()
+ const active = container.querySelector('.manual-highlight-active')
+ expect(active).toHaveTextContent('Initial')
+ expect(active.dataset.start).toBe('0')
+ })
+
+ it('removes a highlight on right-click, and ignores plain text', async () => {
+ const onRemoveHighlight = vi.fn()
+ const { container } = mount({
+ value: stem, textId: 'q1::question',
+ highlights: [{ start: 2, end: 12 }], onRemoveHighlight,
+ })
+ await userEvent.pointer({ keys: '[MouseRight]', target: container.querySelector('.manual-highlight-active') })
+ expect(onRemoveHighlight).toHaveBeenCalledWith('q1::question', 2)
+
+ onRemoveHighlight.mockClear()
+ await userEvent.pointer({ keys: '[MouseRight]', target: container.querySelector('.manual-highlight-segment:not(.manual-highlight-active)') })
+ expect(onRemoveHighlight).not.toHaveBeenCalled()
+ })
+
+ it('adds no highlight machinery when nothing is being highlighted', () => {
+ const { container } = mount({ value: stem })
+ expect(container.querySelector('[data-manual-highlight-id]')).toBeNull()
+ })
+})
diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx
index 9734095..8b6d41e 100644
--- a/frontend/src/pages/QuestionBankPage.jsx
+++ b/frontend/src/pages/QuestionBankPage.jsx
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, lazy, Suspense } from 'react'
import { useLocation, useNavigate, Link } from 'react-router-dom'
+import RichText from '../components/RichText'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import Dialog from '../components/Dialog'
@@ -58,7 +59,11 @@ function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite,
✕
-
+ {/* Markdown, not injected HTML: a stem is educator prose, and the
+ same renderer the quiz player uses keeps a lab table a table. */}
+
+
+
{question.image_path && (
Number.isFinite(r.start) && Number.isFinite(r.end) && r.end > r.start)
- .sort((a, b) => a.start - b.start || a.end - b.end)
- const merged = []
- sorted.forEach(range => {
- const last = merged[merged.length - 1]
- if (!last || range.start > last.end) {
- merged.push({ start: range.start, end: range.end })
- } else {
- last.end = Math.max(last.end, range.end)
- }
- })
- return merged
-}
function removeTextRange(ranges, removeRange) {
const next = []
@@ -83,44 +70,6 @@ function getManualHighlightSelection(selection = window.getSelection?.()) {
return { id: start.id, ...ordered }
}
-function ManualHighlightText({ text, textId, highlights = [], speechRange = null, onRemoveHighlight = null }) {
- const ranges = mergeTextRanges(highlights)
- const boundaries = new Set([0, (text || '').length])
- ranges.forEach(range => { boundaries.add(range.start); boundaries.add(range.end) })
- if (speechRange) { boundaries.add(speechRange.start); boundaries.add(speechRange.end) }
- const points = [...boundaries]
- .filter(point => point >= 0 && point <= (text || '').length)
- .sort((a, b) => a - b)
-
- return points.slice(0, -1).map((start, i) => {
- const end = points[i + 1]
- if (end <= start) return null
- const manuallyHighlighted = ranges.some(range => start >= range.start && end <= range.end)
- const speechHighlighted = speechRange && start >= speechRange.start && end <= speechRange.end
- const className = [
- 'manual-highlight-segment',
- manuallyHighlighted ? 'manual-highlight-active' : '',
- speechHighlighted ? 'speech-highlight-active' : '',
- ].filter(Boolean).join(' ')
- return (
-
{
- event.preventDefault()
- onRemoveHighlight?.(textId, start)
- } : undefined}
- title={manuallyHighlighted ? 'Right-click to remove this highlight' : undefined}
- >
- {text.slice(start, end)}
-
- )
- })
-}
-
function splitSpeechChunks(text, maxWords) {
const words = (text || '').trim().split(/\s+/).filter(Boolean)
if (!words.length) return []
@@ -1157,16 +1106,17 @@ const timerStarted = timeLeft !== null
{current.question_type === 'mcq' ? 'Multiple choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the blank'}
-
-
-
-
+ {/* A stem carrying a lab table cannot live inside a heading —
+ the table would be invalid markup there — so the heading is the
+ labelled region and the prose sits inside it. */}
+
+
{letter}
- ✓ Correct }
{showWrong && ✗ Wrong }
{hasAnswered && showAllExplanations && current.option_explanations?.[opt] && (
- {current.option_explanations[opt]}
+
+
+
)}
)
@@ -1313,7 +1266,7 @@ const timerStarted = timeLeft !== null
{statsError &&
{statsError}
}
{(current.explanation || current.explanation_image_path) && (
- {current.explanation && <>
Explanation: {current.explanation}>}
+ {current.explanation && <>
Explanation: >}
{current.explanation_image_path && (
{ setImageZoom(1); setExpandedImagePath(current.explanation_image_path) }} title="Expand explanation image" type="button" style={{ marginTop: 12 }}>
r && Number.isFinite(r.start) && Number.isFinite(r.end) && r.end > r.start)
+ .sort((a, b) => a.start - b.start)
+ const merged = []
+ for (const range of clean) {
+ const last = merged[merged.length - 1]
+ if (last && range.start <= last.end) last.end = Math.max(last.end, range.end)
+ else merged.push({ start: range.start, end: range.end })
+ }
+ return merged
+}
+
+const walk = (node, visit) => {
+ if (Array.isArray(node.children)) {
+ node.children = node.children.flatMap(child => {
+ walk(child, visit)
+ return visit(child, node) ?? [child]
+ })
+ }
+}
+
+export default function rehypeHighlightOffsets({
+ textId, highlights = [], speechRange = null,
+} = {}) {
+ const ranges = mergeTextRanges(highlights)
+
+ return (tree) => {
+ walk(tree, (node) => {
+ if (node.type !== 'text') return null
+ const from = node.position?.start?.offset
+ const to = node.position?.end?.offset
+ // No position means the node was synthesised rather than parsed; there is
+ // no honest offset to give it, so it renders as ordinary text.
+ if (from == null || to == null) return null
+
+ const bounds = new Set([from, to])
+ for (const range of ranges) {
+ if (range.start > from && range.start < to) bounds.add(range.start)
+ if (range.end > from && range.end < to) bounds.add(range.end)
+ }
+ if (speechRange) {
+ if (speechRange.start > from && speechRange.start < to) bounds.add(speechRange.start)
+ if (speechRange.end > from && speechRange.end < to) bounds.add(speechRange.end)
+ }
+
+ const points = [...bounds].sort((a, b) => a - b)
+ const pieces = []
+ for (let i = 0; i < points.length - 1; i += 1) {
+ const start = points[i]
+ const end = points[i + 1]
+ const highlighted = ranges.some(r => start >= r.start && end <= r.end)
+ const spoken = speechRange && start >= speechRange.start && end <= speechRange.end
+ pieces.push({
+ type: 'element',
+ tagName: 'span',
+ properties: {
+ className: [
+ 'manual-highlight-segment',
+ highlighted ? 'manual-highlight-active' : '',
+ spoken ? 'speech-highlight-active' : '',
+ ].filter(Boolean),
+ 'data-manual-highlight-id': textId,
+ 'data-start': String(start),
+ 'data-end': String(end),
+ 'data-highlighted': highlighted ? 'true' : undefined,
+ },
+ children: [{ type: 'text', value: node.value.slice(start - from, end - from) }],
+ })
+ }
+ return pieces.length ? pieces : null
+ })
+ }
+}