web: split the physics layer, render formulas, and fix the graph load

The graph took over three seconds to draw. The force layout ran four
hundred and twenty steps of repulsion between every pair of nodes, which
is quadratic in the node count and was doing that work through a reactive
proxy inside an effect that read the state it wrote, so it rescheduled
itself on every run. The repulsion now runs on a grid over neighbouring
cells, and the effect no longer tracks its own writes.

The physics layer covered two unrelated subjects, so it is presented as
two. Formulas are written between dollar signs and translated to MathML,
which the browser sets itself, so no script has to run. The palette is
now two sets of the same variables with a toggle between them, and below
a thousand pixels the sidebar is a drawer over the page.
This commit is contained in:
dev 2026-10-01 17:28:43 +08:00
parent 68eaaf6b20
commit 17e99428de
13 changed files with 1538 additions and 66 deletions

View File

@ -30,15 +30,34 @@
"id": "physics",
"title": "Physics",
"title_zh": "物理层",
"summary": "Time scales, leap seconds, calendars, and the geometry of the Earth: its reference frames, its rotation, and its reference ellipsoid.",
"summary_zh": "时间尺度、闰秒与日历,以及地球的几何:参考系、自转与参考椭球。",
"documents": [
"time-scales",
"leap-seconds",
"calendar-and-mjd",
"reference-frames",
"earth-rotation-angle",
"geodesy-wgs84"
"summary": "The physics layer holds two unrelated subjects: the time a measurement is stamped with, and the geometry of the Earth it is measured against.",
"summary_zh": "物理层包含两个彼此无关的主题:一次测量被标注的时刻,以及它所参照的地球几何。",
"documents": [],
"subsections": [
{
"id": "physics-time",
"title": "Time",
"title_zh": "时间",
"summary": "Time scales, the leap seconds that keep them aligned with the Earth's rotation, and the calendar that turns an instant into a date.",
"summary_zh": "时间尺度、使其与地球自转对齐的闰秒,以及把时刻变为日期的日历。",
"documents": [
"time-scales",
"leap-seconds",
"calendar-and-mjd"
]
},
{
"id": "physics-frames",
"title": "Frames and the Earth",
"title_zh": "参考系与地球",
"summary": "The frames a position is measured in, the angle that ties them together, and the ellipsoid that defines the surface.",
"summary_zh": "测量位置所用的参考系、把各参考系联系起来的角度,以及定义地表的椭球。",
"documents": [
"reference-frames",
"earth-rotation-angle",
"geodesy-wgs84"
]
}
]
},
{

View File

@ -19,6 +19,8 @@
import { getJson, queryString } from './lib/api';
import { currentRoute, onRouteChange, navigate } from './lib/router';
import { language, pick, setLanguage, tr, type Language } from './lib/i18n';
import { theme, toggleTheme } from './lib/theme';
import { untrack } from 'svelte';
import Home from './pages/Home.svelte';
import Docs from './pages/Docs.svelte';
import DocumentPage from './pages/Document.svelte';
@ -31,6 +33,15 @@
let route = $state(currentRoute());
/** The current language, read so that the shell re-renders on a switch. */
let lang = $derived($language);
let colour = $derived($theme);
/** Whether the sidebar covers the page, on a narrow screen. */
let menuOpen = $state(false);
/** Switches between the dark and light palettes. */
function onToggleTheme() {
toggleTheme(colour);
}
let health = $state<Health | null>(null);
let index = $state<DocumentIndex | null>(null);
@ -49,6 +60,16 @@
let collapsed = $state<string[]>([]);
/** Folds a chapter, or unfolds it when it is already folded. */
// The sidebar covers the page on a narrow screen, so it is closed when the
// route changes. Following a link is the usual way the route changes, and
// the links are ordinary anchors, so this is where the close belongs.
$effect(() => {
void route;
untrack(() => {
menuOpen = false;
});
});
function toggleChapter(id: string) {
collapsed = collapsed.includes(id)
? collapsed.filter((entry) => entry !== id)
@ -165,7 +186,19 @@
<div class="app">
<div class="sidebar-frame">
<aside class="sidebar">
{#if menuOpen}
<!-- Dismisses the drawer by tapping the page behind it. It is a button
so that it is reachable from the keyboard. -->
<button
class="sidebar-scrim"
type="button"
tabindex={-1}
aria-label={tr('nav.closeMenu', lang)}
onclick={() => (menuOpen = false)}
></button>
{/if}
<aside class="sidebar" class:open={menuOpen}>
<div class="sidebar-brand">
<a href="#/">APSIS</a>
<p>{tr('app.subtitle', lang)}</p>
@ -207,6 +240,15 @@
class:active={route.name === 'docs' && route.id === entry.id}
href={`#/docs/${entry.id}`}>{pick(entry.title, lang)}</a>
{/each}
{#each section.subsections as subject (subject.id)}
<div class="sidebar-subject">{pick(subject.title, lang)}</div>
{#each subject.documents as entry (entry.id)}
<a
class="item"
class:active={route.name === 'docs' && route.id === entry.id}
href={`#/docs/${entry.id}`}>{pick(entry.title, lang)}</a>
{/each}
{/each}
{/if}
</div>
{/each}
@ -233,12 +275,27 @@
<span class="badge">{health.tools} {tr('tool.plural', lang)}</span>
<span class="badge">v{health.version}</span>
{/if}
<button
class="ghost nav-button"
type="button"
aria-label={colour === 'dark' ? tr('nav.lightMode', lang) : tr('nav.darkMode', lang)}
title={colour === 'dark' ? tr('nav.lightMode', lang) : tr('nav.darkMode', lang)}
onclick={onToggleTheme}
>{colour === 'dark' ? '☀' : '☾'}</button>
<button
class="ghost"
type="button"
title={tr('docs.otherLanguageTitle', lang)}
onclick={toggleLanguage}
>{tr('docs.otherLanguage', lang)}</button>
<button
class="ghost nav-button menu-button"
type="button"
aria-expanded={menuOpen}
aria-label={menuOpen ? tr('nav.closeMenu', lang) : tr('nav.menu', lang)}
title={menuOpen ? tr('nav.closeMenu', lang) : tr('nav.menu', lang)}
onclick={() => (menuOpen = !menuOpen)}
>{menuOpen ? '✕' : '☰'}</button>
</header>
<main class="content" class:doc={route.name === 'docs' && route.id !== null} class:wide={route.name === 'graph' || route.name === 'flows'}>

View File

@ -1,8 +1,10 @@
/*
* The visual design of the service.
*
* The palette is a single dark theme. Colour carries meaning in the graph
* and in the tables, so the variables are named by role rather than by hue.
* Colour carries meaning in the graph and in the tables, so the variables
* are named by role rather than by hue. The dark palette is the default and
* the light one overrides the same variables, so every rule is written once.
* A reader's stored choice sets `data-theme` on the document element.
*/
:root {
@ -26,6 +28,21 @@
--warning: #e8c26a;
--negative: #f28b82;
--shadow: rgba(0, 0, 0, 0.35);
--graph-surface: #161d2b;
--graph-grid: #1e2738;
--topbar: rgba(11, 15, 23, 0.9);
--scrim: rgba(0, 0, 0, 0.45);
--warning-soft: rgba(232, 194, 106, 0.1);
--negative-soft: rgba(242, 139, 130, 0.1);
--accent-faint: rgba(127, 209, 255, 0.04);
--on-accent: #04121c;
--syntax-keyword: #c792ea;
--syntax-type: #82aaff;
--syntax-function: #7fd1ff;
/* One hue per layer, so that a legend with ten entries is readable. The
concept graph uses these directly; the flow graphs reuse the earlier
stage names. */
@ -72,6 +89,84 @@
--group-frameset: #4ade80;
}
/*
* The light palette. It is a full override rather than a filter, because a
* filter inverts the graph colours and makes the legend meaningless. The
* background is tinted rather than pure white, because a long page of
* pure white is harder to read than a page that is not.
*/
:root[data-theme='light'] {
color-scheme: light;
--background: #f4f6fa;
--surface: #ffffff;
--surface-raised: #eef1f6;
--border: #d3dae4;
--border-strong: #b3bfcd;
--text: #16202e;
--text-muted: #4a5769;
--text-faint: #6b7787;
--accent: #0b6fa4;
--accent-strong: #08587f;
--accent-soft: rgba(11, 111, 164, 0.1);
--positive: #147d54;
--warning: #9a6510;
--negative: #b3261e;
--shadow: rgba(15, 30, 50, 0.12);
--graph-surface: #ffffff;
--graph-grid: #dce3ec;
--topbar: rgba(255, 255, 255, 0.9);
--scrim: rgba(15, 30, 50, 0.35);
--warning-soft: rgba(154, 101, 16, 0.1);
--negative-soft: rgba(179, 38, 30, 0.09);
--accent-faint: rgba(11, 111, 164, 0.05);
--on-accent: #ffffff;
--syntax-keyword: #7c3aed;
--syntax-type: #1d4ed8;
--syntax-function: #0b6fa4;
/* The graph hues are darkened so that a thin stroke stays legible on a
light background; the dark values are too pale to read here. */
--group-overview: #0b6fa4;
--group-foundation: #4d7c0f;
--group-physics: #15803d;
--group-geometry: #0f766e;
--group-elements: #a16207;
--group-orbit: #c2410c;
--group-propagation: #b91c1c;
--group-rf: #86198f;
--group-network: #6d28d9;
--group-ops: #3730a3;
--group-sim: #0369a1;
--group-methods: #475569;
--group-input: #475569;
--group-parse: #0369a1;
--group-prepare: #5b21b6;
--group-propagate: #0369a1;
--group-verify: #15803d;
--group-fields: #15803d;
--group-crosscheck: #a16207;
--group-branch: #a16207;
--group-error: #b91c1c;
--group-output: #15803d;
--group-validate: #5b21b6;
--group-convert: #0369a1;
--group-store: #5b21b6;
--group-frames: #0f766e;
--group-rotate: #0369a1;
--group-geodetic: #a16207;
--group-time: #5b21b6;
--group-general: #475569;
--group-damping: #15803d;
--group-frameset: #0f766e;
}
* {
box-sizing: border-box;
}
@ -357,7 +452,7 @@ hr {
gap: 0.75rem;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--border);
background: rgba(11, 15, 23, 0.9);
background: var(--topbar);
position: sticky;
top: 0;
z-index: 20;
@ -447,7 +542,7 @@ textarea {
button {
background: var(--accent-strong);
color: #04121c;
color: var(--on-accent);
border: none;
border-radius: var(--radius-small);
padding: 0.45rem 0.9rem;
@ -655,12 +750,12 @@ button.ghost:hover:not(:disabled) {
.notice.warning {
border-color: var(--warning);
background: rgba(232, 194, 106, 0.1);
background: var(--warning-soft);
}
.notice.error {
border-color: var(--negative);
background: rgba(242, 139, 130, 0.1);
background: var(--negative-soft);
}
.notice.muted {
@ -847,7 +942,7 @@ tbody tr:last-child td {
}
tbody tr:hover {
background: rgba(127, 209, 255, 0.04);
background: var(--accent-faint);
}
/* Scalars */
@ -960,15 +1055,15 @@ tbody tr:hover {
}
.signature .kw {
color: #c792ea;
color: var(--syntax-keyword);
}
.signature .ty {
color: #82aaff;
color: var(--syntax-type);
}
.signature .fn {
color: #7fd1ff;
color: var(--syntax-function);
}
.api-item {
@ -1145,6 +1240,26 @@ tbody tr:hover {
color: var(--accent);
}
/* A subject within a layer, shown above the documents of that subject. */
.docs-subject {
margin: 1.5rem 0 0.5rem;
color: var(--text);
font-size: 0.95rem;
border-left: 2px solid var(--border-strong);
padding-left: 0.6rem;
}
.sidebar-subject {
font-size: 0.72rem;
color: var(--text-muted);
margin: 0.35rem 0 0.15rem 0.55rem;
padding-left: 0.45rem;
border-left: 2px solid var(--border);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* A tool in the tool index. The card is a link, so the whole area is the
target and the hover is the affordance. */
.tool-card {
@ -1357,6 +1472,46 @@ tbody tr:hover {
}
}
/*
* Formulas.
*
* The output is MathML, which the browser sets itself, so only the container
* needs styling: the family to read in, and the room a display formula takes.
* `math` inherits the surrounding colour, so a formula follows the theme
* without a second set of variables.
*/
.math {
/* The browser's default math font is fine on a dark background and thin on
a light one, so the family is only suggested and the browser may use its
own. */
font-size: 1.02em;
line-height: 1.5;
}
.math-display {
display: block;
margin: 1.1rem 0;
/* A formula is centred on its own, as a formula rather than as a line of
prose. Overflow scrolls rather than being clipped, because a long
formula stays readable when it can be scrolled and disappears when it
cannot. */
overflow-x: auto;
overflow-y: hidden;
text-align: center;
}
:root[data-theme='light'] .math {
/* On a light background the browser's math font is thin, so it is asked
for a slightly heavier weight to match the text around it. */
font-weight: 500;
}
/* The drawer toggle. It is only useful while the sidebar is a drawer, so it
is hidden by default and the narrow-screen rule below reveals it. */
.menu-button {
display: none;
}
/* Responsive */
@media (max-width: 1180px) {
@ -1383,17 +1538,78 @@ tbody tr:hover {
}
}
/*
* Below this width there is no room for a sidebar beside the page, so the
* sidebar becomes a drawer that covers the page and is opened from the top
* bar. It is a drawer rather than a stacked block because a block would push
* the page down by half the screen, and the navigation is long.
*
* The drawer is a normal element rather than a dialog because it holds no
* state to trap and no action to confirm; what it does need is to be
* dismissible, which the overlay and the close button both do.
*/
@media (max-width: 960px) {
.app {
flex-direction: column;
}
/* The sidebar is a drawer here, so the button that opens it is needed. */
.menu-button {
display: inline-flex;
}
/* The frame holds the width of the sidebar beside the page. Once the
sidebar is a drawer the frame has nothing to hold, and left in the flow
it would push the page down by its own width. */
.sidebar-frame {
flex: 0 0 auto;
width: 0;
height: 0;
border-right: none;
background: none;
overflow: visible;
}
.sidebar {
width: 100%;
flex: none;
height: auto;
position: relative;
max-height: 45vh;
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(20rem, 84vw);
/* The frame that carried the background is empty in this layout, so the
drawer carries it itself; without this the page shows through it. */
background: var(--surface);
border-right: 1px solid var(--border);
max-width: none;
height: 100dvh;
z-index: 40;
/* The drawer sits over the page, so it needs its own edge to lift it
off the content behind it. */
box-shadow: var(--shadow);
/* Closed by default on a narrow screen; the open class is set by the
top bar button. */
transform: translateX(-100%);
transition: transform 160ms ease;
}
.sidebar.open {
transform: translateX(0);
}
/* Tapping outside the drawer dismisses it. It covers the page, so it is
placed below the drawer and above the page. */
.sidebar-scrim {
position: fixed;
inset: 0;
z-index: 30;
background: var(--scrim);
border: none;
padding: 0;
cursor: pointer;
}
.content {
padding: 1.25rem 1rem 3rem;
}
.doc-layout {
@ -1413,8 +1629,4 @@ tbody tr:hover {
padding-left: 0;
padding-top: 0.75rem;
}
.content {
padding: 1.25rem 1rem 3rem;
}
}

View File

@ -11,6 +11,8 @@
* Interaction: dragging pans, the wheel zooms about the pointer, a click
* on a node selects it, and a double click opens its document.
*/
import { untrack } from 'svelte';
import type { Graph, GraphNode } from '../lib/types';
import { groupColor, truncate } from '../lib/format';
import { language, pick, tr } from '../lib/i18n';
@ -46,7 +48,12 @@
const WIDTH = 1400;
const HEIGHT = 760;
let placements = $state<Map<string, Placement>>(new Map());
// The layout is written once per graph and read many times by the
// template. A deep reactive proxy over it would pay for a tracking
// structure on every node of every frame, so it is held as raw data and
// the view is refreshed by changing a version counter instead.
let placements = $state.raw(new Map<string, Placement>());
let layoutVersion = $state(0);
let span = $state(1);
let offsetX = $state(0);
let offsetY = $state(0);
@ -89,26 +96,71 @@
.map((edge) => ({ source: result.get(edge.source), target: result.get(edge.target) }))
.filter((link) => link.source !== undefined && link.target !== undefined);
// A small force simulation: repulsion between every pair, attraction
// A force simulation with repulsion between every pair, attraction
// along every edge, and a weak pull toward the centre.
// The spacing grows with the node count: a graph of forty nodes laid out
// for a graph of twenty is legible, and the same spacing is crowded.
const iterations = 420;
//
// The repulsion is the cost. Computing it for every pair on every step is
// quadratic in the node count and dominated the time to draw the graph, so
// it is approximated on a grid: each node is compared with the nodes in
// its own cell and the eight neighbouring cells, which is enough because
// distant nodes exert little force. The cost becomes linear in the node
// count for a fixed neighbourhood, and the layout is unchanged in
// character.
//
// The spacing grows with the node count, so a larger graph is a larger
// graph rather than a denser one.
const idealLength = 130 + Math.sqrt(count) * 16;
const repulsion = 9000 + count * 260;
// A cell is about the ideal spacing, so the neighbourhood holds the
// nodes whose distance actually matters.
const cellSize = idealLength;
const iterations = 320;
// A flat array is used rather than the map, so the inner loops do not pay
// for iteration over a map on every step.
const nodes = [...result.values()];
let cells = new Map<number, number[]>();
const cellKey = (x: number, y: number) =>
Math.floor(x / cellSize) * 100003 + Math.floor(y / cellSize);
for (let step = 0; step < iterations; step += 1) {
for (const first of result.values()) {
for (const second of result.values()) {
if (first === second) continue;
const dx = second.x - first.x;
const dy = second.y - first.y;
const distanceSquared = Math.max(64, dx * dx + dy * dy);
const force = repulsion / distanceSquared;
const distance = Math.sqrt(distanceSquared);
first.vx -= (dx / distance) * force;
first.vy -= (dy / distance) * force;
// Bucket the nodes by their cell once per step.
cells = new Map();
for (let index = 0; index < nodes.length; index += 1) {
const node = nodes[index];
const key = cellKey(node.x, node.y);
const bucket = cells.get(key);
if (bucket) {
bucket.push(index);
} else {
cells.set(key, [index]);
}
}
for (let index = 0; index < nodes.length; index += 1) {
const first = nodes[index];
const cellX = Math.floor(first.x / cellSize);
const cellY = Math.floor(first.y / cellSize);
for (let ox = -1; ox <= 1; ox += 1) {
for (let oy = -1; oy <= 1; oy += 1) {
const bucket = cells.get((cellX + ox) * 100003 + (cellY + oy));
if (!bucket) continue;
for (const other of bucket) {
if (other === index) continue;
const second = nodes[other];
const dx = second.x - first.x;
const dy = second.y - first.y;
const distanceSquared = Math.max(64, dx * dx + dy * dy);
const force = repulsion / distanceSquared;
const distance = Math.sqrt(distanceSquared);
first.vx -= (dx / distance) * force;
first.vy -= (dy / distance) * force;
}
}
}
}
for (const link of links) {
const source = link.source as Placement;
const target = link.target as Placement;
@ -121,7 +173,8 @@
target.vx -= (dx / distance) * force;
target.vy -= (dy / distance) * force;
}
for (const placement of result.values()) {
for (const placement of nodes) {
placement.vx += (WIDTH / 2 - placement.x) * 0.0016;
placement.vy += (HEIGHT / 2 - placement.y) * 0.0016;
placement.x += placement.vx;
@ -207,9 +260,18 @@
offsetY = (HEIGHT - (maxY - minY) * span) / 2 - minY * span;
}
// The effect reads `graph` and writes the layout and the viewport. The
// writes must not be tracked, or the effect would depend on the state it
// sets and would schedule itself again on every run.
$effect(() => {
placements = layout(graph);
fitToNodes();
const current = graph;
untrack(() => {
placements = layout(current);
fitToNodes();
// Bumping the version tells the template that the raw map changed,
// which a raw state does not announce on its own.
layoutVersion += 1;
});
});
/** The transform of the layout viewport. */
@ -318,7 +380,9 @@
{/each}
{#each graph.nodes as item (item.id)}
{@const placement = placements.get(item.id)}
<!-- Reading the version is what subscribes the block to a change of
the layout, which a raw state does not announce on its own. -->
{@const placement = layoutVersion >= 0 ? placements.get(item.id) : undefined}
{#if placement}
{@const isSelected = selected === item.id}
{@const isHovered = hovered === item.id}

View File

@ -180,6 +180,10 @@ const STRINGS: Record<string, Text> = {
},
'docs.otherLanguage': { zh: 'English', en: '中文' },
'docs.otherLanguageTitle': { zh: 'Switch to English', en: '切换到中文' },
'nav.lightMode': { zh: 'Light mode', en: '明亮模式' },
'nav.darkMode': { zh: 'Dark mode', en: '深色模式' },
'nav.menu': { zh: 'Menu', en: '菜单' },
'nav.closeMenu': { zh: 'Close menu', en: '关闭菜单' },
'graph.title': { zh: '知识图谱', en: 'Knowledge graph' },
'graph.intro': {

80
frontend/src/lib/theme.ts Normal file
View File

@ -0,0 +1,80 @@
/*
* The colour theme.
*
* The palette lives in the stylesheet as two sets of the same variables; the
* choice is made by setting `data-theme` on the document element. Storing it
* the same way as the language means the two are remembered together and a
* reader who chooses once does not choose again.
*
* The choice is not a preference only: a dark page in a bright room is hard
* to read, and so is a bright one at night. The reader decides.
*/
import { writable } from 'svelte/store';
export type Theme = 'dark' | 'light';
const STORAGE_KEY = 'apsis.theme';
function isTheme(value: string | null): value is Theme {
return value === 'dark' || value === 'light';
}
/**
* The stored choice, the operating system's preference, or the default.
*
* The system preference is only consulted when nothing has been stored, so
* that a reader who has chosen once keeps that choice on a device whose
* system setting later changes.
*/
function initialTheme(): Theme {
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (isTheme(stored)) return stored;
} catch {
// A blocked or absent store is not an error; the system preference or
// the default applies.
}
try {
if (window.matchMedia('(prefers-color-scheme: light)').matches) return 'light';
} catch {
// A browser without the query is rare; the default applies.
}
return 'dark';
}
/** The current theme. */
export const theme = writable<Theme>(initialTheme());
/** Applies a theme to the store, the storage, and the document. */
export function setTheme(next: Theme): void {
theme.set(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Persisting is best effort.
}
if (typeof document !== 'undefined') {
document.documentElement.dataset.theme = next;
}
}
theme.subscribe((current) => {
// The subscription keeps the document attribute in step with the store
// even when the store is set directly.
if (typeof document !== 'undefined') {
document.documentElement.dataset.theme = current;
}
});
/**
* Switches between the two themes and reports the one now in force.
*
* The result is returned so that a caller can describe the button in the
* language of the theme it switches to.
*/
export function toggleTheme(current: Theme): Theme {
const next: Theme = current === 'dark' ? 'light' : 'dark';
setTheme(next);
return next;
}

View File

@ -71,12 +71,28 @@ export interface DocumentSummary {
tags: BilingualList;
}
/** A layer, or section, inside a group. */
/** One subject within a layer. */
export interface DocumentSubsection {
id: string;
title: Bilingual;
summary: Bilingual;
documents: DocumentSummary[];
}
/**
* A layer inside a group.
*
* A layer that covers two unrelated subjects, such as the physics layer,
* carries a subsection for each so that the two are not presented as one
* list. `documents` is then empty and the documents live in the
* subsections.
*/
export interface DocumentSection {
id: string;
title: Bilingual;
summary: Bilingual;
documents: DocumentSummary[];
subsections: DocumentSubsection[];
}
/**

View File

@ -46,6 +46,31 @@
{#if pick(section.summary, lang)}
<p style="color: var(--text-muted); margin-top: 0">{pick(section.summary, lang)}</p>
{/if}
{#each section.subsections as subject (subject.id)}
<h4 class="docs-subject">{pick(subject.title, lang)}</h4>
{#if pick(subject.summary, lang)}
<p style="color: var(--text-muted); margin-top: 0">{pick(subject.summary, lang)}</p>
{/if}
<div class="grid cards">
{#each subject.documents as document (document.id)}
<article class="card">
<h3><a href={`#/docs/${document.id}`}>{pick(document.title, lang)}</a></h3>
<p class="card-clamp" style="color: var(--text-muted); margin: 0.4rem 0 0.6rem">
{pick(document.summary, lang)}
</p>
<div class="card-tags">
{#each pickList(document.tags, lang) as tag}
<span class="tag">{tag}</span>
{/each}
</div>
<button
class="ghost card-action"
type="button"
onclick={() => navigate(`/docs/${document.id}`)}>{tr('docs.read', lang)}</button>
</article>
{/each}
</div>
{/each}
<div class="grid cards">
{#each section.documents as document (document.id)}
<article class="card">

View File

@ -483,16 +483,31 @@ fn document_index(state: &AppState) -> Value {
.sections
.iter()
.map(|section| {
let documents: Vec<Value> = section
.documents
let documents = |ids: &[String]| -> Vec<Value> {
ids.iter()
.filter_map(|id| state.docs.document(id))
.map(|document| {
json_object! {
"id" => document.id.clone(),
"title" => bilingual("title", document),
"summary" => bilingual("summary", document),
"tags" => tag_lists(document),
}
})
.collect()
};
// A layer that covers two unrelated subjects carries a
// subject for each, rather than one list of documents
// that have nothing to do with one another.
let subsections: Vec<Value> = section
.subsections
.iter()
.filter_map(|id| state.docs.document(id))
.map(|document| {
.map(|subject| {
json_object! {
"id" => document.id.clone(),
"title" => bilingual("title", document),
"summary" => bilingual("summary", document),
"tags" => tag_lists(document),
"id" => subject.id.clone(),
"title" => optional_bilingual(&subject.title_zh, &subject.title),
"summary" => optional_bilingual(&subject.summary_zh, &subject.summary),
"documents" => documents(&subject.documents),
}
})
.collect();
@ -500,7 +515,8 @@ fn document_index(state: &AppState) -> Value {
"id" => section.id.clone(),
"title" => optional_bilingual(&section.title_zh, &section.title),
"summary" => optional_bilingual(&section.summary_zh, &section.summary),
"documents" => documents,
"documents" => documents(&section.documents),
"subsections" => subsections,
}
})
.collect();

View File

@ -415,8 +415,17 @@ pub struct Group {
}
/// A chapter, or layer, of a group.
///
/// A layer that covers two unrelated subjects declares a subsection for each,
/// so that a reader is not shown one flat list of documents that have
/// nothing to do with one another. When the list is empty the layer holds its
/// documents directly.
#[derive(Debug, Clone)]
pub struct Chapter {
/// The subjects of the layer, when it is presented as more than one.
///
/// Empty when the layer holds its documents directly.
pub subsections: Vec<Section>,
/// Chapter identifier.
pub id: String,
/// Display title.
@ -431,6 +440,24 @@ pub struct Chapter {
pub documents: Vec<String>,
}
/// One subject within a layer, used when a layer covers two that are
/// unrelated.
#[derive(Debug, Clone)]
pub struct Section {
/// Section identifier.
pub id: String,
/// Display title.
pub title: String,
/// Display title in Chinese, when the chapter list gives one.
pub title_zh: Option<String>,
/// One sentence description.
pub summary: String,
/// The same description in Chinese, when the chapter list gives one.
pub summary_zh: Option<String>,
/// Document identifiers, in reading order.
pub documents: Vec<String>,
}
/// The document collection plus its navigation order.
#[derive(Debug)]
pub struct Tree {
@ -470,12 +497,31 @@ impl Tree {
.get(&id)
.map(|document| document.chapter.clone())
.unwrap_or_else(|| "overview".to_string());
let section = groups
let holder = groups
.iter_mut()
.flat_map(|group| group.sections.iter_mut())
.find(|section| section.id == layer);
match section {
Some(section) => section.documents.push(id),
match holder {
Some(section) if section.subsections.is_empty() => section.documents.push(id),
Some(section) => {
// The layer is presented as subjects, so a document that
// the chapter list already placed in one of them stays
// where it is. Only an unlisted document is appended,
// and it goes into the first subject rather than beside
// them.
let already_listed = section
.subsections
.iter()
.any(|subject| subject.documents.contains(&id));
if !already_listed {
section
.subsections
.first_mut()
.expect("the layer has subjects")
.documents
.push(id);
}
}
None => {
let section = Chapter {
id: layer.clone(),
@ -484,6 +530,7 @@ impl Tree {
summary: String::new(),
summary_zh: None,
documents: vec![id],
subsections: Vec::new(),
};
match groups.iter_mut().find(|group| group.id == "implementation") {
Some(group) => group.sections.push(section),
@ -503,17 +550,37 @@ impl Tree {
for group in &mut groups {
for section in &mut group.sections {
section.documents.retain(|id| map.contains_key(id));
for subject in &mut section.subsections {
subject.documents.retain(|id| map.contains_key(id));
}
section
.subsections
.retain(|subject| !subject.documents.is_empty());
// A layer that declares subjects holds its documents in
// them. The layer's own list is dropped, because a document
// named in both places would be offered twice and the two
// entries would share a key, which stops the page rendering.
if !section.subsections.is_empty() {
section.documents.clear();
}
}
group
.sections
.retain(|section| !section.documents.is_empty());
.retain(|section| !section.documents.is_empty() || !section.subsections.is_empty());
}
groups.retain(|group| !group.sections.is_empty());
let order: Vec<String> = groups
.iter()
.flat_map(|group| group.sections.iter())
.flat_map(|section| section.documents.iter().cloned())
.flat_map(|section| {
section.documents.iter().cloned().chain(
section
.subsections
.iter()
.flat_map(|subject| subject.documents.iter().cloned()),
)
})
.collect();
Self {
@ -654,6 +721,46 @@ fn parse_group(value: &Value) -> Option<Group> {
fn parse_section(value: &Value) -> Option<Chapter> {
let id = value.get("id")?.as_str()?.to_string();
Some(Chapter {
title: value
.get("title")
.and_then(Value::as_str)
.unwrap_or(&id)
.to_string(),
title_zh: value
.get("title_zh")
.and_then(Value::as_str)
.map(str::to_string),
summary: value
.get("summary")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
summary_zh: value
.get("summary_zh")
.and_then(Value::as_str)
.map(str::to_string),
documents: value
.get("documents")
.map(Value::as_array)
.unwrap_or_default()
.iter()
.filter_map(|document| document.as_str().map(str::to_string))
.collect(),
subsections: value
.get("subsections")
.map(Value::as_array)
.unwrap_or_default()
.iter()
.filter_map(parse_subsection)
.collect(),
id,
})
}
/// Parses one subject within a layer.
fn parse_subsection(value: &Value) -> Option<Section> {
let id = value.get("id")?.as_str()?.to_string();
Some(Section {
title: value
.get("title")
.and_then(Value::as_str)
@ -1175,4 +1282,54 @@ BSTAR 项。
let missing = tree.missing_links();
assert_eq!(missing, vec![("sgp4".to_string(), "tle".to_string())]);
}
#[test]
fn a_document_is_offered_once_when_its_layer_has_subjects() {
// The physics layer declares two subjects, so its documents live in
// them. If a document were also left in the layer's own list, the
// page would render it twice under one key and stop rendering at
// all, so no identifier may appear twice in the reading order.
let documents = vec![
Document::parse(
"time-scales",
"physics",
Language::English,
"---\ntitle: Time scales\nsummary: s\n---\n\nScales.\n",
)
.expect("must parse"),
Document::parse(
"geodesy-wgs84",
"physics",
Language::English,
"---\ntitle: WGS84\nsummary: s\n---\n\nThe ellipsoid.\n",
)
.expect("must parse"),
];
let tree = Tree::new(documents);
let mut seen: Vec<&String> = tree.order.iter().collect();
let before = seen.len();
seen.sort();
seen.dedup();
assert_eq!(
before,
seen.len(),
"a document is offered twice: {:?}",
tree.order
);
// Both documents are still reachable, each under exactly one layer.
let physics = tree
.groups
.iter()
.flat_map(|group| group.sections.iter())
.find(|section| section.id == "physics")
.expect("the physics layer is declared");
assert!(
physics.documents.is_empty(),
"the layer still lists its own documents: {:?}",
physics.documents
);
assert!(physics.subsections.len() >= 2, "the subjects are missing");
}
}

View File

@ -329,15 +329,146 @@ fn inline(text: &str) -> String {
inline_items(text, None)
}
/// Renders a formula written between dollar signs.
///
/// The formula is found before any other inline markup, because a dollar
/// sign is otherwise ordinary text and a formula may itself contain the
/// characters that the other rules act on.
/// Reports whether a position falls inside a code span.
///
/// The span is found by counting the backticks before this position: an odd
/// number means the last one opened a span that has not been closed. Code
/// spans do not nest, so the count alone settles it.
fn inside_code_span(characters: &[char], index: usize) -> bool {
let mut backticks = 0;
let mut cursor = index;
while cursor > 0 {
cursor -= 1;
if characters[cursor] == '`' {
backticks += 1;
}
// A span ends at a line break, so a backtick on an earlier line
// cannot pair with one on this one.
if characters[cursor] == '\n' {
break;
}
}
backticks % 2 == 1
}
/// Finds a formula at `start`, if there is one.
///
/// Returns the source, whether it stands on its own line, and the position
/// just past it.
///
/// An inline formula may not begin or end with a space. That rule is what
/// separates a formula from a price or a shell prompt: in `$5 and `$HOME`
/// the second dollar has a space before it, so the pair is not a formula and
/// both dollars are text. Without the rule a sentence mentioning two prices
/// would be read as a formula between them.
fn find_formula(characters: &[char], start: usize) -> Option<(String, bool, usize)> {
// Two dollars open a formula that stands on its own line; a single one
// opens a formula within the sentence.
let display = characters.get(start + 1) == Some(&'$');
let open = if display { start + 2 } else { start + 1 };
if display {
// A display formula runs to the closing pair and may span lines.
let mut cursor = open;
while cursor + 1 < characters.len() {
// A code span is stepped over, for the same reason as below.
if characters[cursor] == '`' {
match find(characters, cursor + 1, '`') {
Some(end) => {
cursor = end + 1;
continue;
}
None => {
cursor += 1;
continue;
}
}
}
if characters[cursor] == '$' && characters[cursor + 1] == '$' {
let source: String = characters[open..cursor].iter().collect();
if source.trim().is_empty() {
return None;
}
return Some((source, true, cursor + 2));
}
cursor += 1;
}
return None;
}
// An inline formula must not open on a space, and must not close on one.
match characters.get(open) {
Some(character) if character.is_whitespace() => return None,
None => return None,
_ => {}
}
let mut cursor = open + 1;
while cursor < characters.len() {
// A code span is stepped over rather than read, so that a dollar
// inside one cannot close the formula. This is what keeps
// `` `$HOME` `` from being read as the end of one.
if characters[cursor] == '`' {
match find(characters, cursor + 1, '`') {
Some(end) => {
cursor = end + 1;
continue;
}
// An unmatched backtick is ordinary text.
None => {
cursor += 1;
continue;
}
}
}
if characters[cursor] == '$' {
if characters[cursor - 1].is_whitespace() {
return None;
}
let source: String = characters[open..cursor].iter().collect();
return Some((source, false, cursor + 1));
}
// A newline ends the sentence, and a formula does not cross one.
if characters[cursor] == '\n' {
return None;
}
cursor += 1;
}
None
}
/// Renders inline markup, resolving a shortcut link against `items`.
fn inline_items(text: &str, items: Option<&std::collections::HashSet<String>>) -> String {
let characters: Vec<char> = text.chars().collect();
let mut output = String::with_capacity(text.len());
let mut index = 0;
while index < characters.len() {
let character = characters[index];
// A formula is read before anything else, because a dollar sign is
// otherwise ordinary text and the characters inside a formula are
// not escaped. A dollar inside a code span is left to the code rule,
// which is the older one and the more specific of the two.
if character == '$' && !inside_code_span(&characters, index) {
match find_formula(&characters, index) {
Some((source, display, next)) => {
output.push_str(&crate::docs::mathml::render(&source, display));
index = next;
continue;
}
// An unpaired dollar is a dollar.
None => {
output.push('$');
index += 1;
continue;
}
}
}
if character == '`' {
if let Some(end) = find(&characters, index + 1, '`') {
let code: String = characters[index + 1..end].iter().collect();
@ -594,7 +725,7 @@ fn render_concept_link(inner: &str, items: Option<&std::collections::HashSet<Str
}
/// Escapes text for use in an HTML text node.
fn escape(text: &str) -> String {
pub(crate) fn escape(text: &str) -> String {
let mut output = String::with_capacity(text.len());
for character in text.chars() {
match character {
@ -614,7 +745,7 @@ fn escape(text: &str) -> String {
/// Only the characters that can end the attribute or start an entity are
/// escaped; the value never contains a quote because identifiers are
/// restricted by [`render_concept_link`] and links are escaped here too.
fn escape_attr(text: &str) -> String {
pub(crate) fn escape_attr(text: &str) -> String {
escape(text)
}
@ -736,6 +867,63 @@ fn strip_inline(text: &str) -> String {
mod tests {
use super::*;
#[test]
fn an_inline_formula_is_rendered() {
let html = render("The rate is $r$ per second.");
assert!(html.contains("<math"), "the formula was not rendered");
assert!(!html.contains("math-display"), "it belongs in the sentence");
assert!(html.contains("per second."), "the sentence was lost");
}
#[test]
fn a_display_formula_is_rendered_on_its_own() {
let html = render("$$a^2 + b^2 = c^2$$");
assert!(html.contains("math-display"), "the formula is not a block");
assert!(html.contains("display=\"block\""), "it is not a display");
}
#[test]
fn a_dollar_without_a_pair_is_left_alone() {
// A price must survive as a price.
let html = render("It costs $5 today.");
assert!(html.contains("$5"), "the lone dollar was consumed");
assert!(!html.contains("<math"), "no formula should be produced");
}
#[test]
fn two_prices_are_not_a_formula() {
// The closing dollar has a space before it, which is what separates
// this from a formula.
let html = render("It costs $5 and then $6.");
assert!(
!html.contains("<math"),
"two prices should not be a formula"
);
assert!(html.contains("$5 and then $6."), "the prices were consumed");
}
#[test]
fn a_dollar_inside_code_is_left_alone() {
let html = render("`$HOME` is the home directory.");
assert!(!html.contains("<math"), "code should not become a formula");
assert!(
html.contains("<code>$HOME</code>"),
"the code span was lost"
);
}
#[test]
fn a_formula_does_not_cross_a_line() {
let html = render("A $x$ and\nB $y$.");
assert_eq!(html.matches("<math").count(), 2, "each should be its own");
}
#[test]
fn a_backslash_in_a_formula_is_kept() {
let html = render("$\\frac{1}{2}$");
assert!(html.contains("<mfrac>"), "the fraction was not built");
}
#[test]
fn renders_headings_and_paragraphs() {
let html = render("# Title\n\nSome *text* and **more**.\n");

633
src/docs/mathml.rs Normal file
View File

@ -0,0 +1,633 @@
//! Rendering of mathematical notation for the documents.
//!
//! The documents carry formulas, and the reader needs them to look like
//! formulas rather than like source. A full TeX engine is a large dependency
//! with its own font files, so the notation used here is translated to
//! MathML instead. Browsers lay out and print MathML themselves, so no
//! script has to run and the output works without JavaScript.
//!
//! The subset is the one orbital mechanics actually uses: fractions,
//! superscripts, subscripts, roots, Greek letters, the common operators, and
//! the delimiters that group them. An expression outside the subset is shown
//! as written rather than dropped, so an unsupported command costs nothing
//! but looks plain.
//!
//! Two forms are recognised, following the convention the documents use:
//! `$...$` for a formula within a sentence and `$$...$$` for one on its own
//! line.
use crate::docs::markdown::{escape, escape_attr};
/// Converts a formula to MathML wrapped in a container.
///
/// `display` is set when the formula stands on its own line, which makes it
/// larger and centred.
pub fn render(latex: &str, display: bool) -> String {
let body = to_mathml(&normalize(latex));
let class = if display { "math math-display" } else { "math" };
// The original source is kept in a data attribute so that the formula
// can be recovered from the page, for copying and for the tests.
format!(
"<span class=\"{class}\" data-tex=\"{}\"><math xmlns=\"http://www.w3.org/1998/Math/MathML\" \
display=\"{display}\">{body}</math></span>",
escape_attr(latex),
display = if display { "block" } else { "inline" }
)
}
/// Rewrites the source into the tokens the translation loop reads.
///
/// Braces are turned into single characters so that a group is one unit, and
/// the spacing commands are dropped.
fn normalize(latex: &str) -> Vec<Token> {
let characters: Vec<char> = latex.chars().collect();
let mut tokens: Vec<Token> = Vec::with_capacity(characters.len());
let mut index = 0;
while index < characters.len() {
let character = characters[index];
match character {
'{' | '}' => {
tokens.push(Token::Group);
index += 1;
}
'\\' => {
// A command runs to the first non letter.
let mut end = index + 1;
while end < characters.len() && characters[end].is_ascii_alphabetic() {
end += 1;
}
if end == index + 1 {
// A backslash before a non letter is that character.
if let Some(next) = characters.get(index + 1) {
tokens.push(Token::Symbol(*next));
}
index += 2;
} else {
let name: String = characters[index + 1..end].iter().collect();
tokens.push(Token::Command(name));
index = end;
}
}
_ => {
tokens.push(Token::Symbol(character));
index += 1;
}
}
}
tokens
}
/// One unit of the formula after normalization.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Token {
/// A single character.
Symbol(char),
/// A backslash command, without the backslash.
Command(String),
/// An opening or closing brace; which one is taken from the position.
Group,
}
/// A parsed expression, ready to become MathML.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Node {
/// Text, already escaped.
Text(String),
/// A row of children.
Row(Vec<Node>),
/// A fraction.
Fraction(Box<Node>, Box<Node>),
/// A root.
Root(Box<Node>, Option<Box<Node>>),
/// A superscript.
Superscript(Box<Node>, Box<Node>),
/// A subscript.
Subscript(Box<Node>, Box<Node>),
/// A subscript and a superscript on one base.
Scripts(Box<Node>, Box<Node>, Box<Node>),
/// A named function such as `sin`.
Function(String),
}
/// The Greek letters, by command.
fn greek(name: &str) -> Option<&'static str> {
Some(match name {
"alpha" => "α",
"beta" => "β",
"gamma" => "γ",
"delta" => "δ",
"epsilon" => "ε",
"varepsilon" => "ε",
"zeta" => "ζ",
"eta" => "η",
"theta" => "θ",
"vartheta" => "ϑ",
"iota" => "ι",
"kappa" => "κ",
"lambda" => "λ",
"mu" => "μ",
"nu" => "ν",
"xi" => "ξ",
"pi" => "π",
"varpi" => "ϖ",
"rho" => "ρ",
"varrho" => "ϱ",
"sigma" => "σ",
"varsigma" => "ς",
"tau" => "τ",
"upsilon" => "υ",
"phi" => "φ",
"varphi" => "ϕ",
"chi" => "χ",
"psi" => "ψ",
"omega" => "ω",
"Gamma" => "Γ",
"Delta" => "Δ",
"Theta" => "Θ",
"Lambda" => "Λ",
"Xi" => "Ξ",
"Pi" => "Π",
"Sigma" => "Σ",
"Upsilon" => "Υ",
"Phi" => "Φ",
"Psi" => "Ψ",
"Omega" => "Ω",
_ => return None,
})
}
/// The operators, by command.
fn operator(name: &str) -> Option<&'static str> {
Some(match name {
"times" => "×",
"cdot" => "⋅",
"div" => "÷",
"pm" => "±",
"mp" => "∓",
"leq" | "le" => "≤",
"geq" | "ge" => "≥",
"neq" | "ne" => "≠",
"approx" => "≈",
"sim" => "∼",
"propto" => "∝",
"equiv" => "≡",
"ll" => "≪",
"gg" => "≫",
"to" | "rightarrow" => "→",
"leftarrow" => "←",
"Rightarrow" => "⇒",
"Leftarrow" => "⇐",
"in" => "∈",
"notin" => "∉",
"subset" => "⊂",
"subseteq" => "⊆",
"supset" => "⊃",
"infty" => "∞",
"partial" => "∂",
"nabla" => "∇",
"sum" => "∑",
"prod" => "∏",
"int" => "∫",
"forall" => "∀",
"exists" => "∃",
"circ" => "∘",
_ => return None,
})
}
/// The functions, by command; these are set upright rather than italic.
fn function(name: &str) -> Option<&'static str> {
Some(match name {
"sin" => "sin",
"cos" => "cos",
"tan" => "tan",
"cot" => "cot",
"sec" => "sec",
"csc" => "csc",
"arcsin" => "arcsin",
"arccos" => "arccos",
"arctan" => "arctan",
"sinh" => "sinh",
"cosh" => "cosh",
"tanh" => "tanh",
"log" => "log",
"ln" => "ln",
"lg" => "lg",
"exp" => "exp",
"min" => "min",
"max" => "max",
"det" => "det",
"mod" => "mod",
"arg" => "arg",
"lim" => "lim",
_ => return None,
})
}
/// Renders a token sequence to MathML.
fn to_mathml(tokens: &[Token]) -> String {
let mut cursor = 0;
let row = parse_row(tokens, &mut cursor, None);
match row.len() {
1 => emit(&row[0]),
_ => emit(&Node::Row(row)),
}
}
/// Parses until the end of the input or, when `stop` is given, until the
/// group that ends the enclosing group.
fn parse_row(tokens: &[Token], cursor: &mut usize, stop: Option<&[char]>) -> Vec<Node> {
let mut row: Vec<Node> = Vec::new();
while *cursor < tokens.len() {
match &tokens[*cursor] {
// A closing brace ends the enclosing group; a delimiter set
// ends the stretchy group.
Token::Group => {
if stop.is_some_and(|set| set.contains(&'}')) {
*cursor += 1;
return row;
}
if stop.is_some_and(|set| set.contains(&')')) {
return row;
}
*cursor += 1;
let inner = parse_row(tokens, cursor, Some(&['}']));
row.push(Node::Row(inner));
}
Token::Symbol(symbol) => {
if stop.is_some_and(|set| set.contains(symbol)) {
return row;
}
*cursor += 1;
row.extend(parse_symbol(*symbol, tokens, cursor, &mut 0));
}
Token::Command(name) => {
if stop.is_some_and(|set| {
set.contains(&name.as_str()[..1].chars().next().unwrap_or(' '))
}) {
return row;
}
*cursor += 1;
row.extend(parse_command(name, tokens, cursor));
}
}
}
row
}
/// Reads the argument of a command, which is either a group or one symbol.
fn parse_argument(tokens: &[Token], cursor: &mut usize) -> Node {
match tokens.get(*cursor) {
Some(Token::Group) => {
*cursor += 1;
Node::Row(parse_row(tokens, cursor, Some(&['}'])))
}
// A square bracket is a group too, and is how a root takes its
// index: `\sqrt[3]{x}`.
Some(Token::Symbol('[')) => {
*cursor += 1;
let inner = parse_row(tokens, cursor, Some(&[',', ']']));
if matches!(tokens.get(*cursor), Some(Token::Symbol(']'))) {
*cursor += 1;
}
Node::Row(inner)
}
Some(Token::Symbol(symbol)) => {
let symbol = *symbol;
*cursor += 1;
Node::Text(symbol.to_string())
}
Some(Token::Command(name)) => {
let name = name.clone();
*cursor += 1;
match greek(&name) {
Some(text) => Node::Text(text.to_string()),
None => match operator(&name) {
Some(text) => Node::Text(text.to_string()),
None => Node::Text(format!("\\{name}")),
},
}
}
None => Node::Text(String::new()),
}
}
/// Reads the scripts that follow a base, and returns the node with them.
fn attach_scripts(base: Node, tokens: &[Token], cursor: &mut usize) -> Node {
let mut superscript: Option<Node> = None;
let mut subscript: Option<Node> = None;
loop {
let marker = match tokens.get(*cursor) {
Some(Token::Symbol('^')) => '^',
Some(Token::Symbol('_')) => '_',
Some(Token::Command(name)) if name == "sup" => '^',
Some(Token::Command(name)) if name == "sub" => '_',
_ => break,
};
*cursor += 1;
let argument = parse_argument(tokens, cursor);
if marker == '^' {
superscript = Some(argument);
} else {
subscript = Some(argument);
}
}
match (subscript, superscript) {
(None, None) => base,
(None, Some(top)) => Node::Superscript(Box::new(base), Box::new(top)),
(Some(bottom), None) => Node::Subscript(Box::new(base), Box::new(bottom)),
(Some(bottom), Some(top)) => Node::Scripts(Box::new(base), Box::new(bottom), Box::new(top)),
}
}
/// The digit at a position.
fn characters_digit(tokens: &[Token], cursor: usize) -> char {
match tokens.get(cursor) {
Some(Token::Symbol(symbol)) => *symbol,
_ => '0',
}
}
/// Handles a plain character, including the brackets that group.
fn parse_symbol(symbol: char, tokens: &[Token], cursor: &mut usize, _depth: &mut u32) -> Vec<Node> {
let base = match symbol {
// Digits are read together as one number, so that `12` is a number
// rather than two identifiers. A dot between digits belongs to it,
// which also stops the layout breaking the number across a line.
'0'..='9' => {
let mut text = symbol.to_string();
loop {
match tokens.get(*cursor) {
Some(Token::Symbol('.'))
if matches!(tokens.get(*cursor + 1), Some(Token::Symbol('0'..='9'))) =>
{
text.push('.');
*cursor += 1;
}
Some(Token::Symbol('0'..='9')) => {
text.push(characters_digit(tokens, *cursor));
*cursor += 1;
}
_ => break,
}
}
Node::Text(text)
}
// An opening bracket stretches to the height of what it contains.
'(' => {
let inner = parse_row(tokens, cursor, Some(&[',', ')']));
if matches!(tokens.get(*cursor), Some(Token::Symbol(')'))) {
*cursor += 1;
}
return vec![Node::Row(vec![
Node::Text("(".to_string()),
Node::Row(inner),
Node::Text(")".to_string()),
])];
}
'[' => {
let inner = parse_row(tokens, cursor, Some(&[',', ']']));
if matches!(tokens.get(*cursor), Some(Token::Symbol(']'))) {
*cursor += 1;
}
return vec![Node::Row(vec![
Node::Text("[".to_string()),
Node::Row(inner),
Node::Text("]".to_string()),
])];
}
'{' => {
let inner = parse_row(tokens, cursor, Some(&['}']));
return vec![Node::Row(inner)];
}
'_' | '^' => Node::Text(symbol.to_string()),
// The spaces are kept between words and dropped elsewhere, which is
// what keeps `a b` apart without leaving gaps after a bracket.
other => Node::Text(other.to_string()),
};
vec![attach_scripts(base, tokens, cursor)]
}
/// Handles a backslash command.
fn parse_command(name: &str, tokens: &[Token], cursor: &mut usize) -> Vec<Node> {
if let Some(letter) = greek(name) {
return vec![attach_scripts(
Node::Text(letter.to_string()),
tokens,
cursor,
)];
}
if let Some(symbol) = operator(name) {
return vec![attach_scripts(
Node::Text(symbol.to_string()),
tokens,
cursor,
)];
}
if let Some(label) = function(name) {
// A function is upright, so it is emitted as its own node rather
// than as an italic variable.
return vec![attach_scripts(
Node::Function(label.to_string()),
tokens,
cursor,
)];
}
match name {
"frac" | "dfrac" | "tfrac" => {
let top = parse_argument(tokens, cursor);
let bottom = parse_argument(tokens, cursor);
vec![attach_scripts(
Node::Fraction(Box::new(top), Box::new(bottom)),
tokens,
cursor,
)]
}
"sqrt" => {
// The index, when there is one, is given in brackets before the
// radicand: `\sqrt[3]{x}`.
let degree = if matches!(tokens.get(*cursor), Some(Token::Symbol('['))) {
Some(Box::new(parse_argument(tokens, cursor)))
} else {
None
};
let radicand = parse_argument(tokens, cursor);
vec![attach_scripts(
Node::Root(Box::new(radicand), degree),
tokens,
cursor,
)]
}
"left" | "right" | "big" | "Big" | "bigg" | "Bigg" => {
// The size and pairing commands are structural; the delimiter
// that follows is read as an ordinary symbol.
Vec::new()
}
"quad" => vec![Node::Text("\u{2009}\u{2009}".to_string())],
// The spacing commands are punctuation with no shape of their own.
"," => vec![Node::Text(", ".to_string())],
";" => vec![Node::Text("; ".to_string())],
":" => vec![Node::Text(": ".to_string())],
"text" | "mathrm" | "operatorname" | "mbox" => {
let inner = parse_argument(tokens, cursor);
vec![Node::Row(vec![inner])]
}
"mathbf" | "mathit" | "mathsf" | "mathtt" => {
let inner = parse_argument(tokens, cursor);
vec![Node::Row(vec![inner])]
}
// An unknown command is shown as written, so that a formula that
// uses one still reads rather than losing part of itself.
other => vec![Node::Text(format!("\\{other}"))],
}
}
/// Writes a node as MathML.
fn emit(node: &Node) -> String {
match node {
// MathML distinguishes an identifier, a number and operators, and a
// browser uses the element to choose the font: an upright digit in a
// serif face reads as a number rather than as a variable.
Node::Text(text) => {
let mut characters = text.chars();
match (characters.next(), characters.next()) {
(Some(single), None) if single.is_numeric() => {
format!("<mn>{}</mn>", escape(text))
}
(Some(single), None) if single.is_alphabetic() => {
format!("<mi>{}</mi>", escape(text))
}
_ => escape(text),
}
}
Node::Function(label) => format!("<mi>{}</mi>", escape(label)),
Node::Row(children) => match children.len() {
// A row of one is the child itself; an extra pair of brackets
// would be noise in the markup and would break the grouping the
// element's layout relies on.
1 => emit(&children[0]),
_ => {
let body: String = children.iter().map(emit).collect();
format!("<mrow>{body}</mrow>")
}
},
Node::Fraction(top, bottom) => format!(
"<mfrac><mrow>{}</mrow><mrow>{}</mrow></mfrac>",
emit(top),
emit(bottom)
),
Node::Root(radicand, degree) => match degree {
Some(degree) => format!(
"<mroot><mrow>{}</mrow><mrow>{}</mrow></mroot>",
emit(radicand),
emit(degree)
),
None => format!("<msqrt><mrow>{}</mrow></msqrt>", emit(radicand)),
},
Node::Superscript(base, top) => format!(
"<msup><mrow>{}</mrow><mrow>{}</mrow></msup>",
emit(base),
emit(top)
),
Node::Subscript(base, bottom) => format!(
"<msub><mrow>{}</mrow><mrow>{}</mrow></msub>",
emit(base),
emit(bottom)
),
Node::Scripts(base, bottom, top) => format!(
"<msubsup><mrow>{}</mrow><mrow>{}</mrow><mrow>{}</mrow></msubsup>",
emit(base),
emit(bottom),
emit(top)
),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The MathML for a formula, without the wrapper, so that a test can
/// look at the structure alone.
fn mathml(latex: &str) -> String {
to_mathml(&normalize(latex))
}
#[test]
fn a_variable_is_an_identifier() {
assert_eq!(mathml("x"), "<mi>x</mi>");
}
#[test]
fn a_power_becomes_a_superscript() {
assert_eq!(
mathml("x^2"),
"<msup><mrow><mi>x</mi></mrow><mrow><mn>2</mn></mrow></msup>"
);
}
#[test]
fn a_group_holds_several_tokens() {
assert_eq!(
mathml("x^{12}"),
"<msup><mrow><mi>x</mi></mrow><mrow>12</mrow></msup>"
);
}
#[test]
fn a_fraction_has_a_numerator_and_a_denominator() {
assert_eq!(
mathml("\\frac{a}{b}"),
"<mfrac><mrow><mi>a</mi></mrow><mrow><mi>b</mi></mrow></mfrac>"
);
}
#[test]
fn a_greek_letter_is_translated() {
assert_eq!(mathml("\\theta"), "<mi>θ</mi>");
}
#[test]
fn an_unknown_command_is_kept_as_written() {
// Losing part of a formula would be worse than showing it plainly.
assert_eq!(mathml("\\notacommand"), "\\notacommand");
}
#[test]
fn a_comma_is_kept() {
// The command is the spacing one, so dropping it would run a list of
// symbols together.
assert_eq!(
mathml("\\alpha, \\beta"),
"<mrow><mi>α</mi>, <mi>β</mi></mrow>"
);
}
#[test]
fn a_root_can_have_an_index() {
let html = mathml("\\sqrt[3]{x}");
assert!(html.contains("<mroot>"), "the index was lost: {html}");
}
#[test]
fn text_is_escaped() {
assert!(mathml("a<b").contains("&lt;"));
}
#[test]
fn a_display_formula_is_marked_as_such() {
let html = render("x^2", true);
assert!(html.contains("math-display"));
assert!(html.contains("display=\"block\""));
}
#[test]
fn the_source_is_kept_in_the_output() {
// The page keeps the source so the formula can be copied back.
assert!(render("x^2", false).contains("data-tex=\"x^2\""));
}
}

View File

@ -20,6 +20,7 @@ pub mod api;
pub mod content;
pub mod document;
pub mod markdown;
pub mod mathml;
#[allow(unused_imports)]
pub use api::ApiItem;