From 50d6ecc9fd0ee1c16f4b826903b8d913f7a5733f Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 1 Oct 2026 22:46:56 +0800 Subject: [PATCH] web: scroll to the top on navigation, organize the interface by layer, and set the formulas in the tool output A page change did not move the scroll position, so following a link from the bottom of a long document landed in the middle of the next one. The scroll now follows the path rather than the fragment, so a filter applied to the page a reader is on leaves them where they are. The browser fires `popstate` for a fragment navigation as well as for a traversal, so there is no event that means "this was a traversal" on its own, and going back starts at the top rather than where the reader left. The interface index listed fifteen crates in one grid. It is now grouped by layer, with the title and order the concept documentation already declares, and a card shows the opening sentence of the crate summary rather than the whole of it, which is what made the cards four hundred pixels tall. A tool result showed its column headings, scalar labels and notes in the reader's language, but left the table body in English. Text cells are now looked up as well. Units are written with a power, and are set as one. A tool description is Markdown, so it is now rendered as such, as are the parameter help and the notes. A formula written between dollar signs is set the same way in both places. --- frontend/src/App.svelte | 14 ++- frontend/src/app.css | 38 ++++++++ frontend/src/lib/ResultView.svelte | 29 ++++-- frontend/src/lib/format.ts | 15 ++- frontend/src/lib/router.ts | 37 ++++++++ frontend/src/lib/types.ts | 47 +++++++++- frontend/src/pages/Api.svelte | 63 ++++++++++--- frontend/src/pages/Tools.svelte | 16 +++- src/api/mod.rs | 110 ++++++++++++++++++++++ src/docs/mathml.rs | 12 ++- src/tools/labels.rs | 3 + src/tools/mod.rs | 20 +++- src/tools/out.rs | 143 ++++++++++++++++++++++++++++- 13 files changed, 517 insertions(+), 30 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 2d99b20..bf14afd 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -17,7 +17,12 @@ SearchResponse, } from './lib/types'; import { getJson, queryString } from './lib/api'; - import { currentRoute, onRouteChange, navigate } from './lib/router'; + import { + currentRoute, + navigate, + onRouteChange, + scrollToTopOnNavigation, + } from './lib/router'; import { language, pick, setLanguage, tr, type Language } from './lib/i18n'; import { theme, toggleTheme } from './lib/theme'; import { untrack } from 'svelte'; @@ -134,10 +139,15 @@ $effect(() => { void loadShared(); + // A new page starts at the top; going back keeps the position. + const stopNavigation = scrollToTopOnNavigation(); const stop = onRouteChange(() => { route = currentRoute(); }); - return stop; + return () => { + stop(); + stopNavigation(); + }; }); $effect(() => { diff --git a/frontend/src/app.css b/frontend/src/app.css index 52545eb..163bff8 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -653,6 +653,44 @@ button.ghost:hover:not(:disabled) { overflow-wrap: anywhere; } +/* + * A card on the interface index shows what a crate is, in one or two lines. + * + * The cards there are in a grid with three columns on a wide screen, so a + * single long summary made every card in its row as tall as the longest one. + * The text is cut rather than the card stretched, because the rest of the + * summary is on the page the card leads to. + */ +.crate-grid .card-clamp { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; + margin: 0.4rem 0 0; + color: var(--text-muted); + font-size: 0.9rem; +} + +/* A layer heading on the interface index. */ +.api-layer-heading { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin: 1.75rem 0 0; + font-size: 1.05rem; +} + +.api-layer-heading a { + color: var(--text); +} + +.api-layer-summary { + margin: 0.25rem 0 0; + color: var(--text-muted); + font-size: 0.9rem; +} + .grid { display: grid; gap: 1rem; diff --git a/frontend/src/lib/ResultView.svelte b/frontend/src/lib/ResultView.svelte index 93bc6d9..c0eb22f 100644 --- a/frontend/src/lib/ResultView.svelte +++ b/frontend/src/lib/ResultView.svelte @@ -30,7 +30,9 @@
{pick(scalar.label, lang)} - {scalar.value}{#if scalar.unit}{scalar.unit}{/if} + {scalar.value}{#if scalar.unit}{#if scalar.unit_html}{@html scalar.unit_html}{:else}{scalar.unit}{/if}{/if}
{/each} @@ -48,7 +50,9 @@ {#each result.table.columns as column} - {pick(column.label, lang)}{#if column.unit} [{column.unit}]{/if} + {pick(column.label, lang)}{#if column.unit} [{#if column.unit_html}{@html column.unit_html}{:else}{column.unit}{/if}]{/if} {/each} @@ -58,7 +62,18 @@ {#each row as cell, index} - {formatCell(cell, result.table.columns[index]?.numeric ?? false)} + {#if typeof cell === 'object' && cell !== null && cell.html} + + {@html cell.html} + {:else} + {formatCell( + cell, + result.table.columns[index]?.numeric ?? false, + lang, + )} + {/if} {/each} @@ -69,9 +84,11 @@ {/if} {#if result.notes.length > 0} -
- {#each result.notes as note} -

{pick(note, lang)}

+
+ {#each result.notes as note, index (index)} + + {@html pick(result.notes_html?.[index] ?? { zh: note.zh, en: note.en }, lang)} {/each}
{/if} diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 1e82428..c2f7e7c 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -5,6 +5,9 @@ * number is written the same way everywhere. */ +import type { Bilingual } from './types'; +import { pick, type Language } from './i18n'; + /** Formats a number with a sensible number of decimals for its magnitude. */ export function formatNumber(value: number | null | undefined): string { if (value === null || value === undefined || !Number.isFinite(value)) { @@ -21,11 +24,21 @@ export function formatNumber(value: number | null | undefined): string { } /** Formats a table cell according to the column type. */ -export function formatCell(value: string | number | null, numeric: boolean): string { +export function formatCell( + value: string | number | Bilingual | null, + numeric: boolean, + lang: Language, +): string { if (value === null || value === undefined) return '—'; if (typeof value === 'number') { return numeric ? formatNumber(value) : String(value); } + if (typeof value === 'object') { + // A text cell may carry a translation of a fixed phrase, such as the + // meaning of a field. A cell holding data rather than prose carries the + // same string in both languages and needs no lookup. + return pick(value, lang); + } return value; } diff --git a/frontend/src/lib/router.ts b/frontend/src/lib/router.ts index 944688d..3cb88d9 100644 --- a/frontend/src/lib/router.ts +++ b/frontend/src/lib/router.ts @@ -60,6 +60,43 @@ export function onRouteChange(listener: () => void): () => void { return () => window.removeEventListener('hashchange', listener); } +/** + * Returns the page to the top when the route changes. + * + * A hash change does not move the scroll position, so a reader who has + * scrolled to the bottom of a long document and follows a link lands in the + * middle of the next page, with nothing to show that it is a different one. + * + * The scroll follows the path rather than the fragment, so that a search or a + * filter applied to the page a reader is already on leaves them where they + * are. Only a move to another page starts at the top. + * + * Going back also starts at the top rather than where the reader left. The + * browser would restore that position itself, but it cannot be told apart + * from an ordinary link: a fragment navigation creates a history entry, and + * the browser fires `popstate` for it as well as for a traversal, so there is + * no event that means "this was a traversal" on its own. Restoring the + * position by watching the path would mean keeping a history of positions, + * which is more machinery than the gain is worth here. + */ +export function scrollToTopOnNavigation(): () => void { + let previous: string | null = null; + + const onChange = () => { + const path = window.location.hash.split('?')[0]; + if (path === previous) return; + previous = path; + window.scrollTo(0, 0); + }; + + // The first page is already at the top, but the position is recorded so + // that moving away and back is treated as two changes rather than one. + previous = window.location.hash.split('?')[0]; + + window.addEventListener('hashchange', onChange); + return () => window.removeEventListener('hashchange', onChange); +} + /** * Intercepts clicks on internal links so that the router handles them. * diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index e229651..e0b947c 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -202,6 +202,11 @@ export interface BilingualSummary { en: string; plain: Bilingual; html: Bilingual; + /** + * The opening sentence, for a card that links onward. The rest of the + * summary belongs on the page the card leads to. + */ + brief?: Bilingual; } /** One interface item. */ @@ -214,11 +219,20 @@ export interface ApiItem { associated: boolean; } +/** One layer of the library, used to organize the interface index. */ +export interface ApiLayer { + id: string; + title: Bilingual; + summary: Bilingual; +} + /** The interface catalog index. */ export interface ApiIndex { generated_from: string[]; crate_count: number; item_count: number; + /** The layers, in reading order, that the crates are grouped by. */ + layers: ApiLayer[]; crates: ApiCrateSummary[]; items: { name: string; kind: string; crate: string }[]; } @@ -244,6 +258,8 @@ export interface ToolParam { name: string; label: Bilingual; help: Bilingual; + /** The help rendered from Markdown, so that a formula in it is set. */ + help_html?: Bilingual; kind: 'number' | 'integer' | 'text' | 'multiline' | 'select'; required: boolean; default: string | null; @@ -256,6 +272,8 @@ export interface Tool { name: Bilingual; summary: Bilingual; description: Bilingual; + /** The description rendered from Markdown, so that a formula in it is set. */ + description_html?: Bilingual; category: Bilingual; params: ToolParam[]; api_items: string[]; @@ -272,13 +290,36 @@ export interface ResultScalar { label: Bilingual; value: string; unit: string; + /** The unit rendered, so that a power in it reads as a power. */ + unit_html?: string; monospace: boolean; } +/** A column of a result table. */ +export interface ResultColumn { + key: string; + label: Bilingual; + unit: string; + /** The unit rendered, so that a power in it reads as a power. */ + unit_html?: string; + numeric: boolean; +} + +/** + * One cell of a result table. + * + * A cell is usually a number. A text cell may carry a translation, because a + * cell holding a fixed phrase, such as the meaning of a field, is written + * once and shown in the reader's language. Such a cell may also carry the + * text rendered, which is how a unit written with a power in it, such as + * `rev/day^2`, is shown as a power. + */ +export type ResultCell = string | number | (Bilingual & { html?: string }) | null; + /** A result table. */ export interface ResultTable { - columns: { key: string; label: Bilingual; unit: string; numeric: boolean }[]; - rows: (string | number | null)[][]; + columns: ResultColumn[]; + rows: ResultCell[][]; } /** A numeric series. */ @@ -296,6 +337,8 @@ export interface ToolResult { table: ResultTable; series: ResultSeries[]; notes: Bilingual[]; + /** The notes rendered from Markdown, which is how a formula in one is set. */ + notes_html?: Bilingual[]; axis?: { name: Bilingual; unit: string; values: number[] }; } diff --git a/frontend/src/pages/Api.svelte b/frontend/src/pages/Api.svelte index 4e777aa..e4d214e 100644 --- a/frontend/src/pages/Api.svelte +++ b/frontend/src/pages/Api.svelte @@ -119,6 +119,35 @@ return names ? pick(names, language) : kind; } + /** + * The crates of the index, grouped by the layer they belong to. + * + * The index is organized by layer for the same reason the concept + * documentation is: a reader looking for a type is looking for the subject + * it belongs to, and one flat list of fifteen crates gives no hint of where + * to look. The layers come from the concept documentation, so the order and + * the titles are the same in both. + * + * A layer the catalog names but the documentation does not declare would + * leave its crates with no heading, so those are collected at the end. + */ + const layers = $derived.by(() => { + const current = index; + if (!current) return []; + const declared = current.layers ?? []; + const grouped = declared + .map((layer) => ({ + ...layer, + crates: current.crates.filter((krate) => krate.layer === layer.id), + })) + .filter((layer) => layer.crates.length > 0); + const placed = new Set(declared.map((layer) => layer.id)); + const rest = current.crates.filter((krate) => !placed.has(krate.layer)); + return rest.length > 0 + ? [...grouped, { id: '', title: { zh: '其他', en: 'Others' }, summary: { zh: '', en: '' }, crates: rest }] + : grouped; + }); + /** Opens an item route. */ function openItem(name: string) { navigate(`/api/${name}`); @@ -263,18 +292,28 @@ {/if} - + {#each layers as layer (layer.id)} +

+ {pick(layer.title, lang)} + {layer.crates.length} +

+ {#if pick(layer.summary, lang)} +

{pick(layer.summary, lang)}

+ {/if} + + {/each} {/if}
diff --git a/frontend/src/pages/Tools.svelte b/frontend/src/pages/Tools.svelte index 371f4c0..ebeb2d4 100644 --- a/frontend/src/pages/Tools.svelte +++ b/frontend/src/pages/Tools.svelte @@ -148,7 +148,12 @@

{pick(tool.name, lang)}

{pick(tool.category, lang)} -

{pick(tool.description, lang)}

+ +
+ {@html pick(tool.description_html ?? { zh: tool.description.zh, en: tool.description.en }, lang)} +
{ @@ -185,7 +190,14 @@ {:else} {/if} - {pick(param.help, lang)} + + {@html pick( + param.help_html ?? { zh: param.help.zh, en: param.help.en }, + lang, + )} {#if param.constraints.unit} {tr('tools.unit', lang)}: {param.constraints.unit} {/if} diff --git a/src/api/mod.rs b/src/api/mod.rs index 33be980..95c6729 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -274,6 +274,10 @@ impl Router { "generated_from" => catalog.generated_from.iter().map(|path| Value::from(path.as_str())).collect::>(), "crate_count" => catalog.crates.len(), "item_count" => catalog.item_count(), + // The layers the crates belong to, in reading order, so that the + // index is organized the same way as the concept documentation + // rather than as one flat list. + "layers" => api_layers(&self.state.docs), "crates" => crates, "items" => items, }; @@ -566,6 +570,12 @@ fn crate_texts(krate: &crate::docs::ApiCrate, items: &std::collections::HashSet< "zh" => crate_summary_plain(zh), "en" => crate_summary_plain(&krate.summary), }, + // The index card takes the opening sentence; the crate page shows the + // whole summary under `html`. + "brief" => json_object! { + "zh" => crate_summary_brief(zh), + "en" => crate_summary_brief(&krate.summary), + }, "html" => json_object! { "zh" => crate::docs::markdown::render_with(zh, Some(items)), "en" => crate::docs::markdown::render_with(&krate.summary, Some(items)), @@ -581,6 +591,78 @@ fn crate_summary_plain(summary: &str) -> String { crate::docs::markdown::to_plain_text(summary) } +/// Returns the first sentence of a summary, for a card that links onward. +/// +/// Returns the layers of the concept documentation, in reading order. +/// +/// The interface catalog records which layer each crate belongs to. The index +/// groups the crates by that layer, so it needs the layer's title and its +/// place in the reading order, both of which the concept documentation +/// already declares. +fn api_layers(docs: &crate::docs::Tree) -> Value { + let layers: Vec = docs + .groups() + .iter() + .flat_map(|group| group.sections.iter()) + .map(|section| { + json_object! { + "id" => section.id.clone(), + "title" => optional_bilingual(§ion.title_zh, §ion.title), + "summary" => optional_bilingual(§ion.summary_zh, §ion.summary), + } + }) + .collect(); + Value::Array(layers) +} + +/// Returns the first sentence of a summary, for a card that links onward. +/// +/// The crate summary is the whole of the crate documentation, which runs to +/// several paragraphs and a list of what the crate holds. A card on the index +/// shows what a crate is, and the crate page shows the rest, so the card takes +/// the opening sentence only. Without this the index cards are as tall as the +/// longest crate document and the page reads as a wall. +fn crate_summary_brief(summary: &str) -> String { + let text = crate_summary_plain(summary); + let text = text.trim(); + // A sentence ends at the first full stop that is not part of a number or + // an abbreviation, which keeps `1.5` and `e.g.` from cutting a sentence + // in half. + let mut end = None; + for (index, character) in text.char_indices() { + // A Chinese sentence ends at its own full stop and is not followed by + // a space, so those three end a sentence wherever they fall. + if matches!(character, '\u{3002}' | '\u{ff01}' | '\u{ff1f}') { + end = Some(index + character.len_utf8()); + break; + } + if character == '.' { + let before = text[..index].chars().next_back(); + let after = text[index + 1..].chars().next(); + let ends_number = before.is_some_and(|c| c.is_ascii_digit()) + && after.is_some_and(|c| c.is_ascii_digit()); + let next_is_space = after.is_none_or(|c| c.is_whitespace()); + if !ends_number && next_is_space { + end = Some(index + 1); + break; + } + } + } + let first = match end { + Some(index) => &text[..index], + None => text, + }; + let first = first.trim(); + // A summary with no full stop would otherwise put a whole paragraph on a + // card, so the length is capped as well. + if first.chars().count() <= 160 { + return first.to_string(); + } + let mut cut: String = first.chars().take(157).collect(); + cut.push('…'); + cut +} + /// Encodes a document title or summary in both languages. /// /// The two names select the field of a document; an unknown name yields an @@ -1039,4 +1121,32 @@ mod tests { Status::MethodNotAllowed ); } + + #[test] + fn a_crate_card_takes_the_first_sentence_only() { + // The index shows what a crate is; the crate page shows the rest. + assert_eq!( + crate_summary_brief( + "ApsisRS \u{7684}\u{5171}\u{4eab}\u{57fa}\u{7840}\u{7c7b}\u{578b}\u{3002}\u{672c} crate \u{4f4d}\u{4e8e}\u{4f9d}\u{8d56}\u{56fe}\u{7684}\u{5e95}\u{5c42}\u{3002}" + ), + "ApsisRS \u{7684}\u{5171}\u{4eab}\u{57fa}\u{7840}\u{7c7b}\u{578b}\u{3002}" + ); + } + + #[test] + fn a_number_is_not_a_sentence_end() { + // A version or a measurement must not cut a sentence in half. + assert_eq!( + crate_summary_brief("Reads version 1.5 of the format. Then it checksums."), + "Reads version 1.5 of the format." + ); + } + + #[test] + fn a_summary_without_a_full_stop_is_capped() { + let long = "word ".repeat(80); + let brief = crate_summary_brief(&long); + assert!(brief.chars().count() <= 158, "the brief is too long"); + assert!(brief.ends_with('\u{2026}'), "it should be marked as cut"); + } } diff --git a/src/docs/mathml.rs b/src/docs/mathml.rs index 14df430..e9082f6 100644 --- a/src/docs/mathml.rs +++ b/src/docs/mathml.rs @@ -501,6 +501,13 @@ fn emit(node: &Node) -> String { (Some(single), None) if single.is_alphabetic() => { format!("{}", escape(text)) } + // An operator is written as one rather than as bare text. A + // browser sets text inside a row as ordinary glyphs with no + // spacing around it, so `rev/day` came out as `revday`; an + // operator carries its own spacing and cannot be swallowed. + (Some(single), None) if single.is_ascii_punctuation() => { + format!("{}", escape(text)) + } _ => escape(text), } } @@ -600,10 +607,11 @@ mod tests { #[test] fn a_comma_is_kept() { // The command is the spacing one, so dropping it would run a list of - // symbols together. + // symbols together. It is written as an operator so that it keeps its + // space, which is what a comma is for. assert_eq!( mathml("\\alpha, \\beta"), - "α, β" + "α, β" ); } diff --git a/src/tools/labels.rs b/src/tools/labels.rs index b6d24bb..823b279 100644 --- a/src/tools/labels.rs +++ b/src/tools/labels.rs @@ -90,6 +90,9 @@ const PAIRS: &[(&str, &str)] = &[ ), ("Semi-major axis estimate", "半长轴估计"), ("Unit", "单位"), + ("first", "第一次"), + ("Two-line element set", "两行元素集"), + ("second", "第二次"), // Propagation ("Branch", "分支"), ("Minutes from epoch", "距历元分钟数"), diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 3f1ae30..6504701 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -755,8 +755,8 @@ const TOOL_TABLE: &[ToolSpec] = &[ "Compute the two burns of a transfer between coplanar circular orbits.", ), description: Text::new( - "返回将卫星在两个共面圆轨道之间转移的两次速度增量、两次点火之间的滑行时间以及总速度增量。正点火抬升轨道,负点火降低轨道。", - "Returns the two velocity changes that move a satellite between two circular coplanar orbits, the coast time between them, and the total velocity change. A positive burn raises the orbit and a negative one lowers it.", + "返回将卫星在两个共面圆轨道之间转移的两次速度增量、两次点火之间的滑行时间以及总速度增量。正点火抬升轨道,负点火降低轨道。\n\n每次点火的大小由齐奥科夫斯基方程给出:$\\Delta v = v_e \\ln(m_0 / m_f)$,其中 $v_e$ 是排气速度,$m_0$ 与 $m_f$ 分别是点火前后的质量。总速度增量是两次点火大小之和的绝对值。", + "Returns the two velocity changes that move a satellite between two circular coplanar orbits, the coast time between them, and the total velocity change. A positive burn raises the orbit and a negative one lowers it.\n\nThe size of each burn follows the Tsiolkovsky equation, $\\Delta v = v_e \\ln(m_0 / m_f)$, where $v_e$ is the exhaust velocity and $m_0$ and $m_f$ are the masses before and after the burn. The total is the absolute value of the sum of the two.", ), category: Text::new("轨道", "Orbits"), params: &[ @@ -1094,6 +1094,12 @@ pub fn registry_json(registry: &Registry) -> Value { } /// Encodes one tool as JSON. +/// +/// A description and a parameter help are written in Markdown, so they are +/// sent twice: as the source, which a reader of the JSON needs, and as HTML, +/// which is what the page shows. The HTML comes from the same renderer as +/// the concept documentation, so a formula written between dollar signs is +/// set the same way in both places. fn tool_json(tool: &ToolSpec) -> Value { let params: Vec = tool.params.iter().map(param_json).collect(); json_object! { @@ -1101,12 +1107,21 @@ fn tool_json(tool: &ToolSpec) -> Value { "name" => tool.name.json(), "summary" => tool.summary.json(), "description" => tool.description.json(), + "description_html" => rendered_text(&tool.description), "category" => tool.category.json(), "params" => params, "api_items" => tool.api_items.iter().map(|name| Value::from(*name)).collect::>(), } } +/// Renders both languages of a piece of Markdown to HTML. +fn rendered_text(text: &Text) -> Value { + json_object! { + "zh" => crate::docs::markdown::render(text.zh), + "en" => crate::docs::markdown::render(text.en), + } +} + /// Encodes one parameter as JSON. fn param_json(param: &ParamSpec) -> Value { let (kind, options) = match ¶m.kind { @@ -1140,6 +1155,7 @@ fn param_json(param: &ParamSpec) -> Value { "name" => param.name, "label" => param.label.json(), "help" => param.help.json(), + "help_html" => rendered_text(¶m.help), "kind" => kind, "required" => param.required, "default" => param.default.map(Value::from).unwrap_or(Value::Null), diff --git a/src/tools/out.rs b/src/tools/out.rs index cc8cb48..72b038d 100644 --- a/src/tools/out.rs +++ b/src/tools/out.rs @@ -112,6 +112,14 @@ impl Table { self.rows.push(row); } + /// Returns the column at a position, if there is one. + /// + /// A row is written by the handler, which may add or leave out a cell, so + /// a position is not guaranteed to name a column. + pub fn column(&self, index: usize) -> Option<&Column> { + self.columns.get(index) + } + /// Returns `true` when the table has no rows. #[allow(dead_code)] pub fn is_empty(&self) -> bool { @@ -177,14 +185,48 @@ pub fn table_response( "key" => column.key.clone(), "label" => labels::label(&column.label).json(), "unit" => column.unit.clone(), + "unit_html" => unit_html(&column.unit), "numeric" => column.numeric, } }) .collect(); + // A text cell may hold a fixed phrase rather than data: the meaning of a + // field, for instance. Those are looked up so that a reader of either + // language sees prose in that language. A cell holding data was written + // by the reader rather than by this table, so the lookup gives it back + // unchanged in both languages, which is what it should show. let rows: Vec = table .rows .iter() - .map(|row| Value::Array(row.clone())) + .map(|row| { + Value::Array( + row.iter() + .enumerate() + .map(|(index, cell)| match cell { + Value::String(text) + if !table.column(index).is_some_and(|column| column.numeric) => + { + let mut encoded = labels::label(text).json(); + // A unit is written with a power in it, as in + // `rev/day^2`, and a unit is the one kind of text a + // table shows that is notation rather than prose. + // Only notation is rendered, so a sentence is never + // set as a formula and read as a row of variables. + if is_notation(text) { + if let Value::Object(fields) = &mut encoded { + fields.push(( + "html".to_string(), + Value::from(crate::docs::mathml::render(text, false)), + )); + } + } + encoded + } + other => other.clone(), + }) + .collect(), + ) + }) .collect(); let scalar_values: Vec = scalars.iter().map(scalar_json).collect(); let series_values: Vec = series.iter().map(series_json).collect(); @@ -195,10 +237,51 @@ pub fn table_response( "scalars" => scalar_values, "table" => json_object! { "columns" => columns, "rows" => rows }, "series" => series_values, + // A note is written in Markdown, so it is sent as the source and as + // HTML in both languages; a formula in a note is set the same way as + // one in a document. "notes" => notes.iter().map(Text::json).collect::>(), + "notes_html" => notes.iter().map(note_html).collect::>(), } } +/// Renders one note to HTML in both languages. +fn note_html(note: &Text) -> Value { + json_object! { + "zh" => crate::docs::markdown::render(note.zh), + "en" => crate::docs::markdown::render(note.en), + } +} + +/// Reports whether a piece of text is notation that should be set. +/// +/// Only a unit qualifies. A unit is a single token carrying a power, such as +/// `rev/day^2`, and a power is what a reader needs to see raised. +/// +/// Three things are deliberately excluded. A sentence such as `Mean motion` +/// is prose, and a formula sets its letters as variables, so a sentence read +/// that way stops being a sentence. An identifier such as `mean_motion` is a +/// field name, and it is already written the way the field is spelled; setting +/// it as a formula would split it into letters and turn the underscore into a +/// subscript. And a unit written with a space is prose with a caret in it, +/// which is not a unit. +fn is_notation(text: &str) -> bool { + text.contains('^') && !text.contains(' ') +} + +/// Renders a unit as HTML, so that a power reads as a power. +/// +/// A unit is notation rather than prose: `rev/day^2` is a name, and the only +/// mark-up it ever needs is the exponent. It is rendered by the same +/// mathematics renderer as a formula so that the two agree on how a power +/// looks, and so that a unit written with a subscript is set correctly too. +fn unit_html(unit: &str) -> Value { + if unit.is_empty() { + return Value::string(String::new()); + } + Value::from(crate::docs::mathml::render(unit, false)) +} + /// Returns a note written as a literal in the label table. /// /// The note text is both the lookup key and the English form, so a note @@ -224,6 +307,7 @@ fn scalar_json(scalar: &Scalar) -> Value { "label" => labels::label(&scalar.label).json(), "value" => scalar.value.clone(), "unit" => scalar.unit.clone(), + "unit_html" => unit_html(&scalar.unit), "monospace" => scalar.monospace, } } @@ -246,6 +330,63 @@ fn series_json(series: &Series) -> Value { mod tests { use super::*; + /// The first cell of the first row of a shaped response. + fn first_cell(value: &Value) -> Value { + let rows = value + .get("table") + .and_then(|table| table.get("rows")) + .expect("the response carries a table") + .as_array(); + rows.first() + .and_then(|row| row.as_array().first()) + .expect("the table carries a row") + .clone() + } + + #[test] + fn a_text_cell_is_shown_in_either_language() { + // The meaning of a field is a fixed phrase, so it is looked up rather + // than written twice. + let mut table = Table::new(vec![Column::text("meaning", "Meaning")]); + table.push(vec![Value::from("Mean motion")]); + let value = table_response( + "kind", + "Title", + Vec::new(), + &table, + Vec::new(), + vec![labels::label("Mean motion")], + ); + let cell = first_cell(&value); + assert_eq!(cell.get("zh").and_then(Value::as_str), Some("平均运动")); + assert_eq!(cell.get("en").and_then(Value::as_str), Some("Mean motion")); + } + + #[test] + fn an_identifier_is_not_set_as_a_formula() { + // `mean_motion` is a field name. Set as a formula it would come out as + // separate letters with a subscript, which is not the field name. + let mut table = Table::new(vec![Column::text("field", "Field")]); + table.push(vec![Value::from("mean_motion")]); + let value = table_response("kind", "Title", Vec::new(), &table, Vec::new(), Vec::new()); + let cell = first_cell(&value); + assert!( + cell.get("html").is_none(), + "an identifier should not be rendered: {cell:?}" + ); + } + + #[test] + fn a_unit_with_a_power_is_rendered() { + // `rev/day^2` is a unit, and the power is what a reader needs raised. + let mut table = Table::new(vec![Column::text("unit", "Unit")]); + table.push(vec![Value::from("rev/day^2")]); + let value = table_response("kind", "Title", Vec::new(), &table, Vec::new(), Vec::new()); + let cell = first_cell(&value); + let html = cell.get("html").and_then(Value::as_str).unwrap_or_default(); + assert!(html.contains(""), "the power was not raised: {html}"); + } + #[test] fn table_response_carries_the_shape() { let mut table = Table::new(vec![Column::number("a", "A", "km")]);