web: fix the tool form, the search box, the toc anchors, and translate the interface

This commit is contained in:
dev 2026-09-27 10:29:10 +08:00
parent b4c9627cbb
commit 65b0b690f7
14 changed files with 1060 additions and 97 deletions

View File

@ -138,6 +138,7 @@ or later. The Rust build does not run it and does not depend on Python.
| `content/graph/concepts.json` | The concept graph | | `content/graph/concepts.json` | The concept graph |
| `content/flows/*.json` | The algorithm flow graphs | | `content/flows/*.json` | The algorithm flow graphs |
| `content/api.json` | The generated interface catalog; do not edit by hand | | `content/api.json` | The generated interface catalog; do not edit by hand |
| `content/api.zh.json` | Chinese translations of the crate summaries and item doc comments |
A document header declares `id`, `title`, `chapter`, `summary`, `tags`, and A document header declares `id`, `title`, `chapter`, `summary`, `tags`, and
`prerequisites`. A cross reference is written `[[id]]` or `[[id|label]]` and `prerequisites`. A cross reference is written `[[id]]` or `[[id|label]]` and

464
content/api.zh.json Normal file
View File

@ -0,0 +1,464 @@
{
"crates": {
"apsis-core": "ApsisRS 工作区的共享基础类型:通用错误类型、结果别名、物理常数与单位标记 trait。\n\n本 crate 位于依赖图的底层。它不依赖任何其他 ApsisRS crate,因此其他所有 crate 都可以使用它。\n\n这里承载三件事:\n\n- [`Error`] 与 [`Result`] 是携带粗略 [`ErrorKind`] 的通用错误。它们的存在使那些失败没有有趣结构的函数无需自造类型即可报告错误,也使工作区有一个可回退的形状。它们刻意**不是**各层的错误类型:有特定领域失败的层定义自己的 `Error` 与 `ErrorKind`,其变体指明调用方需要匹配的具体条件。\n- [`constant`] 存放以 SI 单位表示的物理与天文常数。\n- [`Quantity`] 标记携带物理单位的 newtype,使泛型代码可以接受任意物理量并仍能恢复其单位类型。",
"apsis-units": "物理量的强类型 newtype。\n\n每个物理量都是 `f64` 之上的 newtype,在类型系统中携带其单位,因此长度不能传给期望时长的位置。同一量纲的单位通过 [`Quantity::to_unit`] 换算,各单位也提供常见情况的直接换算方法。\n\n一个 newtype 同时实现 [`Unit`] 与 [`Quantity`],并以自身类型作为关联单位。例如 [`Meters`] 是 [`Length`](dimension::Length) 单位,也是以自身为单位的物理量。\n\n此处覆盖的量纲为长度、时间、频率、角度与热力学温度。温度是本集合中唯一的仿射标度,因此摄氏度由 [`Kelvin`] 上的显式方法处理,而不走线性的 [`Unit`] 换算因子。",
"apsis-math": "ApsisRS 工作区的无依赖线性代数。\n\n本 crate 提供三种类型:\n\n- [`Vec3`],`f64` 上的三分量列矢量\n- [`Quat`],用于旋转的单位四元数\n- [`Mat3`],按列主序存储的三阶矩阵\n\n这些类型是普通数据,除各方法文档所述外没有内部不变量,因此字段是公开的,值可以由结构体字面量构造。对某些输入在数学上无定义的运算——例如对零矢量归一化——返回 [`Option`] 而不是 panic 或产生非有限结果。\n\n所有旋转都按右手定则绕给定轴进行,所有角度都以弧度表示。输入角度不做归约;需要规范区间的方法会在文档中说明该要求。",
"apsis-macros": "支撑 ApsisRS 工作区的宏。",
"apsis-time": "时间尺度、历元与日历转换。\n\n本 crate 提供工作区其余部分所使用的时间模型。[`Epoch`] 是以国际原子时尺度上的约化儒略日存储的时刻,[`TimeScale`] 则为一个数所表示的尺度命名。\n\n存储在单一均匀尺度上使运算精确:两个历元之差就是二者之间经过的 SI 时间,与它们创建时所用的尺度无关。UTC 不是均匀的,因为为保持其接近地球自转而插入闰秒,因此 UTC 换算经由 `apsis-leap` 中的表。\n\n来源:\n\n- 儒略日与约化儒略日约定:IAU,见《天文年历解释性补充》。\n- 地球时相对 TAI 的偏差:IAU 2000 决议 B1.9。\n- 闰秒:IERS Bulletin C,由 `apsis-leap` 内嵌。\n- 日历算法:Howard Hinnant,*chrono-Compatible Low-Level Date Algorithms*,2015。",
"apsis-leap": "用于 UTC 时间尺度的内嵌闰秒表。\n\nUTC 通过插入闰秒保持接近 UT1,即由地球自转定义的尺度。每次插入都是 UTC 时钟中的阶跃间断,因此从 UTC 求“已经过多少时间”需要这张表。\n\n本 crate 以表的形式提供偏移 `TAI - UTC`,覆盖自 1972-01-01 起的时期,即现行方案以 10 秒偏移开始的时刻。表中最新一次插入之后的偏移保持不变,直到宣布新的闰秒,因此其后的日期是外推而非事实。\n\n来源:IERS Bulletin C,以及随公告发布的偏移历史。",
"apsis-coord": "坐标系及坐标系之间的变换。\n\n本 crate 定义在类型中携带参考系的位置,因此表示在某一系中的位置不能传给期望另一系的接口。两个参考系是地心惯性系(ECI)与地心地固系(ECEF)。\n\n提供三种位置表示:\n\n- [`Eci`],惯性系中的直角坐标位置,单位为米\n- [`Ecef`],地固系中的直角坐标位置,单位为米\n- [`Geodetic`],WGS 84 椭球面上的纬度、经度与高\n\n[`Eci`] 与 [`Ecef`] 之间的变换是由地球自转角定义的旋转,由 `apsis-frames` 计算。[`Ecef`] 与 [`Geodetic`] 之间的变换使用 `apsis-geodesy` 中的 WGS 84 椭球。\n\n来源:\n\n- 地球自转角:*IERS Conventions (2010)*,公式 8.32,由 `apsis-frames` 实现。\n- 大地坐标转换与 WGS 84 椭球:NIMA TR8350.2,第三版,2000,由 `apsis-geodesy` 实现。",
"apsis-geodesy": "参考椭球与大地坐标计算。\n\n大地坐标以相对某椭球的纬度、经度与高表示。此处的纬度始终是大地纬度,即椭球法线与赤道面之间的夹角,而不是地心纬度。二者在中纬度最大相差约 0.19 度,因此对指向与测距而言这一区别很重要。\n\n[`WGS84`] 是 GPS 以及几乎所有卫星工作所使用的椭球。其定义参数是长半轴与扁率倒数;其余所有量都由这两个导出,因此导出值与标准中公布的数值在定义参数的精度内一致。\n\n来源:美国国家影像与测绘局,*Department of Defense World Geodetic System 1984*,技术报告 TR8350.2,第三版,2000。",
"apsis-frames": "参考系定义以及地心天球参考系、当日平赤道平春分点与地固系之间的旋转。\n\n本 crate 实现 IERS 约定中定义参考系之间变换的部分:地球自转角、岁差、章动、框架偏差、极移,以及由它们组合而成的完整链条。\n\n这些量在 `rf` 层及以上的精密工作中需要;对 `orbit` 层的传播而言通常只需要地球自转角,即由 UT1 的一个线性表达式给出的角度。\n\n来源:*IERS Conventions (2010)*;IAU 2000A 章动模型;IAU 2006 岁差;MHB2000 行星项。",
"apsis-tle": "两行元素集的解析。\n\n两行元素集(TLE)以北美防空司令部使用、由 Space-Track 分发的形式描述卫星轨道。卫星名称位于可选的第一行,其后两行携带轨道数据。\n\n格式是定宽的。每个字段占据固定的列范围,每行的校验和是其数字之和对十取模,其中负号计一。本 crate 将 TLE 解析为结构化字段,并校验每行校验和以及两行的元素集编号是否一致。它不解释字段含义;平均元素及其单位是 `apsis-sgp4` 的关注点。\n\n列位置从 1 开始编号,与格式文档一致。格式中有两个约定值得明确指出,因为它们容易被误读:\n\n- 指数字段写作带符号的一位数字后接量值。例如 ` 12345-3` 表示 `0.12345e-3`,而不是 `12345e-3`。\n- 隐含小数点写作不带小数点的形式。例如偏心率字段 `0006703` 表示 `0.0006703`。\n\n来源:字段布局遵循 CelesTrak 发布的格式说明 *FAQs: Two-Line Element Set Format*,其中记录了原始 NORAD 规范定义的列位置。校验和规则即该文档所述并被 Space-Track 使用。",
"apsis-sgp4": "SGP4 与 SDP4 解析轨道传播器。\n\n传播器接收两行元素集的平均元素,返回卫星在指定时刻的位置与速度。它正是元素集所拟合的模型,因此也是使用这些元素的正确方式;把同样的元素交给通用数值积分器会偏离已发布的位置。\n\n存在两个分支,传播器按轨道周期选择。周期低于 225 分钟的卫星用 SGP4 传播,该分支模拟地球扁率的长期效应与一个阻力项。周期大于等于该值的卫星用 SDP4 传播,该分支加入在高轨占主导的共振与日月摄动。\n\n输入与输出:\n\n- 元素是 [`apsis_tle::TwoLineElement`] 中的平均元素。元素集中的角度以度表示、平均运动以圈每天表示;本 crate 内部转换为弧度与弧度每分钟。\n- 输出位于真赤道平春分点(TEME)坐标系,位置单位为千米、速度单位为千米每秒。TEME 是模型定义的坐标系;将其转换为地固系需要地球自转与春分点方程,不属于本 crate 的范围。\n- 时间自元素集历元量起,单位为分钟。\n\n来源:算法遵循 Vallado 等,*Revisiting Spacetrack Report #3*,AIAA 2006-6753,2006,以及随该论文分发的修订实现。",
"apsis-kepler": "开普勒轨道元素与二体问题。\n\n本 crate 定义六元素形式的椭圆轨道以及到直角状态的转换,并提供三种近点角之间的转换。\n\n这里给出的解是**二体**解:它只假设中心天体是质点。真实卫星还受到地球扁率、大气阻力与日月引力的作用,因此由瞬时状态算出的元素是**密切**元素,沿轨道变化。它们不是两行元素集中的平均元素,二者之间的转换与传播任一者都是不同的运算。",
"apsis-ephemeris": "星历采样与插值。\n\n传播器给出的是单个时刻的卫星状态,但要回答“卫星何时可见”这类问题,需要一条轨迹上的大量时刻。本 crate 把轨迹采样成表,并通过插值回答问题。\n\n插值对每个分量使用三次 Hermite 多项式,同时用到两端的位置与速度,因此它是 `C^1` 的:位置与速度在采样边界处连续。只由位置构建的表在每个采样点速度不连续,对任何需要求导的用途都是错误的。\n\n表在插值误差允许的范围内回答查询,误差随采样间距的四次方增长。区间之外的查询返回空值,而不是外推。",
"apsis-maneuver": "脉冲与有限推力轨道机动模型。\n\n机动以速度增量改变轨道。本 crate 提供两种模型:脉冲机动,在零时间内施加速度增量;有限推力机动,在一段时长内施加恒定加速度,这正是有实际推力限制的发动机的模型。\n\n由于有限推力在推力期间受重力作用,它达到的速度增量小于加速度与时长之积;这一差值即重力损失,由结果报告。\n\n除点火本身之外,本 crate 还提供霍曼转移,即在两个共面圆轨道之间最省燃料的两次脉冲转移。",
"apsis-orbit": "整合轨道层的轨道状态门面。\n\n本 crate 是轨道层的单一入口。它重导出元素解析、解析传播器与二体模型,并加入一个把它们串联起来的类型。\n\n[`Orbit`] 持有一个已准备好传播的卫星,以及它初始化时所用的引力模型,并提供该层所支持的两种轨道描述之间的转换:\n\n- 由 SGP4 传播的平均元素,这是两行元素集所针对的模型,也是复现已编目卫星已发布位置的唯一方式。\n- 瞬时开普勒元素,描述某一时刻的密切二体轨道,便于检视与规划机动。\n\n两种描述不可互换。用二体模型传播平均元素,或用 SGP4 传播密切元素,都会得到不同且错误的结果;此处的转换明确说明各自的方向。"
},
"items": {
"SPEED_OF_LIGHT": "真空中的光速,单位为米每秒。\n\n按 SI 中米的定义是精确值。",
"GRAVITATIONAL_CONSTANT": "牛顿引力常数,单位为立方米每千克每二次方秒。\n\nCODATA 2018 值,其标准不确定度为 `1.5e-11 m^3 kg^-1 s^-2`。",
"BOLTZMANN": "玻尔兹曼常数,单位为焦耳每开尔文。\n\n按 SI 中开尔文的定义是精确值。",
"PLANCK": "普朗克常数,单位为焦耳秒。\n\n按 SI 中千克的定义是精确值。",
"ELEMENTARY_CHARGE": "基本电荷,单位为库仑。\n\n按 SI 中安培的定义是精确值。",
"ASTRONOMICAL_UNIT": "天文单位,单位为米。\n\n按 IAU 2012 决议 B2 是精确值。",
"STANDARD_GRAVITY": "标准重力加速度,单位为米每二次方秒。\n\n按标准大气的定义是精确值。",
"MINUTE": "一分钟,单位为秒。",
"HOUR": "一小时,单位为秒。",
"DAY": "24 小时的一天,单位为秒。\n\n这是 SI 日。它不是平太阳日(略长),也不是儒略日计算中使用的 86 400 秒的日,后者与本值相同。",
"JULIAN_YEAR": "365.25 天的儒略年,单位为秒。\n\n按儒略年的定义是精确值。",
"JULIAN_CENTURY": "36 525 天的儒略世纪,单位为秒。\n\n按儒略年的定义是精确值;以每儒略世纪给出的岁差与长期变化率表达式使用该单位。",
"JULIAN_DATE_UNIX_EPOCH": "Unix 历元的儒略日,即 1970-01-01T00:00:00 UTC。\n\n在儒略日系统中为 `2 440 587.5`。",
"JULIAN_DATE_J2000": "J2000.0 历元的儒略日,即 2000-01-01T12:00:00 TT。\n\n按 J2000.0 历元的定义是精确值。",
"SECONDS_J2000_TO_UNIX": "J2000.0 历元与 Unix 历元之间的秒数。\n\n由 [`JULIAN_DATE_J2000`] 与 [`JULIAN_DATE_UNIX_EPOCH`] 导出。",
"EARTH_MU": "地球引力参数,单位为立方千米每二次方秒。\n\nWGS 84 值,`GM = 398 600.4418 km^3/s^2`,其不确定度主要由牛顿引力常数的不确定度决定。轨道传播使用千米,因此该值以千米给出而非 SI 单位。",
"EARTH_EQUATORIAL_RADIUS": "地球赤道半径,单位为千米。\n\nWGS 84 长半轴,`a = 6 378.137 km`。",
"EARTH_FLATTENING": "地球扁率,无量纲。\n\nWGS 84 值,`f = 1 / 298.257 223 563`。",
"EARTH_ROTATION_RATE": "地球自转角速率,单位为弧度每秒。\n\nWGS 84 值。这是地球的标称平均角速度,`omega = 7.292 115e-5 rad/s`。",
"EARTH_J2": "地球二阶带谐系数,无量纲。\n\nWGS 84 值,`J2 = 1.082 626 683e-3`。",
"STANDARD_ATMOSPHERE": "标准大气海平面气压,单位为帕斯卡。\n\n按标准大气的定义是精确值。",
"ZERO_CELSIUS_IN_KELVIN": "摄氏零度在热力学温标上的值,单位为开尔文。\n\n按摄氏温标的定义是精确值。",
"ErrorKind": "一个 [`Error`] 的类别。\n\n变体按最常产生它们的层分组,但任何层都可以返回任何变体。调用方应匹配与自身恢复逻辑相关的变体,其余视为不透明。",
"Error": "工作区通用的错误类型。\n\n`Error` 携带可机器匹配的 [`ErrorKind`] 与人类可读的消息。可选的来源记录导致本错误的更低层错误。",
"Error::new": "创建一个只有类别、没有消息的错误。",
"Error::with_message": "创建一个带类别与消息的错误。\n\n消息应描述这一次具体失败的实际原因。它是给阅读日志的人看的,不用于匹配。",
"Error::with_source": "创建一个带类别与来源错误的错误。",
"Error::with_context": "为本错误附上消息,替换已有的消息。",
"Error::caused_by": "附上来源错误,替换已有的来源。",
"Error::kind": "返回本错误的类别。",
"Error::message": "返回附在本错误上的消息,若有。",
"Result": "错误类型为 [`ElementError`] 的 [`Result`](core::result::Result)。",
"Unit": "单一物理量纲的单位。\n\n实现者是零大小标记类型。为同一量纲实现 `Unit` 的两个单位通过 [`Quantity::to_unit`](crate::Quantity::to_unit) 换算。",
"Quantity": "与单位配对的数值。\n\n实现者是 `f64` 与 [`Unit`] 之上的 newtype。该 trait 使泛型代码无需假定所收到的 newtype 即可访问存储的数值及其单位。",
"Mat3": "按列主序存储的三阶矩阵。\n\n字段名遵循 `m<行><列>` 约定,因此 `m01` 是第一行第二列的元。字段按列主序列出,[`Mat3::from_columns`] 与 [`Mat3::column`] 也使用同一顺序。",
"Mat3::ZERO": "零矩阵。",
"Mat3::IDENTITY": "单位矩阵。",
"Mat3::from_rows": "按行主序由九个元创建矩阵。\n\n参数按行分组:先是 `row0`,然后是 `row1`,最后是 `row2`。",
"Mat3::from_columns": "由三个列矢量创建矩阵。",
"Mat3::from_diagonal": "由对角元矢量创建对角矩阵。",
"Mat3::from_quat": "由单位四元数创建旋转矩阵。\n\n假定四元数长度为 1。非单位四元数会产生带缩放的矩阵,[`Mat3::is_rotation`] 会拒绝该结果。",
"Mat3::from_axis_angle": "创建绕单位轴 `axis` 旋转 `angle` 弧度的旋转矩阵。\n\n当轴的长度为零或非有限时返回 `None`。",
"Mat3::from_rotation_x": "创建绕第一轴旋转 `angle` 弧度的旋转矩阵。",
"Mat3::from_rotation_y": "创建绕第二轴旋转 `angle` 弧度的旋转矩阵。",
"Mat3::from_rotation_z": "创建绕第三轴旋转 `angle` 弧度的旋转矩阵。",
"Mat3::get": "返回第 `row` 行第 `column` 列的元。\n\n当任一下标超出 `0..=2` 时返回 `None`。",
"Mat3::column": "返回第 `index` 列。\n\n当 `index` 超出 `0..=2` 时返回 `None`。",
"Mat3::row": "返回第 `index` 行。\n\n当 `index` 超出 `0..=2` 时返回 `None`。",
"Mat3::transpose": "返回转置。",
"Mat3::trace": "返回对角元之和。",
"Mat3::determinant": "返回行列式。",
"Mat3::is_rotation": "当本矩阵在 `tolerance` 内是旋转时返回 `true`。\n\n矩阵是旋转当且仅当它是正交的且行列式为 1。该检查逐元比较 `M^T M` 与单位矩阵,再比较行列式与 1。",
"Mat3::is_finite": "当九个元都是有限值时返回 `true`。",
"Quat": "四元数 `w + xi + yj + zk`。\n\n旋转矢量的方法假定四元数长度为 1。乘法与加法不作此假定,因为任意四元数运算在该假定之外仍有定义。用 [`Quat::normalize`] 施加单位长度,用 [`Quat::is_normalized`] 检查它。\n\n绕单位轴 `u` 旋转角度 `theta` 的旋转为 `w = cos(theta / 2)`,`(x, y, z) = u * sin(theta / 2)`。复合约定与矩阵乘法相同:`(a * b)` 作用于矢量时先施加 `b`,再施加 `a`。",
"Quat::IDENTITY": "单位旋转。",
"Quat::new": "由四个分量创建四元数。",
"Quat::from_mat3": "由旋转矩阵 `m` 创建四元数。\n\n算法选取最大的对角元作为除数,使平方根对任意输入旋转都良态。当 `m` 不是旋转矩阵时——即在 `1e-9` 容差内不正交或行列式不为 1——返回 `None`。",
"Quat::vector": "返回矢量部分 `(x, y, z)`。",
"Quat::norm_squared": "返回本四元数长度的平方。",
"Quat::norm": "返回本四元数的长度。",
"Quat::normalize": "返回缩放至单位长度的本四元数。\n\n当长度为零或非有限时返回 `None`。",
"Quat::conjugate": "返回共轭 `w - xi - yj - zk`。\n\n对单位四元数而言这就是逆旋转。",
"Quat::inverse": "返回逆旋转。\n\n当长度为零或非有限时返回 `None`。",
"Quat::dot": "返回 `self` 与 `rhs` 的点积,二者都视为四分量矢量。",
"Quat::rotate": "用本旋转旋转 `v`。\n\n假定本四元数长度为 1。旋转按 `v + 2 * cross(vector, cross(vector, v) + w * v)` 计算,避免两次构造四元数乘积矩阵。",
"Quat::angle": "返回旋转角,单位为弧度,位于 `[0, pi]`。\n\n假定本四元数长度为 1。",
"Quat::axis": "返回作为单位矢量的旋转轴。\n\n对单位旋转返回 `None`,因为此时没有定义轴。假定本四元数长度为 1。",
"Quat::lerp": "返回从 `self` 到 `rhs` 的线性插值。\n\n这是弦插值,因此结果不是单位四元数,角速度也不恒定。需要中间旋转均匀时请使用 [`Quat::slerp`]。",
"Quat::slerp": "返回从 `self` 到 `rhs` 的球面线性插值。\n\n取两条弧中较短的一条,因此结果沿保持角度最小的路径。当输入近乎平行时退化为 [`Quat::lerp`],否则分母中的正弦会损失精度。",
"Quat::is_normalized": "当长度在 `1e-9` 容差内为 1 时返回 `true`。",
"Quat::quarter_turn_x": "返回绕第一轴四分之一圈的旋转。",
"Quat::half_turn_z": "返回绕第三轴半圈的旋转。",
"Vec3": "三分量列矢量。\n\n分量名为 `x`、`y`、`z`。此处不赋予它们含义;参考系由使用该矢量的 crate 定义。",
"Vec3::ZERO": "零矢量。",
"Vec3::X": "沿第一轴的单位矢量。",
"Vec3::Y": "沿第二轴的单位矢量。",
"Vec3::Z": "沿第三轴的单位矢量。",
"Vec3::new": "由三个分量创建矢量。",
"Vec3::splat": "创建三个分量都等于 `value` 的矢量。",
"Vec3::x": "返回第一个分量。",
"Vec3::y": "返回第二个分量。",
"Vec3::z": "返回第三个分量。",
"Vec3::get": "返回由 `index` 选中的分量,其中 `0`、`1`、`2` 分别选中 `x`、`y`、`z`。\n\n下标超出 `0..=2` 时返回 `None`。",
"Vec3::cross": "返回叉积 `self x rhs`。\n\n结果与两个输入都垂直并遵循右手定则,因此 `Vec3::X.cross(Vec3::Y)` 等于 `Vec3::Z`。",
"Vec3::distance": "返回 `self` 与 `rhs` 之间的欧氏距离。",
"Vec3::distance_squared": "返回 `self` 与 `rhs` 之间欧氏距离的平方。",
"Vec3::angle_between": "返回 `self` 与 `rhs` 之间的夹角,单位为弧度。\n\n结果位于 `[0, pi]`。当任一矢量长度为零或非有限,或任一分量非有限时返回 `None`。",
"Vec3::abs": "返回各分量取绝对值后的矢量。",
"Vec3::max_component": "返回最大的分量。",
"Vec3::min_component": "返回最小的分量。",
"Radians": "以弧度表示的角度。",
"Degrees": "以度表示的角度。",
"Radians::new": "由弧度值创建角度。",
"Radians::value": "以弧度返回本角度。",
"Radians::to_degrees": "把本角度转换为度。",
"Radians::wrap": "返回归约到半开区间 `[-pi, pi)` 的本角度。\n\n归约使用 [`f64::rem_euclid`](f64::rem_euclid),因此结果对负输入与大输入都有定义。",
"Degrees::to_radians": "把本角度转换为弧度。",
"Length": "长度。",
"Time": "时长。",
"Frequency": "频率,时间的倒数。",
"Angle": "平面角。",
"Temperature": "热力学温度。",
"Hertz": "以赫兹表示的频率。",
"Hertz::period": "返回本频率对应的周期,单位为秒。\n\n频率为零时返回 `f64::INFINITY`,这是无限周期的正确极限。",
"Meters": "以米表示的长度。",
"Kilometers": "以千米表示的长度。",
"Meters::to_kilometers": "把本长度转换为千米。",
"Kilometers::to_meters": "把本长度转换为米。",
"Kelvin": "以开尔文表示的热力学温度。\n\n低于绝对零度的值不被拒绝,因为中间运算可能合理地经过它们;需要物理温度语义的调用方应自行检查该值。",
"Kelvin::from_celsius": "由摄氏度值创建温度。\n\n使用摄氏温标定义的精确偏移。",
"Kelvin::to_celsius": "把本温度转换为摄氏度。",
"Seconds": "以秒表示的时长。",
"SECONDS_PER_DAY": "一天中的秒数,不计闰秒。",
"Calendar": "日历日期与一天中的时刻。\n\n字段是外推格里高利历字段。`second` 是区间 `[0, 60)` 内的浮点数,因此包含插入闰秒的分钟可以表示为 60 秒。",
"Calendar::new": "由各字段创建日历日期。",
"Calendar::from_ymd": "创建给定日期起始时刻的日历日期。",
"Calendar::seconds_of_day": "返回自当天开始起经过的秒数。",
"Calendar::is_valid": "当各字段在外推格里高利历中构成存在的日期时返回 `true`。",
"Calendar::is_leap_year": "当 `year` 在外推格里高利历中是闰年时返回 `true`。",
"Calendar::days_in_month": "返回给定年份给定月份的天数。\n\n`month` 超出 `1..=12` 时返回零。",
"Calendar::mjd_from_ymd": "把日历日期转换为约化儒略日,忽略一天中的时刻。\n\n结果是给定日期起始时刻的 MJD,因此它表示的是一整天的开始。",
"Calendar::ymd_from_mjd": "把约化儒略日转换为日历日期。\n\n小数部分被忽略,因此结果是包含该时刻的日历日。",
"Calendar::seconds_of_day_from_mjd": "只保留 MJD 的小数部分,作为自午夜起的秒数。\n\n对任意有限输入,结果位于 `[0, 86400)`。",
"Epoch": "TAI 时间尺度上的时刻,以约化儒略日存储。\n\n字段是私有的,以保证所存储的尺度。使用 [`Epoch::from_mjd`] 或 [`Epoch::from_calendar`] 构造,再用 [`Epoch::mjd`] 或 [`Epoch::calendar`] 按所需尺度读回。",
"Epoch::J2000": "时刻 2000-01-01T12:00:00 TT,即 J2000.0 历元的定义。\n\n该值在 TT 尺度上是 `2451545.0`,换算回 TAI。",
"Epoch::from_tai_mjd": "由 TAI 尺度上的约化儒略日创建历元。",
"Epoch::from_mjd": "由给定尺度上的约化儒略日创建历元。\n\n当该时刻无法用所请求的尺度表示时返回 `None`。这发生在 1972 年之前的 UTC,因为用于从 UTC 换算的闰秒表不适用于该区间。",
"Epoch::from_calendar": "由日历日期创建历元,按给定尺度解释。\n\n当字段不构成有效日期,或该时刻无法用所请求的尺度表示时返回 `None`。",
"Epoch::tai_mjd": "以 TAI 尺度上的约化儒略日返回该时刻。",
"Epoch::mjd": "以给定尺度上的约化儒略日返回该时刻。\n\n当该时刻早于 1972 年且请求 UTC 尺度时返回 `None`,因为闰秒表不覆盖该区间。",
"Epoch::calendar": "以给定尺度上的日历日期返回该时刻。\n\n当该时刻无法进行该尺度换算时返回 `None`;见 [`Epoch::mjd`]。",
"Epoch::seconds_until": "返回从 `self` 到 `other` 的带符号秒数。\n\n当 `other` 更晚时结果为正。由于两个历元都以均匀的 TAI 尺度存储,该差值是精确的,与创建二者时所用的尺度无关。",
"Epoch::add_seconds": "返回平移 `seconds` 后的历元。\n\n平移以均匀 TAI 尺度上的 SI 秒计量,因此它使时刻恰好前进那么多经过时间。这是唯一提供的平移:以 UTC 时钟计量的平移在区间跨越插入闰秒时会不同,而 UTC 时钟在该时刻没有单一明确的数值。需要在 UTC 尺度上进行日历运算的调用方应转换为 [`Calendar`]、调整、再转换回来。",
"Epoch::is_finite": "当存储的约化儒略日是有限值时返回 `true`。",
"JULIAN_DATE_OFFSET": "儒略日尺度起点与约化儒略日尺度起点之间的天数。",
"mjd_from_julian_date": "把儒略日转换为约化儒略日。",
"julian_date_from_mjd": "把约化儒略日转换为儒略日。",
"TT_MINUS_TAI": "地球时减国际原子时,单位为秒。\n\n按地球时的定义是精确值。",
"TAI_MINUS_GPS": "国际原子时减 GPS 时,单位为秒。\n\n按 GPS 时的定义是精确值。",
"TimeScale": "时间尺度。\n\n尺度决定一个数值时刻如何被解释。它不改变时刻本身;在尺度之间转换 [`Epoch`](crate::Epoch) 只改变用于显示它的数值。",
"TimeScale::is_uniform": "当该尺度以均匀速率运行时返回 `true`。\n\nUTC 是此处唯一不均匀的尺度,因为闰秒使其部分分钟为 61 秒。",
"TimeScale::name": "返回该尺度的简称,例如 `TAI` 或 `UTC`。",
"LeapSecond": "一次闰秒公告。\n\n该记录记载新的 `TAI - UTC` 值生效的日期。它不记录所插入秒的大小,因为迄今为止每次公告插入的都恰好是 1 秒,且新偏移比前一个偏移大 1。",
"LeapSecond::effective_mjd": "返回生效时刻的约化儒略日。\n\n闰秒在 00:00:00 UTC 生效,因此生效时刻是给定日历日的开始。",
"LEAP_SECONDS": "闰秒表,按生效日期排序。\n\n来源:IERS Bulletin C。第一项是 1972-01-01,即现行方案以继承自早期调整的 10 秒初始偏移开始之时。",
"TABLE_COVERS_FROM_MJD": "表中第一项的约化儒略日,即 1972-01-01。\n\n早于该时刻的查询会被拒绝,因为当时 UTC 尚未使用整数秒偏移。",
"TAI_MINUS_UTC_AFTER_LAST_ENTRY": "表中最后一项及其之后的 `TAI - UTC` 值。\n\n这是在下一次闰秒公布之前保持有效的偏移。",
"mjd_from_ymd": "把外推格里高利历日期转换为约化儒略日。\n\n结果是给定日期 00:00:00 的 MJD。算法为 Fliegel-Van Flandern 整数转换,对格里高利历有定义的每个日期都是精确的,无需查找表。\n\n计算在 `i64` 中进行并在结束时转换一次,因此对闰秒可能出现的日期范围是精确的。",
"MJD_AT_UNIX_EPOCH": "Unix 历元的约化儒略日,即 1970-01-01T00:00:00。\n\nMJD 本身不携带时间尺度,因此无论该时刻以 UTC、TAI 还是任何其他尺度读取,该常数都是同一个数。",
"tai_minus_utc_at_mjd": "返回给定 UTC 约化儒略日处的 `TAI - UTC` 值,单位为秒。\n\n该偏移是阶跃函数:它在每次闰秒的生效日期 00:00:00 UTC 变化,其间保持恒定。恰好在生效日期上的查询返回新值。\n\n当该时刻早于 1972-01-01 时返回 `None`,此前 UTC 使用非整数偏移,本表不适用。",
"tai_mjd_from_utc_mjd": "把 UTC 约化儒略日转换为对应的 TAI 约化儒略日。\n\n该转换加上该时刻生效的偏移。由于偏移是在 UTC 一侧求值的阶跃函数,该转换对每个 UTC 时刻都有定义;逆转换则不然,因为闰秒使某些 TAI 时刻不对应任何 UTC 时刻。\n\n当该时刻早于 1972-01-01 时返回 `None`。",
"utc_mjd_from_tai_mjd": "把 TAI 约化儒略日转换为对应的 UTC 约化儒略日。\n\n依次尝试表中的每个候选偏移。当所得 UTC 时刻处生效的偏移与产生它的偏移相同时,该候选被接受。对给定输入至多只有一个偏移能满足该条件,因此搜索是明确的。\n\n两种情况返回 `None`。第一种是早于表起点的时刻,此时 UTC 使用本表未描述的分数偏移。第二种是严格落在某次插入闰秒之内的时刻。这样的时刻没有 UTC 表示,因为 UTC 时钟把该区间标记为 `23:59:60`,而该值在连续的约化儒略日尺度上不存在。",
"calendar_mjd": "把 00:00:00 的外推格里高利历日期转换为约化儒略日。\n\n这是用于放置闰秒表项之日历转换的便捷重导出。提供它是为了让调用方无需自行复现该转换,也因为 `validation/` 中的标定脚本需要在比较两侧使用同一函数。",
"Eci": "地心惯性系中的直角坐标位置。\n\n该系与 J2000.0 的平赤道平春分点对齐,并被视为惯性系,因此不随地球旋转。坐标单位为米。",
"Ecef": "地心地固系中的直角坐标位置。\n\n该系随地球旋转,因此固连于地面的点在其中有恒定坐标。坐标单位为米。",
"Eci::new": "由以米表示的矢量创建惯性系位置。",
"Eci::vector": "以米返回该位置。",
"Eci::to_ecef": "在给定地球自转角下,把本惯性系位置转换到地固系。",
"Ecef::to_eci": "在给定地球自转角下,把本地固系位置转换到惯性系。",
"Ecef::to_geodetic": "把本地固系位置转换为给定椭球面上的纬度、经度与高。\n\n当位置位于极轴上时返回 `None`,因为此时经度无定义。",
"Geodetic::new": "由纬度、经度与高创建大地坐标位置。",
"Geodetic::latitude": "以弧度返回大地纬度。",
"Geodetic::longitude": "以弧度返回经度,向东为正。",
"Geodetic::height": "以米返回椭球面以上的高。",
"Geodetic::ellipsoidal": "返回底层的大地坐标位置。",
"Geodetic::to_ecef": "把本位置转换到 WGS 84 椭球上的地固系。\n\n这是常见情形。需要其他椭球时请使用 [`Geodetic::to_ecef_on`]。",
"Geodetic::to_ecef_on": "把本位置转换到给定椭球上的地固系。",
"Geodetic": "大地坐标位置:椭球面上的纬度、经度与高。\n\n纬度与经度单位为弧度,高单位为米。纬度是大地纬度,即椭球法线与赤道面之间的夹角,位于 `[-pi/2, pi/2]`。经度自本初子午线向东量取。高是沿椭球法线的距离,椭球面之外为正。",
"Ellipsoid": "双轴参考椭球:绕短半轴的旋转椭圆。\n\n形状由两个数确定。本 crate 存储长半轴与扁率倒数,即标准中 WGS 84 椭球的两个定义参数。其余所有量都是导出的,因此不存在参数之间互相矛盾的风险。\n\n长度单位为米,角度单位为弧度。",
"Ellipsoid::new": "由长半轴与扁率倒数创建椭球。\n\n长度单位为米。扁率倒数必须为正且有限;无穷大描述球体。",
"Ellipsoid::semi_major_axis": "以米返回长半轴。",
"Ellipsoid::inverse_flattening": "返回扁率倒数。",
"Ellipsoid::flattening": "返回扁率。\n\n对球体该值为零。",
"Ellipsoid::semi_minor_axis": "以米返回短半轴。",
"Ellipsoid::eccentricity_squared": "返回第一偏心率平方,即 `[0, 1)` 内的无量纲数。\n\n对扁率 `f`,它是 `2f - f^2`。",
"Ellipsoid::eccentricity": "返回第一偏心率,即 `[0, 1)` 内的无量纲数。",
"Ellipsoid::second_eccentricity_squared": "返回第二偏心率平方,无量纲。\n\n它是 `e^2 / (1 - e^2)`,出现在把大地坐标转换为直角坐标的闭式中。",
"Ellipsoid::polar_radius_of_curvature": "以米返回极曲率半径。\n\n这是极点处子午圈内的曲率半径,等于 `a / sqrt(1 - e^2)`。",
"Ellipsoid::prime_vertical_radius": "以米返回给定大地纬度处的卯酉圈曲率半径。\n\n这是沿椭球法线从表面到自转轴的距离,从赤道处的长半轴增至极点处的 [`Ellipsoid::polar_radius_of_curvature`]。\n\n纬度单位为弧度。",
"Ellipsoid::meridian_radius": "以米返回给定大地纬度处的子午圈曲率半径。\n\n纬度单位为弧度。",
"Ellipsoid::geocentric_latitude": "以弧度返回与给定大地纬度对应的地心纬度。\n\n地心纬度自赤道面量到矢径,而大地纬度量到椭球法线。离开赤道与两极时,地心纬度是两者中较小的那个。",
"WGS84": "WGS 84 参考椭球。\n\n两个定义参数是长半轴 `a = 6 378 137 m` 与扁率倒数 `1 / f = 298.257 223 563`。\n\n来源:NIMA TR8350.2,第三版,2000。",
"WGS84_GRAVITATIONAL_PARAMETER": "WGS 84 的地球引力参数,单位为立方米每二次方秒。\n\n它放在这里而不是 `apsis-core`,是为了让使用该椭球的调用方能配上相应的引力场。该值为 `3.986 004 418e14 m^3/s^2`。",
"Geodetic::to_cartesian": "把本大地坐标位置转换为给定椭球上的地固系直角坐标。\n\n该系的第三轴沿椭球短半轴穿过北极,第一轴穿过赤道与本初子午线的交点,第二轴构成右手系。结果单位为米。\n\n闭式为\n\n```text\nN = a / sqrt(1 - e^2 sin^2(lat))\nx = (N + h) cos(lat) cos(lon)\ny = (N + h) cos(lat) sin(lon)\nz = (N (1 - e^2) + h) sin(lat)\n```\n\n其中 `N` 是卯酉圈曲率半径。该形式处处精确,包括两极,因为两处的余弦因子为零。",
"Geodetic::is_valid": "当每个字段都是有限值且纬度与经度在各自范围内时返回 `true`。",
"cartesian_from_geodetic": "把大地坐标位置转换为地固系直角坐标。\n\n坐标系定义与闭式见 [`Geodetic::to_cartesian`]。",
"geodetic_from_cartesian": "把地固系直角坐标转换为大地坐标位置。\n\n纬度与高由 Bowring 方法求得:闭式求解子午线方程,再用若干固定点迭代修正结果。对从椭球面以下到地球静止轨道半径的位置,迭代收敛到远优于一微米。\n\n当位置非有限,或位于极轴上以致经度无定义时返回 `None`。",
"ERA_J2000_TURNS": "J2000.0 历元处的地球自转角,单位为圈。\n\n按该角的定义,历元值为零:国际天文学联合会把地球自转角定义为自 J2000.0 起 UT1 天数的线性函数,因此历元处的常数项属于该定义的另一部分。",
"ERA_RATE_TURNS_PER_DAY": "地球自转角的变化率,单位为圈每 UT1 日。\n\n该值为 `1.002 737 811 911 354 6`,即每天 `360.985 612 288 088` 度。它超过每天一圈,因为地球在一个恒星日内转一圈,而恒星日短于太阳日。",
"ut1_days_from_j2000": "自 J2000.0 历元到给定 UT1 尺度儒略日的天数。\n\nJ2000.0 历元是儒略日 2451545.0,即 2000-01-01 的正午。",
"EarthRotationAngle": "给定时刻的地球自转角。\n\n该角是自天体中间原点绕天极量到地球中间原点的旋转。在实践中它就是惯性系与地固系之间的夹角。\n\n该角不归约到一圈之内,因此即使区间超过一天,两个角之差仍有意义。",
"EarthRotationAngle::from_turns": "由圈值创建角度。",
"EarthRotationAngle::from_radians": "由弧度值创建角度。",
"EarthRotationAngle::from_ut1_julian_date": "返回 UT1 尺度上给定儒略日处的角度。",
"EarthRotationAngle::from_ut1_days": "返回自 J2000.0 历元起给定 UT1 天数处的角度。\n\n表达式是线性的:`theta = 0.779 057 273 2640 + 1.002 737 909 350 795 * d`,其中 `d` 是区间天数。常数项 `0.779 057` 圈是历元时刻的角,约 280.46 度。",
"EarthRotationAngle::turns": "以圈返回该角。\n\n该值不归约到 `[0, 1)`。",
"EarthRotationAngle::turns_positive": "以圈返回该角,归约到 `[0, 1)`。",
"EarthRotationAngle::radians": "以弧度返回该角。\n\n该值不归约到 `[0, 2 pi)`。",
"EarthRotationAngle::radians_positive": "以弧度返回该角,归约到 `[0, 2 pi)`。",
"rotation_eci_to_ecef": "返回把惯性系矢量映射到地固系的旋转。\n\n该旋转是绕第三轴按地球自转角顺时针转动。把它作用于惯性系矢量得到地固系矢量,因此固定在惯性空间的矢量会随着地球在其下方转动而获得一个负向旋转。",
"rotation_ecef_to_eci": "返回把地固系矢量映射到惯性系的旋转。\n\n这是 [`rotation_eci_to_ecef`] 的转置,对旋转矩阵而言也就是它的逆。",
"mean_anomaly_moon": "以弧度返回月球的平近点角。\n\n这是 IERS 2003 表达式中的 Delaunay 参数 `l`。",
"mean_anomaly_sun": "以弧度返回太阳的平近点角。\n\n这是 Delaunay 参数 `l'`。参考实现将其内联求值而非通过通用函数,二者在常数项的最后几位不同。",
"mean_anomaly_sun_iers2003": "返回 IERS 2003 表达式给出的太阳平近点角。\n\n它与 [`mean_anomaly_sun`] 在常数项与线性项的最后几位不同。章动级数使用相应的形式。",
"mean_elongation_moon_sun_iers2003": "返回 IERS 2003 表达式给出的月球相对太阳的平距角。\n\n它与 [`mean_elongation_moon_sun`] 在常数项的最后几位不同。",
"mean_latitude_moon": "以弧度返回月球的平纬度辐角。\n\n这是 Delaunay 参数 `F`,即月球的平黄经减去其升交点的平黄经。",
"mean_elongation_moon_sun": "以弧度返回月球相对太阳的平距角。\n\n这是 Delaunay 参数 `D`。参考实现将其内联求值。",
"mean_longitude_node": "以弧度返回月球升交点的平黄经。\n\n这是 Delaunay 参数 `Om`。",
"mean_longitude_mercury": "以弧度返回水星的平黄经。",
"mean_longitude_venus": "以弧度返回金星的平黄经。",
"mean_longitude_earth": "以弧度返回地球的平黄经。",
"mean_longitude_mars": "以弧度返回火星的平黄经。",
"mean_longitude_jupiter": "以弧度返回木星的平黄经。",
"mean_longitude_saturn": "以弧度返回土星的平黄经。",
"mean_longitude_uranus": "以弧度返回天王星的平黄经。",
"mean_longitude_neptune": "以弧度返回海王星的平黄经。\n\n参考实现将其内联求值,取自 MHB2000 模型而非其他行星所用的 IERS 2003 表达式。",
"general_precession": "以弧度返回黄经中的总累积岁差。",
"DelaunayArguments": "五个 Delaunay 参数,按日月级数所用的顺序排列。",
"DelaunayArguments::at": "返回儒略世纪时间对应的五个参数。",
"DelaunayArguments::as_array": "按级数顺序以数组返回各参数。",
"PlanetaryArguments": "行星级数的十三个参数,按级数顺序排列。\n\n顺序为 `l`、`F`、`D`、`Om`、水星至天王星、海王星,以及总累积岁差。",
"PlanetaryArguments::as_array": "按级数顺序以数组返回各参数。",
"SeriesTerm": "日月章动级数的一项。\n\n五个乘数按 `l`、`l'`、`F`、`D`、`Om` 的顺序作用于 Delaunay 参数。六个系数按黄经与交角两部分给出。",
"ComplementaryTerm": "春分点方程补充项的一项。\n\n八个乘数作用于参数 `l`、`l'`、`F`、`D`、`Om`、金星的平黄经、地球的平黄经与总累积岁差。",
"PlanetaryTerm": "行星章动级数的一项。\n\n十三个乘数按 `l`、`F`、`D`、`Om`、水星至天王星、海王星与总累积岁差的顺序作用于行星参数。",
"Nutation": "以弧度表示的黄经章动与交角章动。",
"Nutation::new": "由两个角创建章动。",
"Nutation::longitude_arcseconds": "以角秒返回黄经章动。",
"Nutation::obliquity_arcseconds": "以角秒返回交角章动。",
"Nutation::plus": "返回两个章动之和。",
"nutation_2000a": "返回给定时刻的黄经章动与交角章动。\n\n时间是以地球时尺度表示的、自 J2000.0 起的儒略世纪数。这是 IAU 2000A 模型,包含完整的日月与行星级数。",
"nutation_2000a_with_2006_adjustment": "返回施加 IAU 2006 岁差调整后的章动。\n\nIAU 2000A 模型建立在 IAU 1976 岁差之上,而 IAU 2006 岁差改变了岁差量的表示,因此需要一个小调整以保持两个模型相互一致。",
"FRAME_BIAS_RIGHT_ASCENSION": "赤经方向的框架偏差,单位为弧度。\n\n国际天球参考系原点与 J2000.0 动力学春分点之间的偏移。该值为负 0.0146 角秒。",
"FRAME_BIAS_DECLINATION": "赤纬方向的框架偏差,单位为弧度。\n\n国际天球参考系极轴与 J2000.0 平极之间的偏移。该值为负 0.0068192 角秒。",
"FRAME_BIAS_LONGITUDE": "黄经方向的框架偏差,单位为弧度。\n\n沿赤道量取的两个原点之间的偏移。该值为负 0.041775 角秒。",
"PrecessionAngles": "描述自 J2000.0 到某一日期的岁差的四个角。\n\n它们按参考实现所用的顺序施加:先绕第三轴旋转 `gamma_bar`,再绕第一轴旋转 `phi`,再绕第三轴旋转 `psi`,最后绕第一轴旋转 `epsilon`。",
"mean_obliquity": "以弧度返回黄赤交角的平均值。\n\n该多项式是 IAU 2006 表达式,以自 J2000.0 起在地球时尺度上的儒略世纪数求值。",
"precession_angles": "返回给定时刻的四个岁差角。\n\n时间是以地球时尺度表示的、自 J2000.0 起的儒略世纪数。\n\n来源:P03 岁差,按 `eraPfw06` 的实现。",
"frame_bias_matrix": "返回框架偏差旋转矩阵。\n\n该偏差是自国际天球参考系到 J2000.0 动力学平赤道平春分点的一个常数旋转。",
"bias_precession_matrix": "返回到当日平赤道平春分点的旋转。\n\n这是偏差-岁差矩阵。在其上施加章动即可到达当日真赤道真春分点。",
"frame_to_mean_of_date": "在给定已算好的岁差角时,返回到当日平赤道平春分点的旋转。\n\n来源:按 `eraFw2m` 的实现,它施加 P03 岁差。",
"rotation_z": "返回绕第三轴旋转 `angle` 弧度的旋转。\n\n符号约定与本模块中其他轴旋转一致。",
"greenwich_mean_sidereal_time_1982": "以弧度返回 1982 年表达式给出的格林尼治平恒星时。\n\n该表达式是自 J2000.0 起儒略世纪数加上当日小数部分的星历多项式。保留它是为了与采用该形式的已发布历表进行比较。",
"teme_to_terrestrial_matrix": "返回自真赤道平春分点系到地固系的旋转。\n\n参数是 UT1 儒略日,因为该旋转是地球的自转,而地球自转由 UT1 定义。",
"bias_precession_nutation_matrix": "返回给定时刻的偏差-岁差-章动矩阵。\n\n该矩阵把矢量自天球参考系带到当日真赤道真春分点。它由偏差、岁差与章动三部分构成。",
"equation_of_the_equinoxes_complementary_terms": "返回春分点方程的补充项。\n\n春分点方程除了黄经章动在真赤道上的投影之外,还有一组补充项,这部分由该函数给出。",
"equation_of_the_equinoxes": "返回给定时刻的春分点方程。\n\n春分点方程是沿真赤道量取的真春分点与平春分点之差。它就把平恒星时转换为视恒星时的那个量。",
"PolarMotion": "地球的极移,以两个角与相应旋转表示。\n\n极移是自转轴在地球内部的运动。国际地球自转与参考系统服务每日测量并发布它。它小于 0.5 角秒,约合地面 15 米,对大多数用途可忽略;本工作区保留它,以便需要时能够施加。",
"PolarMotion::NONE": "零极移,即在无法获得已发布数值时所用的近似。\n\n忽略极移带来的误差小于十分之一角秒,远低于这些内核所针对的定位精度。",
"PolarMotion::new": "由三个角创建极移。",
"PolarMotion::is_valid": "当各角可用时返回 `true`。",
"PolarMotion::matrix": "返回极移的旋转矩阵。\n\n该旋转先绕第三轴旋转定位角,再绕第二轴旋转负的 `x` 位移,最后绕第一轴旋转 `y` 位移。",
"intermediate_to_terrestrial_matrix": "返回给定时刻的中间系到地固系矩阵。\n\n中间系是当日真赤道真春分点,地固系是该时刻的地固系。两者之差是地球自转与极移。",
"greenwich_apparent_sidereal_time": "返回给定 UT1 与 TT 儒略日的格林尼治视恒星时。\n\n它是平恒星时加上春分点方程,因此是真春分点的时角。",
"greenwich_mean_sidereal_time": "返回给定 UT1 与 TT 儒略日的格林尼治平恒星时。\n\n该关系是地球自转角加上赤经中的累积岁差,后者即平春分点相对天体中间原点的旋转量。",
"earth_rotation_angle": "返回给定 UT1 儒略日的地球自转角。\n\n这是国际天文学联合会的线性表达式,在该角已知的精度内是精确的。",
"celestial_to_terrestrial_matrix": "返回给定时刻的天球到地固矩阵。\n\n它组合了整条链条:框架偏差、岁差、章动、地球自转与极移。",
"line_checksum": "返回一行前 68 列的校验和。\n\n输入可以包含末尾的校验位,它会被忽略,因为只有前 68 列参与求和。短于 68 个字符的行只贡献它拥有的列。\n\n负号贡献一,十进制数字贡献其数值,其他字符贡献零。",
"verify_line_checksum": "校验一行的最后一个字符是其校验和。\n\n该行必须至少 69 个字符长,因为校验位占据第 69 列。期望值与同一行的 [`line_checksum`] 比较。\n\n`line_number` 是所返回错误中使用的一基行号,使解析多行输入的调用方能报告是哪一行失败。",
"Classification": "元素集的密级。",
"Classification::as_char": "返回元素集中使用的字符。",
"TwoLineElement": "已解析的两行元素集。\n\n各字段是元素集中原样书写的平均元素与标识数据。单位即格式所用的单位,在各字段的文档中说明。将它们转换为状态矢量是 `apsis-sgp4` 的关注点。",
"expand_two_digit_year": "按 `57` 至 `99` 属于二十世纪、`00` 至 `56` 属于二十一世纪的约定展开两位年。\n\n分界点位于卫星编目的起点之前,因此没有任何编目条目被放在与其真实日期相差一个世纪的位置。",
"parse": "从一行、两行或三行解析两行元素集。\n\n行结束符可以是 `\\n`、`\\r\\n` 或二者的混合;空行与全为空白字符的行被忽略。不以 `1 ` 开头的首行被视为卫星名称,这正是可选三行形式的写法。\n\n每行都会检查第 1 列的预期行号、行长度以及校验和。两行的元素集编号必须一致。\n\n返回描述首个失败及发生行号的错误。",
"ElementErrorKind": "解析失败的类别。",
"ElementError": "解析两行元素集时的失败。\n\n错误记录是哪一行失败,并在适用时记录当时正在读取的列,使调用方能指向有问题的文本。行号从 1 开始,列范围是格式定义中从 1 开始的闭区间。",
"ElementError::new": "由给定的类别、行号与列创建错误。\n\n行号从 1 开始。列范围是当时正在读取的从 1 开始的闭区间,若该失败与某个字段相关。",
"ElementError::with_detail": "附上有问题文本的描述。",
"ElementError::kind": "返回本失败的类别。",
"ElementError::line": "返回失败所在行的一基行号。",
"ElementError::columns": "返回失败的从 1 开始的闭列范围,若有。",
"ElementError::detail": "返回附在本错误上的详情文本,若有。",
"PropagationErrorKind": "传播失败的类别。",
"PropagationError": "初始化或传播卫星时的失败。",
"PropagationError::new": "由给定类别创建错误。",
"PropagationError::with_detail": "附上导致失败的数值的描述。",
"PropagationError::kind": "返回本失败的类别。",
"PropagationError::detail": "返回附在本错误上的详情文本,若有。",
"Elements": "以传播器所用单位表示的卫星元素。\n\n角度单位为弧度,平均运动单位为弧度每分钟。辅助函数把 [`TwoLineElement`] 转换为这种形式。",
"Elements::from_tle": "把已解析的元素集转换为传播器所用的单位。\n\n历元取为元素集中原样书写的 UTC 约化儒略日。元素集历元是一个 UTC 日历时刻,但传播器把它当作均匀的时间原点,这正是理论拟合时所用的约定。先把它转换为均匀尺度会因闰秒偏移而使历元移动约 33 秒,而该移动会传入深空分支的日月级数。",
"Elements::unkozai_mean_motion": "施加 Kozai 到 Brouwer 平均运动的改正。\n\n元素集中的平均运动是 Kozai 平均运动,理论在使用前必须把它转换为 Brouwer 平均运动。这里以求值所用的形式写作不动点迭代,因为 `a` 通过开普勒第三定律依赖于 `n0`。对元素集中出现的每一条轨道,迭代都从改正前的平均运动出发在若干步内收敛。\n\n来源:Vallado 等,*Revisiting Spacetrack Report #3*,AIAA 2006-6753,2006。",
"Elements::validate": "在初始化之前校验平均元素。\n\n当轨道无法传播时返回错误,例如偏心率不描述闭合轨道,或平均运动不为正。",
"Elements::is_deep_space": "当元素描述深空轨道时返回 `true`。\n\n判断依据是轨道周期,而周期由平均运动给出。",
"GravityModel": "引力模型:传播器使用的地球常数。",
"GravityModel::WGS72": "WGS 72 引力模型。\n\n这是 SGP4 理论所拟合的模型,也是与标准元素集匹配的模型。",
"GravityModel::WGS84": "WGS 84 引力模型。\n\n仅在元素集是针对 WGS 84 拟合时使用。",
"GravityModel::reciprocal_sqrt_mu": "返回引力参数平方根的倒数,单位为秒每千米的二分之三次方。",
"GravityModel::sqrt_mu": "返回引力参数的平方根,单位为千米的二分之三次方每秒。",
"GravityModel::xke": "返回 `xke`,即开普勒常数,单位为 `er^1.5 / min`。\n\n传播器以分钟与地球半径为单位工作,因此引力参数按\n\n```text\nxke = 60 / sqrt(radius^3 / mu)\n```\n\n换算,即半径为一个地球半径的圆轨道的角速率,单位为弧度每分钟。",
"GravityModel::tumin": "以分钟返回 `xke` 的倒数。\n\n这是模型的时间单位:参考圆轨道的周期除以二π。",
"GravityModel::j3_over_j2": "返回 `j3 / j2`,它在理论中作为一个比值出现。",
"GravityModel::x2o3": "返回 `2 * xke^2`,该项出现在平均运动改正中。",
"Satellite": "已准备好传播的卫星。\n\n用 [`Satellite::from_element`] 构造,然后对每个关注的时刻调用 [`Satellite::propagate_minutes`] 或 [`Satellite::propagate_at`]。初始化工作——包括平均运动改正与每个只依赖元素的系数——在构造时完成一次。",
"Satellite::from_element": "由已解析的元素集初始化卫星。\n\n当平均元素不描述模型可传播的轨道时返回 `None`,例如偏心率大于等于一。需要知道原因的调用方应使用 [`Satellite::try_from_element`]。",
"Satellite::try_from_element": "由已解析的元素集初始化卫星。\n\n当元素被拒绝时,返回描述哪个元素不可传播的错误。",
"Satellite::try_from_elements": "由已是模型单位的平均元素初始化卫星。",
"Satellite::epoch_mjd": "以 TAI 尺度上的约化儒略日返回元素的历元。",
"Satellite::is_deep_space": "当适用深空分支时返回 `true`。",
"Satellite::minutes_since_epoch": "返回自元素集历元到给定时刻的分钟数。\n\n该时刻以 TAI 尺度上的约化儒略日给出,与 [`Satellite::epoch_mjd`] 同尺度。",
"Satellite::propagate_at": "返回卫星在给定时刻的状态。\n\n该时刻是 TAI 尺度上的约化儒略日。",
"Satellite::propagate_minutes": "返回卫星在距元素集历元若干分钟处的状态。\n\n负值表示向后传播。结果位于真赤道平春分点坐标系,位置单位为千米、速度单位为千米每秒。\n\n当传播得到的半径低于一个地球半径时返回 [`PropagationErrorKind::Decayed`](crate::PropagationErrorKind::Decayed),模型把该情况视为卫星已再入。",
"Satellite::xke": "返回模型的 Kepler 常数,单位为弧度每分钟每地球半径的二分之三次方。",
"State": "卫星在真赤道平春分点坐标系中的位置与速度。\n\n位置单位为千米,速度单位为千米每秒。该系是模型定义的坐标系:第一轴指向平春分点,第三轴沿真赤道极轴,第二轴构成右手系。",
"State::altitude_radius": "以千米返回距地心的距离。",
"State::speed": "以千米每秒返回相对地心的速率。",
"wrap_to_pi": "把角度归约到 `[-pi, pi)`。",
"true_from_eccentric": "返回给定偏近点角与偏心率对应的真近点角。\n\n该关系来自辅助圆的几何:\n\n```text\ntan(nu / 2) = sqrt((1 + e) / (1 - e)) tan(E / 2)\n```\n\n采用半角形式而不是直接的余弦关系,是为了在四个象限中都正确而无需象限判断。",
"eccentric_from_true": "返回给定真近点角与偏心率对应的偏近点角。\n\n这是 [`true_from_eccentric`] 的逆。",
"eccentric_from_mean": "返回给定平近点角与偏心率对应的偏近点角。\n\n用牛顿法求解开普勒方程\n\n```text\nM = E - e sin E\n```\n\n初值取平近点角加上一个与偏心率成正比的项,使迭代在偏心率趋近 1 时仍稳定。迭代总会终止,结果是至多固定步数后达到的值。",
"mean_from_eccentric": "返回给定偏近点角与偏心率对应的平近点角。\n\n这是直接求值的开普勒方程,`M = E - e sin E`。",
"mean_from_true": "返回给定真近点角与偏心率对应的平近点角。\n\n先把真近点角转换为偏近点角,平近点角再由开普勒方程给出。经由偏近点角可避免为扫过的面积另写闭式,使该关系只由一个表达式负责。",
"true_from_mean": "返回给定平近点角与偏心率对应的真近点角。",
"KeplerianElements": "轨道的六个开普勒元素。\n\n形状与大小由半长轴与偏心率给出,朝向由倾角、升交点赤经与近地点幅角给出,沿轨道的位置由真近点角给出。\n\n角度单位为弧度,半长轴单位为千米。轨道是椭圆,因此偏心率必须小于一。",
"KeplerianElements::State": "卫星在元素所参考惯性系中的位置与速度。\n\n位置单位为千米,速度单位为千米每秒。该系的第一轴指向参考方向,第三轴沿轨道法线,第二轴构成右手系。",
"KeplerianElements::is_valid": "当元素描述椭圆轨道时返回 `true`。",
"KeplerianElements::semi_latus_rectum": "以千米返回半通径。\n\n这是 `a (1 - e^2)`,即从焦点到轨道的距离,沿垂直长轴方向量取。",
"KeplerianElements::semi_minor_axis": "以千米返回短半轴。",
"KeplerianElements::perigee_radius": "以千米返回近地点半径。\n\n这是距焦点的最近距离,`a (1 - e)`。",
"KeplerianElements::apogee_radius": "以千米返回远地点半径。\n\n这是距焦点的最远点,`a (1 + e)`。",
"KeplerianElements::period": "以秒返回轨道周期。\n\n这是开普勒第三定律,`T = 2 pi sqrt(a^3 / mu)`。",
"KeplerianElements::eccentric_anomaly": "返回与真近点角对应的偏近点角。",
"KeplerianElements::mean_anomaly": "返回与真近点角对应的平近点角。",
"KeplerianElements::to_state": "把本元素转换为位置与速度。\n\n当元素不构成有效椭圆轨道时返回 `None`。\n\n转换先把卫星置于近焦点坐标系,其第一轴指向近地点、第三轴沿轨道法线,\n\n```text\nr = p / (1 + e cos nu)\nx = r cos nu, y = r sin nu, z = 0\nvx = -sqrt(mu / p) sin nu\nvy = sqrt(mu / p) (e + cos nu)\n```\n\n再按近地点幅角、倾角与升交点赤经依次旋转。",
"KeplerianElements::from_state": "返回给定位置与速度对应的元素。\n\n当状态非有限、半径为零,或轨道不是椭圆时返回 `None`。该转换假定轨道是束缚的,因此抛物线与双曲线状态会被拒绝,而不是以大于等于一的偏心率返回。",
"KeplerianElements::with_true_anomaly": "返回具有不同真近点角的本元素副本。",
"KeplerianElements::with_mean_anomaly": "返回处于给定平近点角的本元素副本。\n\n真近点角由平近点角经开普勒方程恢复。",
"KeplerianElements::to_mean_anomaly": "返回平近点角,即沿轨道的时间线性度量。\n\n该值与 [`KeplerianElements::mean_anomaly`] 相同,命名是为了与 [`KeplerianElements::with_mean_anomaly`] 对称。",
"KeplerianElements::perifocal_to_inertial": "返回自近焦点系到惯性系的旋转。\n\n该旋转是三次初等旋转之积:先绕第三轴旋转近地点幅角,再绕第一轴旋转倾角,最后绕第三轴旋转升交点赤经。",
"GravityParameter": "中心天体的引力参数,单位为立方千米每二次方秒。\n\n提供两个值是因为工作区同时支持两种引力模型,正确的选择是与轨道元素来源相匹配的那个。对由两行元素集导出的元素,应使用 SGP4 理论所拟合的 WGS 72 值。",
"GravityParameter::WGS72": "WGS 72 引力参数,`398 600.8 km^3/s^2`。",
"GravityParameter::WGS84": "WGS 84 引力参数,`398 600.5 km^3/s^2`。",
"GravityParameter::value": "以立方千米每二次方秒返回该值。",
"GravityParameter::sqrt": "返回参数的平方根,单位为千米的二分之三次方每秒。",
"Orbit::from_element": "由已解析的元素集与引力模型准备轨道。\n\n引力模型必须与元素集拟合时所用的一致,对已编目卫星而言即 [`GravityModel::WGS72`]。当元素不可传播时返回 `None`。",
"Orbit::try_from_element": "由已解析的元素集与引力模型准备轨道。\n\n返回描述哪个元素不可传播的错误。",
"Orbit::from_elements": "由已是模型单位的平均元素准备轨道。",
"Orbit::satellite": "返回底层传播器。",
"Orbit::gravity_parameter": "返回与引力模型配对的引力参数。\n\n该值用于密切元素转换,使转换与传播保持一致。",
"Orbit::epoch_mjd": "以 UTC 尺度上的约化儒略日返回元素的历元。\n\n历元是元素集中书写的值,也是传播器据以计量时间的原点。",
"Orbit::is_deep_space": "当传播器使用深空分支时返回 `true`。",
"Orbit::propagate_minutes": "返回距元素历元若干分钟处的状态。\n\n位置单位为千米、速度单位为千米每秒,位于真赤道平春分点坐标系。",
"Orbit::propagate_at": "返回 UTC 尺度上给定约化儒略日处的状态。",
"Orbit::osculating_elements": "返回距元素历元若干分钟处的密切开普勒元素。\n\n密切元素描述与那一时刻的位置和速度相符的二体轨道。它们不是元素集中的平均元素,且沿轨道变化,因为 SGP4 包含二体模型没有的摄动。\n\n当状态无法传播,或瞬时轨道不是椭圆时返回错误;后者在传播把状态推到逃逸轨迹上时可能发生。",
"Orbit::radius_at": "返回状态矢量的模。\n\n这是只想得到距地心距离这一常见情形的便捷方法。",
"Orbit::position_at": "返回距元素历元若干分钟处的位置。",
"Orbit::sample_ephemeris": "在分钟区间内把轨道采样为星历。\n\n传播是轨迹中开销较大的部分,因此建表一次、查询多次值得占用这些内存。采样时间是从元素历元起的分钟数,与本类型其余部分一致;而返回的星历保留秒,与 `apsis-ephemeris` 一致。\n\n表由传播器给出的位置与速度构建,因此 Hermite 插值在端点具有精确斜率,采样之间的误差极小;除非来源没有速度,否则无需使用只给位置的路径。\n\n当网格不可用,或某个采样无法传播时返回错误;后者在元素已衰减或区间超出模型定义范围时发生。",
"Orbit::apply_burn": "施加有限推力机动并返回所得的密切元素。\n\n机动施加在给定时刻的密切状态上,因此它描述的是相对卫星实际所在轨道而非平均元素的机动。所得元素是机动后状态的密切元素,这正是后续传播所需要的。由于底层传播器是为一个元素集构建的,这是规划结果而不是新的传播器;想要传播新轨道的调用方应从返回的元素重新构造一个 `Orbit`。",
"EphemerisSamplingError": "把轨道采样为星历时的失败。",
"ManeuverApplicationError": "对轨道施加机动时的失败。",
"Degrees::new": "由弧度值创建角度。",
"Degrees::value": "以弧度返回本角度。",
"Degrees::to_degrees": "把本角度转换为度。",
"Degrees::wrap": "返回归约到半开区间 `[-pi, pi)` 的本角度。\n\n归约使用 [`f64::rem_euclid`](f64::rem_euclid),因此结果对负输入与大输入都有定义。",
"Kilometers::to_kilometers": "把本长度转换为千米。",
"Sample": "一个采样状态。\n\n采样是某一时刻的位置与速度,这是工作区中每个传播器产生的形式,也是插值所需的形式。",
"Sample::new": "创建采样。",
"Sample::is_valid": "当时间、位置与速度都是有限值时返回 `true`。",
"Sample::radius": "以千米返回距原点的距离。",
"Sample::speed": "以千米每秒返回速率。",
"hermite_position": "对单个分量求值三次 Hermite 多项式。\n\n`p0` 与 `p1` 是端点值,`v0` 与 `v1` 是端点对时间的导数,`dt` 是区间长度,`s` 是 `[0, 1]` 内的区间比例。\n\n四项是标准的三次 Hermite 基:第一、三项承载端点值,第二、四项承载斜率。导数项乘以 `dt`,因为斜率是每单位时间的量,而 `s` 是每单位区间的量。",
"hermite_velocity": "对单个分量求 Hermite 三次多项式的导数。\n\n这是与 [`hermite_position`] 相配的速度。它是该三次多项式的精确导数,因此重建的状态内部自洽:对位置求导的调用方会得到这个速度。",
"hermite_state": "同时求值 Hermite 位置与速度。",
"Ephemeris": "通过插值回答查询的采样状态表。\n\n表至少包含两个采样,且时间严格递增。在采样时刻的查询精确返回该采样;两个采样之间的查询返回通过四个端点值的 Hermite 三次多项式。超出覆盖区间的查询被拒绝而不外推,因为插值在那里没有信息,而外推的轨道可能迅速发散。",
"Ephemeris::from_samples": "由按时间递增的采样构建表。\n\n当采样少于两个、某个采样非有限,或时间不严格递增时返回错误。",
"Ephemeris::samples": "按时间顺序返回采样。",
"Ephemeris::len": "返回采样个数。",
"Ephemeris::is_empty": "当表为空时返回 `true`。\n\n通过 [`Ephemeris::from_samples`] 构建的表永远不会为空,因此对已存在的表该值恒为 `false`;提供它是为了让接受切片的泛型代码能使用常见的方法对。",
"Ephemeris::start_time": "以秒返回第一个采样时间。",
"Ephemeris::end_time": "以秒返回最后一个采样时间。",
"Ephemeris::duration": "以秒返回表覆盖的时间长度。",
"Ephemeris::covers": "当给定时间落在覆盖区间内时返回 `true`。\n\n区间是闭区间,因此两个端点都被覆盖。",
"Ephemeris::uniform_step": "当采样均匀分布时返回标称步长。\n\n当间距变化时返回 `None`,调用方在按算术下标访问采样前会检查这一点。",
"Ephemeris::is_uniform": "当采样均匀分布时返回 `true`。",
"Ephemeris::state_at": "返回给定时间的状态,在采样之间插值。\n\n时间恰为采样时间时返回该采样。当时间超出覆盖区间时返回 `None`。",
"Ephemeris::clamped_state_at": "返回给定时间的状态;当时间超出覆盖区间时返回最近的端点状态。\n\n这适用于必须为任意时间给出数值、并宁愿保持最后已知状态而不愿失败的调用方。",
"Ephemeris::nearest_sample": "返回距给定时间最近的采样,不作插值。",
"Ephemeris::times": "按顺序返回每个采样的时间。",
"Ephemeris::all_samples": "当每个状态都满足给定谓词时返回 `true`。\n\n这是为想一次性检查整表某个性质(例如每个半径都在地表之上)的调用方提供的便捷方法。",
"EphemerisErrorKind": "星历错误的类别。",
"EphemerisError": "构建或查询星历时的失败。",
"EphemerisError::new": "由给定类别创建错误。",
"EphemerisError::with_detail": "附上导致失败的数值的描述。",
"EphemerisError::kind": "返回本失败的类别。",
"EphemerisError::detail": "返回附在本错误上的详情文本,若有。",
"Sampler": "用于求值状态函数的均匀时间网格。\n\n采样器描述何时采样以及多密。网格由起始时间、结束时间与步长定义,采样个数由它们推出:采样放在起点以及直到终点的每个步长处,终点无论是否落在网格上都被包含。包含终点很重要,因为要求覆盖到某一时间的调用方期望表能够到达那里。",
"Sampler::new": "创建带步长的区间采样器。",
"Sampler::sample_count": "返回网格产生的采样个数。\n\n这是区间内能容纳的完整步数,加上起点一个,以及当终点不在网格上时终点一个。当采样器不可用时返回 `None`。",
"Sampler::times": "按顺序返回网格的各时间。\n\n当采样器不可用时返回 `None`。当终点不在网格上时最后一个时间是终点,否则是最后一个网格点。",
"Sampler::sample": "通过在网格上求值状态函数填充星历。\n\n该函数把以秒计的时间映射为位置与速度。在本工作区中它通常是 `apsis_sgp4::Satellite::propagate_at` 上的闭包,但任何函数都可以,因此采样器可以用解析轨道测试。\n\n当采样器不可用,或函数产生非有限状态时返回错误。",
"Sampler::sample_positions": "由只返回位置的函数填充星历。\n\n速度由相邻位置的中心差分重建。这适用于只有位置的来源,例如表格文件;在有速度可用时应使用它,因此请优先使用 [`Sampler::sample`]。",
"FiniteBurn": "在一段时长内施加恒定加速度的机动。\n\n有限推力机动是发动机开机时间长到不可忽略时的模型:电推进发动机的推力段可能占轨道的很大一部分,或者航天器在推力期间移动明显的任何点火。\n\n加速度在调用方选定的惯性系中保持恒定。这是本模型所作的简化:真实发动机沿随航天器转动以保持某一方向的体轴点火,对其建模需要姿态历史,不属于本 crate 的范围。",
"FiniteBurn::new": "由加速度与时长创建机动。\n\n当某个值非有限、时长不为正,或加速度为零时返回错误。",
"FiniteBurn::along": "创建沿某方向的给定加速度大小的机动。\n\n方向会被归一化,因此无论方向矢量多长,加速度大小都是固定的。",
"FiniteBurn::acceleration": "以千米每二次方秒返回加速度矢量。",
"FiniteBurn::acceleration_magnitude": "以千米每二次方秒返回加速度的大小。",
"FiniteBurn::duration": "以秒返回时长。",
"FiniteBurn::nominal_delta_v": "以千米每秒返回该加速度在自由空间会给出的速度增量。\n\n这是加速度与时长之积,即点火期间无重力作用时会提供速度增量。在重力作用下,短点火实际达到的增量接近该值,长点火则偏离,而这一差异正是有限推力模型存在的意义。",
"FiniteBurn::equivalent_impulse": "返回与标称增量等效的脉冲。\n\n这是有限推力机动在时长趋于零时所逼近的脉冲机动,也是 [`FiniteBurn::apply`] 在恒定重力场极限下返回的结果。",
"FiniteBurn::apply": "在二体引力下对状态施加机动。\n\n在时长内把位置与速度向前积分,在重力之外加上恒定加速度。恒定加速度加质点引力没有闭式解,因此积分采用定步长四阶 Runge-Kutta,对一次点火所占的短弧是精确的。\n\n所达到的速度增量是与同长度的滑行比较,而不是与初速度比较。这一区别很重要:即使没有推力,重力在点火期间也会改变速度,因此末速度与初速度之差包含速度矢量的自然转动。与滑行比较可分离出推力的贡献,也就是点火所买到的东西;这也使结果在时长趋于零时收敛到标称增量,而与初速度之差不会如此。\n\n返回点火结束时的状态与所达到的速度增量。",
"FiniteBurn::apply_to_elements": "对一组开普勒元素施加机动。\n\n元素先转换为状态,施加机动,再转换回来。当元素无效、积分失败,或所得状态不再是椭圆轨道(在机动大到足以逃逸时发生)时返回错误。",
"BurnOutcome": "有限推力机动产生的结果。\n\n结果保留末状态、机动所达到的增量,以及加速度在自由空间会给出的增量,使调用方能看出重力在点火期间消耗或增加了多少。",
"BurnOutcome::achieved_magnitude": "返回所达到速度增量的大小。",
"BurnOutcome::gravity_loss": "以千米每秒返回所达到增量与标称增量之差。\n\n负值意味着重力在点火期间把速度转得与推力相反,因此该点火买到的比其自由空间速度增量少。",
"BurnOutcome::elements": "返回点火结束时的元素。\n\n当末状态不是椭圆轨道(在机动大到足以逃逸时发生)时返回 `None`。",
"ManeuverErrorKind": "机动错误的类别。",
"ManeuverError": "构建或施加机动时的失败。",
"ManeuverError::new": "由给定类别创建错误。",
"ManeuverError::with_detail": "附上导致失败的数值的描述。",
"ManeuverError::kind": "返回本失败的类别。",
"ManeuverError::detail": "返回附在本错误上的详情文本,若有。",
"HohmannTransfer": "两个共面圆轨道之间的两次脉冲转移。\n\n该转移即经典的霍曼机动:先点火进入一个椭圆,其近地点与较低轨道相切、远地点与较高轨道相切;滑行该椭圆周期的一半;再第二次点火进入目标圆轨道。在脉冲模型中,这是两个共面圆轨道之间最省燃料的两次脉冲转移,因此也是衡量其他一切转移的基准。\n\n两次点火沿运动方向带符号:正值抬升轨道,负值降低轨道,因此同一类型同时覆盖爬升与下降。总量是二者大小之和,即推进剂预算所用的量。",
"HohmannTransfer::total_delta_v": "以千米每秒返回总速度增量。\n\n这是两次点火大小之和,即该转移无论方向如何所付出的代价。",
"HohmannTransfer::is_raising": "当转移抬升轨道时返回 `true`。",
"HohmannTransfer::first_burn_magnitude": "返回第一次点火的速度增量,不带符号。",
"HohmannTransfer::second_burn_magnitude": "返回第二次点火的速度增量,不带符号。",
"HohmannTransfer::mean_radius": "以千米返回转移所对称的平均半径。\n\n这是椭圆的半长轴,即两个轨道半径的算术平均。",
"hohmann_transfer": "返回两个圆轨道之间的两次脉冲转移。\n\n半径单位为千米,必须有限且为正。二者可以以任意顺序给出:第二个半径较大是爬升,较小是下降。\n\n当某个半径不可用,或二者相等时返回错误,因为轨道与其自身之间的转移不是机动,几乎总是计划中的错误。\n\n两次点火由活力公式给出。在第一个半径处,圆轨道速度为 `sqrt(mu / r1)`,转移椭圆上的速度为 `sqrt(mu (2 / r1 - 1 / a))`,其中 `a` 是椭圆的半长轴,因此第一次点火即二者之差。第二次点火是目标处圆轨道速度与该处椭圆上速度之差,取相反的顺序,因为椭圆到达时慢于它必须并入的圆轨道。滑行时间为椭圆周期的一半。",
"Impulse": "速度的瞬时变化。\n\n脉冲是在零时间内施加的速度增量,因此它改变状态的速度而不改变位置。这是相对轨道周期很短的点的模型,也是速度增量预算的写法。\n\n增量以与将被施加的状态同一坐标系中的矢量存储,因此调用方负责在该系中表达它。其大小即速度增量,也就是推进剂预算所计量的量。",
"Impulse::new": "以千米每秒由速度增量矢量创建脉冲。\n\n当某个分量非有限,或矢量为零时返回错误,因为零增量不是机动,几乎总是计划中的错误。",
"Impulse::along": "创建沿某方向的给定大小的脉冲。\n\n方向会被归一化,因此无论方向矢量多长,大小就是速度增量。当大小不为正且有限,或方向为零或非有限时返回错误。",
"Impulse::delta_v": "以千米每秒返回速度增量矢量。",
"Impulse::magnitude": "以千米每秒返回增量的大小。\n\n这就是该点火所付出的速度增量。",
"Impulse::scaled": "返回按给定因子缩放后的增量副本。\n\n因子为零会产生零脉冲,而 [`Impulse::new`] 会拒绝它,因此这里对它返回错误,而不是返回一个无法构造的值。",
"Impulse::reversed": "返回反向的增量副本,即撤销该点火。",
"Impulse::apply_to_velocity": "对速度施加脉冲,返回新的速度。",
"Orbit": "以平均元素持有的、已准备好传播的卫星轨道。\n\n本类型把已准备好的 SGP4 卫星与其初始化时所用的引力模型配对,因为密切元素转换需要同一个引力参数才能与传播保持一致。",
"Geodetic::vector": "以米返回该位置。",
"Geodetic::to_eci": "在给定地球自转角下,把本地固系位置转换到惯性系。",
"Geodetic::to_geodetic": "把本地固系位置转换为给定椭球面上的纬度、经度与高。\n\n当位置位于极轴上时返回 `None`,因为此时经度无定义。",
"State::is_valid": "当元素描述椭圆轨道时返回 `true`。",
"State::semi_latus_rectum": "以千米返回半通径。\n\n这是 `a (1 - e^2)`,即从焦点到轨道的距离,沿垂直长轴方向量取。",
"State::semi_minor_axis": "以千米返回短半轴。",
"State::perigee_radius": "以千米返回近地点半径。\n\n这是距焦点的最近距离,`a (1 - e)`。",
"State::apogee_radius": "以千米返回远地点半径。\n\n这是距焦点的最远点,`a (1 + e)`。",
"State::period": "以秒返回轨道周期。\n\n这是开普勒第三定律,`T = 2 pi sqrt(a^3 / mu)`。",
"State::eccentric_anomaly": "返回与真近点角对应的偏近点角。",
"State::mean_anomaly": "返回与真近点角对应的平近点角。",
"State::to_state": "把本元素转换为位置与速度。\n\n当元素不构成有效椭圆轨道时返回 `None`。\n\n转换先把卫星置于近焦点坐标系,其第一轴指向近地点、第三轴沿轨道法线,\n\n```text\nr = p / (1 + e cos nu)\nx = r cos nu, y = r sin nu, z = 0\nvx = -sqrt(mu / p) sin nu\nvy = sqrt(mu / p) (e + cos nu)\n```\n\n再按近地点幅角、倾角与升交点赤经依次旋转。",
"State::from_state": "返回给定位置与速度对应的元素。\n\n当状态非有限、半径为零,或轨道不是椭圆时返回 `None`。该转换假定轨道是束缚的,因此抛物线与双曲线状态会被拒绝,而不是以大于等于一的偏心率返回。",
"State::with_true_anomaly": "返回具有不同真近点角的本元素副本。",
"State::with_mean_anomaly": "返回处于给定平近点角的本元素副本。\n\n真近点角由平近点角经开普勒方程恢复。",
"State::to_mean_anomaly": "返回平近点角,即沿轨道的时间线性度量。\n\n该值与 [`KeplerianElements::mean_anomaly`] 相同,命名是为了与 [`KeplerianElements::with_mean_anomaly`] 对称。",
"State::perifocal_to_inertial": "返回自近焦点系到惯性系的旋转。\n\n该旋转是三次初等旋转之积:先绕第三轴旋转近地点幅角,再绕第一轴旋转倾角,最后绕第三轴旋转升交点赤经。"
}
}

View File

@ -155,6 +155,26 @@ description would drift silently.
The script reports the crates it read in `generated_from`, which the service The script reports the crates it read in `generated_from`, which the service
returns in `/api/api`, so the provenance of what is displayed is checkable. returns in `/api/api`, so the provenance of what is displayed is checkable.
The translation is a separate file, `content/api.zh.json`, keyed by crate
name and by item name. It is separate from the catalog because the catalog
is generated and the translation is authored: regenerating the catalog must
not discard the translation. An associated item is keyed by the type that
declares it, as `Type::name`, because several types have an associated
`new`; the bare name is the fallback.
A doc comment refers to another item with a Rust path, written as
`[Epoch](crate::Epoch)` or as a shortcut `[`Epoch::mjd`]`. The renderer turns
such a reference into a link to the interface page of that item. The name is
resolved by walking the path from the right and taking the first segment that
is a known item, so a link to a variant or a method points at the enum or the
trait that declares it. A reference whose segments are all unknown stays a
code span, which is what keeps a label such as `[`0, 1`]` from becoming a
link to nothing.
The documentation comment is rendered to HTML by the backend, with the same
renderer the concept documents use, so a comment that contains a list, a
table, or a code block is drawn as one.
## Frontend and embedding ## Frontend and embedding
The frontend is Svelte and TypeScript, built by Vite. The build output is a The frontend is Svelte and TypeScript, built by Vite. The build output is a

View File

@ -275,10 +275,20 @@ hr {
max-width: 34rem; max-width: 34rem;
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: stretch;
} }
.search-box input { .search-box input {
flex: 1 1 auto; flex: 1 1 auto;
/* Without this the input keeps its intrinsic width as its minimum and
squeezes the button until the label wraps. */
min-width: 0;
}
/* A button label is a single line; a narrow flex item must not break it. */
button {
white-space: nowrap;
flex-shrink: 0;
} }
.content { .content {
@ -863,6 +873,26 @@ tbody tr:hover {
margin: 0.5rem 0; margin: 0.5rem 0;
} }
/* A crate summary on the crate page */
.crate-summary {
color: var(--text-muted);
font-size: 0.9rem;
max-width: 60rem;
margin-bottom: 0.5rem;
}
.crate-summary p {
margin: 0.5rem 0;
}
/* A link to another interface item, rendered from a doc comment. */
.api-doc a.api-link,
.crate-summary a.api-link {
color: var(--accent);
border-bottom: 1px dotted var(--accent-strong);
}
/* The interface items a tool calls */ /* The interface items a tool calls */
.calls { .calls {

View File

@ -28,7 +28,7 @@
<div class="scalars"> <div class="scalars">
{#each result.scalars as scalar} {#each result.scalars as scalar}
<div class="scalar"> <div class="scalar">
<span class="label">{scalar.label}</span> <span class="label">{pick(scalar.label, lang)}</span>
<span class="value" style={scalar.monospace ? '' : 'font-family: var(--sans)'}> <span class="value" style={scalar.monospace ? '' : 'font-family: var(--sans)'}>
{scalar.value}{#if scalar.unit}<span class="unit">{scalar.unit}</span>{/if} {scalar.value}{#if scalar.unit}<span class="unit">{scalar.unit}</span>{/if}
</span> </span>
@ -48,7 +48,7 @@
<tr> <tr>
{#each result.table.columns as column} {#each result.table.columns as column}
<th class={column.numeric ? 'numeric' : ''}> <th class={column.numeric ? 'numeric' : ''}>
{column.label}{#if column.unit}<span class="unit"> [{column.unit}]</span>{/if} {pick(column.label, lang)}{#if column.unit}<span class="unit"> [{column.unit}]</span>{/if}
</th> </th>
{/each} {/each}
</tr> </tr>
@ -71,7 +71,7 @@
{#if result.notes.length > 0} {#if result.notes.length > 0}
<div class="notice muted"> <div class="notice muted">
{#each result.notes as note} {#each result.notes as note}
<p>{note}</p> <p>{pick(note, lang)}</p>
{/each} {/each}
</div> </div>
{/if} {/if}

View File

@ -106,25 +106,43 @@ export interface Graph {
edges: GraphEdge[]; edges: GraphEdge[];
} }
/**
* A documentation text in both languages.
*
* The `plain` members carry the text with the Markdown markup removed, and
* the `html` members carry it rendered. A language without a translation
* falls back to English.
*/
export interface ApiText {
zh: string;
en: string;
plain: Bilingual;
html: Bilingual;
}
/** A crate of the interface catalog. */ /** A crate of the interface catalog. */
export interface ApiCrateSummary { export interface ApiCrateSummary {
name: string; name: string;
layer: string; layer: string;
summary: string; summary: BilingualSummary;
/** The module documentation, with the Markdown markup removed. */
summary_plain: string;
items: number; items: number;
} }
/** A crate summary in both languages, with the plain and rendered forms. */
export interface BilingualSummary {
zh: string;
en: string;
plain: Bilingual;
html: Bilingual;
}
/** One interface item. */ /** One interface item. */
export interface ApiItem { export interface ApiItem {
kind: string; kind: string;
name: string; name: string;
signature: string; signature: string;
/** The documentation comment as it is written in the source. */ /** The documentation comment in both languages. */
doc: string; doc: ApiText;
/** The documentation comment rendered as HTML by the backend. */
doc_html: string;
associated: boolean; associated: boolean;
} }
@ -141,8 +159,7 @@ export interface ApiIndex {
export interface ApiCrate { export interface ApiCrate {
name: string; name: string;
layer: string; layer: string;
summary: string; summary: BilingualSummary;
summary_plain: string;
items: ApiItem[]; items: ApiItem[];
} }

View File

@ -15,7 +15,7 @@
import type { ApiIndex, ApiCrate, ApiItem } from '../lib/types'; import type { ApiIndex, ApiCrate, ApiItem } from '../lib/types';
import { getJson } from '../lib/api'; import { getJson } from '../lib/api';
import { navigate } from '../lib/router'; import { navigate } from '../lib/router';
import { language, tr } from '../lib/i18n'; import { language, pick, tr } from '../lib/i18n';
import Signature from '../lib/Signature.svelte'; import Signature from '../lib/Signature.svelte';
interface Props { interface Props {
@ -101,6 +101,24 @@
navigate(`/api/${name}`); navigate(`/api/${name}`);
} }
/**
* Handles a click inside a rendered doc comment.
*
* A doc comment that links to another item is rendered as an internal
* link, which the router handles; a link to a web address is left to the
* browser.
*/
function onDocClick(event: MouseEvent) {
const target = event.target as HTMLElement | null;
const anchor = target?.closest('a');
if (!anchor) return;
const item = anchor.getAttribute('data-api');
if (item) {
event.preventDefault();
navigate(`/api/${item}`);
}
}
/** /**
* Returns the first sentence of a crate summary. * Returns the first sentence of a crate summary.
* *
@ -131,7 +149,9 @@
<span class="badge" style="margin-left: 0.5rem">{crate.layer}</span> <span class="badge" style="margin-left: 0.5rem">{crate.layer}</span>
</p> </p>
<h2 style="margin-top: 0.5rem">{crate.name}</h2> <h2 style="margin-top: 0.5rem">{crate.name}</h2>
<p style="color: var(--text-muted)">{crate.summary}</p> <!-- The HTML is rendered by the backend from the module documentation,
and every text node is escaped there. -->
<div class="doc-body crate-summary">{@html pick(crate.summary.html, lang)}</div>
{#each groups as group} {#each groups as group}
<h3 id={`kind-${group.kind}`}> <h3 id={`kind-${group.kind}`}>
@ -154,10 +174,15 @@
<div style="margin: 0.5rem 0"> <div style="margin: 0.5rem 0">
<Signature signature={candidate.signature} name={candidate.name} /> <Signature signature={candidate.signature} name={candidate.name} />
</div> </div>
{#if candidate.doc} {#if candidate.doc.en}
<!-- The HTML is rendered by the backend from the doc comment, and <!-- The HTML is rendered by the backend from the doc comment, and
every text node is escaped there. --> every text node is escaped there. -->
<div class="doc-body api-doc">{@html candidate.doc_html}</div> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="doc-body api-doc" role="document" onclick={onDocClick}>
{@html pick(candidate.doc.html, lang)}
</div>
{/if} {/if}
</article> </article>
{/each} {/each}
@ -178,10 +203,15 @@
<div style="margin: 0.75rem 0"> <div style="margin: 0.75rem 0">
<Signature signature={item.signature} name={item.name} /> <Signature signature={item.signature} name={item.name} />
</div> </div>
{#if item.doc} {#if item.doc.en}
<!-- The HTML is rendered by the backend from the doc comment, and <!-- The HTML is rendered by the backend from the doc comment, and
every text node is escaped there. --> every text node is escaped there. -->
<div class="doc-body api-doc">{@html item.doc_html}</div> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="doc-body api-doc" role="document" onclick={onDocClick}>
{@html pick(item.doc.html, lang)}
</div>
{:else} {:else}
<p class="empty">{tr('api.noDoc', lang)}</p> <p class="empty">{tr('api.noDoc', lang)}</p>
{/if} {/if}
@ -228,7 +258,7 @@
<span class="badge">{candidate.layer}</span> <span class="badge">{candidate.layer}</span>
<span class="badge">{candidate.items}</span> <span class="badge">{candidate.items}</span>
</header> </header>
<p>{firstSentence(candidate.summary_plain)}</p> <p>{firstSentence(pick(candidate.summary.plain, lang))}</p>
</a> </a>
{/each} {/each}
</div> </div>

View File

@ -45,6 +45,20 @@
return document.forms.some((entry) => entry.language === other) ? other : null; return document.forms.some((entry) => entry.language === other) ? other : null;
}); });
/**
* Scrolls to a section of the document.
*
* The application routes on the fragment, so a plain `#anchor` link would
* be read as a route and would leave the page. The fragment is left alone
* and the element is scrolled to directly instead.
*/
function scrollToSection(anchor: string) {
const element = window.document.getElementById(anchor);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
/** Handles a click inside the body. */ /** Handles a click inside the body. */
function onClick(event: MouseEvent) { function onClick(event: MouseEvent) {
const target = event.target as HTMLElement | null; const target = event.target as HTMLElement | null;
@ -57,13 +71,9 @@
return; return;
} }
const href = anchor.getAttribute('href'); const href = anchor.getAttribute('href');
if (href && href.startsWith('#')) { if (href && href.startsWith('#') && !href.startsWith('#/')) {
event.preventDefault(); event.preventDefault();
const anchorId = href.slice(1); scrollToSection(href.slice(1));
const element = window.document.getElementById(anchorId);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
} }
} }
@ -136,9 +146,13 @@
<ul style="list-style: none; padding-left: 0;"> <ul style="list-style: none; padding-left: 0;">
{#each form.headings as heading} {#each form.headings as heading}
<li style="margin: 0.2rem 0;"> <li style="margin: 0.2rem 0;">
<a href={`#${heading.anchor}`} style="color: var(--text-muted); font-size: 0.85rem;"> <a
{heading.text} href={`#${heading.anchor}`}
</a> style="color: var(--text-muted); font-size: 0.85rem;"
onclick={(event) => {
event.preventDefault();
scrollToSection(heading.anchor);
}}>{heading.text}</a>
</li> </li>
{/each} {/each}
</ul> </ul>

View File

@ -28,26 +28,37 @@
let error = $state<{ parameter: string | null; message: string } | null>(null); let error = $state<{ parameter: string | null; message: string } | null>(null);
let running = $state(false); let running = $state(false);
/** Selects a tool and fills the form with its defaults. */ /**
function select(next: Tool | null) { * Returns the form values a tool declares as its defaults.
tool = next; *
result = null; * Building a fresh object each time means the form state is replaced
error = null; * rather than mutated inside an effect, which is what keeps the effect
values = {}; * from writing the state it also reads.
if (!next) return; */
function defaultsFor(next: Tool | null): Record<string, string> {
const fresh: Record<string, string> = {};
if (!next) return fresh;
for (const param of next.params) { for (const param of next.params) {
values[param.name] = param.default ?? ''; fresh[param.name] = param.default ?? '';
} }
return fresh;
} }
// The effect depends only on the registry and the route, and it assigns
// the three pieces of form state as a unit. It must not read `values`,
// because reading state that the same effect writes makes the effect
// schedule itself again and the update depth is exceeded.
$effect(() => { $effect(() => {
if (!registry) return; const currentRegistry = registry;
if (!id) { const currentId = id;
select(null); if (!currentRegistry) return;
return; const found = currentId
} ? (currentRegistry.tools.find((candidate) => candidate.id === currentId) ?? null)
const found = registry.tools.find((candidate) => candidate.id === id) ?? null; : null;
select(found); tool = found;
result = null;
error = null;
values = defaultsFor(found);
}); });
/** Runs the selected tool. */ /** Runs the selected tool. */
@ -85,11 +96,7 @@
/** Resets the form to the declared defaults. */ /** Resets the form to the declared defaults. */
function reset() { function reset() {
if (!tool) return; values = defaultsFor(tool);
values = {};
for (const param of tool.params) {
values[param.name] = param.default ?? '';
}
result = null; result = null;
error = null; error = null;
} }

View File

@ -221,6 +221,7 @@ impl Router {
/// Returns the interface catalog summary. /// Returns the interface catalog summary.
fn api_index(&self) -> Response { fn api_index(&self) -> Response {
let catalog = &self.state.api; let catalog = &self.state.api;
let item_names = &catalog.item_names;
let crates: Vec<Value> = catalog let crates: Vec<Value> = catalog
.crates .crates
.iter() .iter()
@ -228,8 +229,7 @@ impl Router {
json_object! { json_object! {
"name" => krate.name.clone(), "name" => krate.name.clone(),
"layer" => krate.layer.clone(), "layer" => krate.layer.clone(),
"summary" => krate.summary.clone(), "summary" => crate_texts(krate, item_names),
"summary_plain" => crate::docs::markdown::to_plain_text(&krate.summary),
"items" => krate.items.len(), "items" => krate.items.len(),
} }
}) })
@ -261,9 +261,11 @@ impl Router {
fn api_crate(&self, path: &str) -> Response { fn api_crate(&self, path: &str) -> Response {
let name = path.trim_start_matches("/api/api/crates/"); let name = path.trim_start_matches("/api/api/crates/");
match self.state.api.krate(name) { match self.state.api.krate(name) {
Some(krate) => { Some(krate) => Response::json(json::to_string(&crate_json(
Response::json(json::to_string(&crate_json(krate))).with_cache_control("no-cache") krate,
} &self.state.api.item_names,
)))
.with_cache_control("no-cache"),
None => not_found("crate"), None => not_found("crate"),
} }
} }
@ -279,9 +281,7 @@ impl Router {
"kind" => item.kind.clone(), "kind" => item.kind.clone(),
"name" => item.name.clone(), "name" => item.name.clone(),
"signature" => item.signature.clone(), "signature" => item.signature.clone(),
"doc" => item.doc.clone(), "doc" => doc_texts(item, &self.state.api.item_names),
"doc_html" => crate::docs::markdown::render(&item.doc),
"doc_plain" => crate::docs::markdown::to_plain_text(&item.doc),
"associated" => item.associated, "associated" => item.associated,
}; };
Response::json(json::to_string(&body)).with_cache_control("no-cache") Response::json(json::to_string(&body)).with_cache_control("no-cache")
@ -437,6 +437,53 @@ fn document_index(state: &AppState) -> Value {
} }
} }
/// Encodes a documentation text and its rendering in both languages.
///
/// The documentation comment is rendered to HTML here, by the same renderer
/// the concept documents use, so a comment that contains a list, a table,
/// or a code block is drawn as one. The two `plain` members carry the text
/// with the markup removed, which the index uses for a one line summary.
fn doc_texts(item: &crate::docs::ApiItem, items: &std::collections::HashSet<String>) -> Value {
let zh = item.doc_zh.as_deref().unwrap_or(&item.doc);
json_object! {
"zh" => zh.to_string(),
"en" => item.doc.clone(),
"html" => json_object! {
"zh" => crate::docs::markdown::render_with(zh, Some(items)),
"en" => crate::docs::markdown::render_with(&item.doc, Some(items)),
},
"plain" => json_object! {
"zh" => crate::docs::markdown::to_plain_text(zh),
"en" => crate::docs::markdown::to_plain_text(&item.doc),
},
}
}
/// Encodes a crate summary in both languages, with the plain text.
fn crate_texts(krate: &crate::docs::ApiCrate, items: &std::collections::HashSet<String>) -> Value {
let zh = krate.summary_zh.as_deref().unwrap_or(&krate.summary);
json_object! {
"zh" => zh.to_string(),
"en" => krate.summary.clone(),
"plain" => json_object! {
"zh" => crate_summary_plain(zh),
"en" => crate_summary_plain(&krate.summary),
},
"html" => json_object! {
"zh" => crate::docs::markdown::render_with(zh, Some(items)),
"en" => crate::docs::markdown::render_with(&krate.summary, Some(items)),
},
}
}
/// Returns the plain text of a crate summary.
///
/// The Markdown renderer is applied first so that a summary that opens with
/// a list or a heading is reduced to its prose.
fn crate_summary_plain(summary: &str) -> String {
crate::docs::markdown::to_plain_text(summary)
}
/// Encodes a document title or summary in both languages. /// Encodes a document title or summary in both languages.
/// ///
/// The two names select the field of a document; an unknown name yields an /// The two names select the field of a document; an unknown name yields an
@ -529,7 +576,10 @@ fn graph_json(graph: &Graph) -> Value {
} }
/// Encodes a crate of the interface catalog as JSON. /// Encodes a crate of the interface catalog as JSON.
fn crate_json(krate: &crate::docs::ApiCrate) -> Value { fn crate_json(
krate: &crate::docs::ApiCrate,
item_names: &std::collections::HashSet<String>,
) -> Value {
let items: Vec<Value> = krate let items: Vec<Value> = krate
.items .items
.iter() .iter()
@ -538,8 +588,7 @@ fn crate_json(krate: &crate::docs::ApiCrate) -> Value {
"kind" => item.kind.clone(), "kind" => item.kind.clone(),
"name" => item.name.clone(), "name" => item.name.clone(),
"signature" => item.signature.clone(), "signature" => item.signature.clone(),
"doc" => item.doc.clone(), "doc" => doc_texts(item, item_names),
"doc_html" => crate::docs::markdown::render(&item.doc),
"associated" => item.associated, "associated" => item.associated,
} }
}) })
@ -547,8 +596,7 @@ fn crate_json(krate: &crate::docs::ApiCrate) -> Value {
json_object! { json_object! {
"name" => krate.name.clone(), "name" => krate.name.clone(),
"layer" => krate.layer.clone(), "layer" => krate.layer.clone(),
"summary" => krate.summary.clone(), "summary" => crate_texts(krate, item_names),
"summary_plain" => crate::docs::markdown::to_plain_text(&krate.summary),
"items" => items, "items" => items,
} }
} }

View File

@ -25,6 +25,12 @@ pub struct ApiItem {
pub signature: String, pub signature: String,
/// Documentation comment, with the leading `///` markers removed. /// Documentation comment, with the leading `///` markers removed.
pub doc: String, pub doc: String,
/// The documentation comment in Chinese, when a translation exists.
///
/// The translation lives in `content/api.zh.json`, keyed by the item
/// name. Interface documentation is long, and a reader of the Chinese
/// interface should not have to read English to understand a function.
pub doc_zh: Option<String>,
/// `true` when the item is an associated item rather than a module /// `true` when the item is an associated item rather than a module
/// level one. /// level one.
pub associated: bool, pub associated: bool,
@ -50,6 +56,8 @@ pub struct ApiCrate {
pub layer: String, pub layer: String,
/// Crate level documentation, with the leading `//!` markers removed. /// Crate level documentation, with the leading `//!` markers removed.
pub summary: String, pub summary: String,
/// The crate level documentation in Chinese, when a translation exists.
pub summary_zh: Option<String>,
/// Public items, in source order. /// Public items, in source order.
pub items: Vec<ApiItem>, pub items: Vec<ApiItem>,
} }
@ -68,6 +76,8 @@ impl ApiCrate {
/// The whole catalog. /// The whole catalog.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ApiCatalog { pub struct ApiCatalog {
/// The names of every item, for resolving a doc comment link.
pub item_names: std::collections::HashSet<String>,
/// The crates, in dependency order. /// The crates, in dependency order.
pub crates: Vec<ApiCrate>, pub crates: Vec<ApiCrate>,
/// Source paths the catalog was extracted from, for provenance. /// Source paths the catalog was extracted from, for provenance.
@ -111,13 +121,28 @@ impl ApiCatalog {
/// ///
/// A malformed member is skipped rather than failing the whole document, so /// A malformed member is skipped rather than failing the whole document, so
/// that a partially generated catalog still serves the items it contains. /// that a partially generated catalog still serves the items it contains.
#[allow(dead_code)]
pub fn parse(text: &str) -> ApiCatalog { pub fn parse(text: &str) -> ApiCatalog {
parse_with(text, None)
}
/// Parses the catalog and applies a Chinese translation when one is given.
///
/// `translation` is the parsed content of `content/api.zh.json`, which maps
/// a crate name to its summary and an item name to its documentation. The
/// two are kept in separate files because the catalog is generated from the
/// source while the translation is authored, so regenerating the catalog
/// must not discard the translation.
pub fn parse_with(text: &str, translation: Option<&Value>) -> ApiCatalog {
let Ok(value) = json::parse(text) else { let Ok(value) = json::parse(text) else {
return ApiCatalog { return ApiCatalog {
crates: Vec::new(), crates: Vec::new(),
generated_from: Vec::new(), generated_from: Vec::new(),
item_names: std::collections::HashSet::new(),
}; };
}; };
let crate_texts = translation.and_then(|value| value.get("crates"));
let item_texts = translation.and_then(|value| value.get("items"));
let generated_from = value let generated_from = value
.get("generated_from") .get("generated_from")
.map(Value::as_array) .map(Value::as_array)
@ -125,21 +150,70 @@ pub fn parse(text: &str) -> ApiCatalog {
.iter() .iter()
.filter_map(|path| path.as_str().map(str::to_string)) .filter_map(|path| path.as_str().map(str::to_string))
.collect(); .collect();
let crates = value let crates: Vec<ApiCrate> = value
.get("crates") .get("crates")
.map(Value::as_array) .map(Value::as_array)
.unwrap_or_default() .unwrap_or_default()
.iter() .iter()
.filter_map(parse_crate) .filter_map(|krate| parse_crate(krate, crate_texts, item_texts))
.collect();
let item_names = crates
.iter()
.flat_map(|krate| krate.items.iter().map(|item| item.name.clone()))
.collect(); .collect();
ApiCatalog { ApiCatalog {
crates, crates,
generated_from, generated_from,
item_names,
} }
} }
/// Returns a translated member of a table, when the key is present.
fn translated(table: Option<&Value>, key: &str) -> Option<String> {
table
.and_then(|table| table.get(key))
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(str::to_string)
}
/// Returns the Chinese documentation of an item.
///
/// An associated item is looked up as `Type::name` first, then as `name`.
/// Several types have an associated `new`, so the qualified key is the one
/// that disambiguates them; the bare key is kept as a fallback for a
/// translation that is the same for every owner.
fn item_translation(item_texts: Option<&Value>, owner: Option<&str>, name: &str) -> Option<String> {
if let Some(owner) = owner {
if let Some(text) = translated(item_texts, &format!("{owner}::{name}")) {
return Some(text);
}
}
translated(item_texts, name)
}
/// Returns the name of the type an item is associated with.
///
/// The catalog lists an item in source order, so the type an associated
/// function belongs to is the last type declaration before it.
fn owner_of(items: &[Value], index: usize) -> Option<String> {
let mut owner = None;
for item in items.iter().take(index + 1) {
if let Some(kind) = item.get("kind").and_then(Value::as_str) {
if matches!(kind, "struct" | "enum" | "trait" | "type") {
owner = item.get("name").and_then(Value::as_str).map(str::to_string);
}
}
}
owner
}
/// Parses one crate. /// Parses one crate.
fn parse_crate(value: &Value) -> Option<ApiCrate> { fn parse_crate(
value: &Value,
crate_texts: Option<&Value>,
item_texts: Option<&Value>,
) -> Option<ApiCrate> {
let name = value.get("name")?.as_str()?.to_string(); let name = value.get("name")?.as_str()?.to_string();
let layer = value let layer = value
.get("layer") .get("layer")
@ -151,23 +225,26 @@ fn parse_crate(value: &Value) -> Option<ApiCrate> {
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or_default() .unwrap_or_default()
.to_string(); .to_string();
let items = value let raw_items = value.get("items").map(Value::as_array).unwrap_or_default();
.get("items") let items = raw_items
.map(Value::as_array)
.unwrap_or_default()
.iter() .iter()
.filter_map(parse_item) .enumerate()
.filter_map(|(index, item)| {
parse_item(item, item_texts, owner_of(raw_items, index).as_deref())
})
.collect(); .collect();
let summary_zh = translated(crate_texts, &name);
Some(ApiCrate { Some(ApiCrate {
name, name,
layer, layer,
summary, summary,
summary_zh,
items, items,
}) })
} }
/// Parses one item. /// Parses one item.
fn parse_item(value: &Value) -> Option<ApiItem> { fn parse_item(value: &Value, item_texts: Option<&Value>, owner: Option<&str>) -> Option<ApiItem> {
let name = value.get("name")?.as_str()?.to_string(); let name = value.get("name")?.as_str()?.to_string();
let kind = value.get("kind")?.as_str()?.to_string(); let kind = value.get("kind")?.as_str()?.to_string();
let signature = value let signature = value
@ -184,11 +261,13 @@ fn parse_item(value: &Value) -> Option<ApiItem> {
.get("associated") .get("associated")
.and_then(Value::as_bool) .and_then(Value::as_bool)
.unwrap_or(false); .unwrap_or(false);
let doc_zh = item_translation(item_texts, owner, &name);
Some(ApiItem { Some(ApiItem {
kind, kind,
name, name,
signature, signature,
doc, doc,
doc_zh,
associated, associated,
}) })
} }

View File

@ -19,6 +19,11 @@
//! - inline emphasis `*text*` and strong emphasis `**text**` //! - inline emphasis `*text*` and strong emphasis `**text**`
//! - inline code spans delimited by backticks //! - inline code spans delimited by backticks
//! - links written as `[text](url)` //! - links written as `[text](url)`
//!
//! A link whose target is a Rust path, such as `[Epoch](crate::Epoch)`,
//! names an item of the library rather than a web address. Such a link is
//! rendered as a link to the interface page of the item, so that a doc
//! comment can cross reference another item the way `rustdoc` does.
//! - concept cross references written as `[[id]]` or `[[id|label]]`, which //! - concept cross references written as `[[id]]` or `[[id|label]]`, which
//! render as a link with a `data-concept` attribute so the frontend can //! render as a link with a `data-concept` attribute so the frontend can
//! route them without a page load //! route them without a page load
@ -28,6 +33,15 @@
/// Renders a Markdown document to HTML. /// Renders a Markdown document to HTML.
pub fn render(markdown: &str) -> String { pub fn render(markdown: &str) -> String {
render_with(markdown, None)
}
/// Renders a Markdown document, resolving a link to a library item.
///
/// `items` is the set of item names the interface catalog contains. A
/// shortcut link whose name is not in the set is left as a code span, so a
/// doc comment can write `[`0, 1`]` without producing a link to nothing.
pub fn render_with(markdown: &str, items: Option<&std::collections::HashSet<String>>) -> String {
let lines: Vec<&str> = markdown.lines().collect(); let lines: Vec<&str> = markdown.lines().collect();
let mut output = String::with_capacity(markdown.len() + markdown.len() / 2); let mut output = String::with_capacity(markdown.len() + markdown.len() / 2);
let mut index = 0; let mut index = 0;
@ -60,7 +74,7 @@ pub fn render(markdown: &str) -> String {
output.push_str(&format!( output.push_str(&format!(
"<h{level} id=\"{}\">{}<a class=\"anchor\" href=\"#{id}\" aria-hidden=\"true\">#</a></h{level}>\n", "<h{level} id=\"{}\">{}<a class=\"anchor\" href=\"#{id}\" aria-hidden=\"true\">#</a></h{level}>\n",
escape(&id), escape(&id),
inline(text), inline_items(text, items),
)); ));
index += 1; index += 1;
continue; continue;
@ -74,7 +88,7 @@ pub fn render(markdown: &str) -> String {
if trimmed.starts_with('|') { if trimmed.starts_with('|') {
let (block, next) = collect_table(&lines, index); let (block, next) = collect_table(&lines, index);
output.push_str(&render_table(&block)); output.push_str(&render_table(&block, items));
index = next; index = next;
continue; continue;
} }
@ -87,7 +101,7 @@ pub fn render(markdown: &str) -> String {
.map(|line| line.trim_start().trim_start_matches('>').trim_start()) .map(|line| line.trim_start().trim_start_matches('>').trim_start())
.collect(); .collect();
output.push_str("<blockquote>\n"); output.push_str("<blockquote>\n");
output.push_str(&render(&inner.join("\n"))); output.push_str(&render_with(&inner.join("\n"), items));
output.push_str("</blockquote>\n"); output.push_str("</blockquote>\n");
index = next; index = next;
continue; continue;
@ -96,7 +110,7 @@ pub fn render(markdown: &str) -> String {
if is_unordered_item(trimmed) { if is_unordered_item(trimmed) {
let (block, next) = let (block, next) =
collect_while(&lines, index, |line| is_unordered_item(line.trim_end())); collect_while(&lines, index, |line| is_unordered_item(line.trim_end()));
output.push_str(&render_list(&block, false)); output.push_str(&render_list(&block, false, items));
index = next; index = next;
continue; continue;
} }
@ -104,7 +118,7 @@ pub fn render(markdown: &str) -> String {
if is_ordered_item(trimmed) { if is_ordered_item(trimmed) {
let (block, next) = let (block, next) =
collect_while(&lines, index, |line| is_ordered_item(line.trim_end())); collect_while(&lines, index, |line| is_ordered_item(line.trim_end()));
output.push_str(&render_list(&block, true)); output.push_str(&render_list(&block, true, items));
index = next; index = next;
continue; continue;
} }
@ -126,7 +140,7 @@ pub fn render(markdown: &str) -> String {
.map(|line| line.trim()) .map(|line| line.trim())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" "); .join(" ");
output.push_str(&format!("<p>{}</p>\n", inline(&text))); output.push_str(&format!("<p>{}</p>\n", inline_items(&text, items)));
index = next; index = next;
} }
@ -223,7 +237,7 @@ fn render_code_block(language: &str, code: &str) -> String {
/// The first row is the header, the second row is the delimiter that marks /// The first row is the header, the second row is the delimiter that marks
/// the table, and the remaining rows are the body. A cell whose content ends /// the table, and the remaining rows are the body. A cell whose content ends
/// with `^` is aligned to the right, which is used for numeric columns. /// with `^` is aligned to the right, which is used for numeric columns.
fn render_table(block: &[&str]) -> String { fn render_table(block: &[&str], items: Option<&std::collections::HashSet<String>>) -> String {
if block.len() < 2 { if block.len() < 2 {
return String::new(); return String::new();
} }
@ -241,7 +255,7 @@ fn render_table(block: &[&str]) -> String {
output.push_str(" class=\"numeric\""); output.push_str(" class=\"numeric\"");
} }
output.push('>'); output.push('>');
output.push_str(&inline(cell.trim().trim_end_matches('^'))); output.push_str(&inline_items(cell.trim().trim_end_matches('^'), items));
output.push_str("</th>"); output.push_str("</th>");
} }
output.push_str("</tr>\n</thead>\n<tbody>\n"); output.push_str("</tr>\n</thead>\n<tbody>\n");
@ -254,7 +268,7 @@ fn render_table(block: &[&str]) -> String {
output.push_str(" class=\"numeric\""); output.push_str(" class=\"numeric\"");
} }
output.push('>'); output.push('>');
output.push_str(&inline(cell.trim().trim_end_matches('^'))); output.push_str(&inline_items(cell.trim().trim_end_matches('^'), items));
output.push_str("</td>"); output.push_str("</td>");
} }
output.push_str("</tr>\n"); output.push_str("</tr>\n");
@ -270,7 +284,11 @@ fn split_row(row: &str) -> Vec<String> {
} }
/// Renders a list. /// Renders a list.
fn render_list(block: &[&str], ordered: bool) -> String { fn render_list(
block: &[&str],
ordered: bool,
items: Option<&std::collections::HashSet<String>>,
) -> String {
let tag = if ordered { "ol" } else { "ul" }; let tag = if ordered { "ol" } else { "ul" };
let mut output = format!("<{tag}>\n"); let mut output = format!("<{tag}>\n");
for line in block { for line in block {
@ -281,7 +299,7 @@ fn render_list(block: &[&str], ordered: bool) -> String {
} else { } else {
trimmed[2..].to_string() trimmed[2..].to_string()
}; };
output.push_str(&format!("<li>{}</li>\n", inline(&content))); output.push_str(&format!("<li>{}</li>\n", inline_items(&content, items)));
} }
output.push_str(&format!("</{tag}>\n")); output.push_str(&format!("</{tag}>\n"));
output output
@ -292,7 +310,13 @@ fn render_list(block: &[&str], ordered: bool) -> String {
/// The scan is single pass. A backtick span ends at the next backtick, a /// The scan is single pass. A backtick span ends at the next backtick, a
/// link ends at its closing parenthesis, and a cross reference ends at its /// link ends at its closing parenthesis, and a cross reference ends at its
/// closing bracket; anything unmatched is left as literal text. /// closing bracket; anything unmatched is left as literal text.
#[allow(dead_code)]
fn inline(text: &str) -> String { fn inline(text: &str) -> String {
inline_items(text, 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 characters: Vec<char> = text.chars().collect();
let mut output = String::with_capacity(text.len()); let mut output = String::with_capacity(text.len());
let mut index = 0; let mut index = 0;
@ -314,19 +338,48 @@ fn inline(text: &str) -> String {
if character == '[' && characters.get(index + 1) == Some(&'[') { if character == '[' && characters.get(index + 1) == Some(&'[') {
if let Some(end) = find_pair(&characters, index + 2) { if let Some(end) = find_pair(&characters, index + 2) {
let inner: String = characters[index + 2..end].iter().collect(); let inner: String = characters[index + 2..end].iter().collect();
output.push_str(&render_concept_link(&inner)); output.push_str(&render_concept_link(&inner, items));
index = end + 2; index = end + 2;
continue; continue;
} }
} }
// A shortcut link, `[`Name`]`, is how a doc comment refers to
// another item without repeating the path. It is rendered as a link
// to the item page when the inner text names a plausible item.
if character == '[' && characters.get(index + 1) == Some(&'`') {
if let Some((label, end)) = parse_shortcut_link(&characters, index) {
if let Some(item) = shortcut_item(&label, items) {
output.push_str(&format!(
"<a class=\"api-link\" data-api=\"{}\" href=\"#/api/{}\"><code>{}</code></a>",
escape_attr(item),
escape_attr(item),
escape(&label),
));
index = end;
continue;
}
}
}
if character == '[' { if character == '[' {
if let Some((label, url, end)) = parse_link(&characters, index) { if let Some((label, url, end)) = parse_link(&characters, index) {
output.push_str(&format!( match rust_path_target(&url, items) {
// A link to a library item becomes a link to the item
// page, which is where the reader can see the signature
// and the rest of the documentation.
Some(item) => output.push_str(&format!(
"<a class=\"api-link\" data-api=\"{}\" href=\"#/api/{}\">{}</a>",
escape_attr(&item),
escape_attr(&item),
inline_items(&label, items),
)),
None => output.push_str(&format!(
"<a href=\"{}\">{}</a>", "<a href=\"{}\">{}</a>",
escape_attr(&url), escape_attr(&url),
inline(&label), inline_items(&label, items),
)); )),
}
index = end; index = end;
continue; continue;
} }
@ -336,14 +389,17 @@ fn inline(text: &str) -> String {
if characters.get(index + 1) == Some(&'*') { if characters.get(index + 1) == Some(&'*') {
if let Some(end) = find_double(&characters, index + 2) { if let Some(end) = find_double(&characters, index + 2) {
let strong: String = characters[index + 2..end].iter().collect(); let strong: String = characters[index + 2..end].iter().collect();
output.push_str(&format!("<strong>{}</strong>", inline(&strong))); output.push_str(&format!(
"<strong>{}</strong>",
inline_items(&strong, items)
));
index = end + 2; index = end + 2;
continue; continue;
} }
} else if let Some(end) = find(&characters, index + 1, '*') { } else if let Some(end) = find(&characters, index + 1, '*') {
if end > index + 1 { if end > index + 1 {
let emphasis: String = characters[index + 1..end].iter().collect(); let emphasis: String = characters[index + 1..end].iter().collect();
output.push_str(&format!("<em>{}</em>", inline(&emphasis))); output.push_str(&format!("<em>{}</em>", inline_items(&emphasis, items)));
index = end + 1; index = end + 1;
continue; continue;
} }
@ -380,6 +436,108 @@ fn find_double(characters: &[char], start: usize) -> Option<usize> {
.find(|index| characters[*index] == '*' && characters[*index + 1] == '*') .find(|index| characters[*index] == '*' && characters[*index + 1] == '*')
} }
/// Parses a shortcut link, `[`text`]`, starting at `start`.
///
/// Returns the text between the backticks and the index just past the
/// closing bracket.
fn parse_shortcut_link(characters: &[char], start: usize) -> Option<(String, usize)> {
if characters.get(start) != Some(&'[') || characters.get(start + 1) != Some(&'`') {
return None;
}
let tick = find(characters, start + 2, '`')?;
if characters.get(tick + 1) != Some(&']') {
return None;
}
let label: String = characters[start + 2..tick].iter().collect();
Some((label, tick + 2))
}
/// Returns the item name a shortcut link refers to.
///
/// A shortcut link names an item, optionally with a module path or a
/// method, such as `Epoch::mjd` or `apsis_time::Epoch`. The item page
/// routes on the item name, so the last segment is used. A label that is
/// not a plausible item name, such as `0, 1` or `-pi/2, pi/2`, is not a
/// link and returns `None`.
fn shortcut_item<'a>(
label: &'a str,
items: Option<&std::collections::HashSet<String>>,
) -> Option<&'a str> {
let name = label.rsplit("::").next()?.trim();
let is_name = !name.is_empty()
&& name
.chars()
.all(|character| character.is_alphanumeric() || character == '_');
if !is_name {
return None;
}
// When the catalog is available the name must be an item of the
// library, which is what keeps a label such as `0, 1` or a phrase from
// becoming a link. Without it, a name that starts with an upper case
// letter is taken as an item, which is the correct guess for a doc
// comment that names another item.
match items {
Some(items) => items.contains(name).then_some(name),
None => name
.chars()
.next()
.is_some_and(char::is_uppercase)
.then_some(name),
}
}
/// Returns the item name of a Rust path written as a link target.
///
/// A doc comment links to a library item with a Rust path, such as
/// `crate::Epoch`, `core::result::Result`, or `f64::rem_euclid`. The name
/// the interface page routes on is the last segment of the path, with any
/// generic arguments removed. A target that is a web address or a fragment
/// is not a Rust path and returns `None`, which leaves the link as written.
///
/// A path may end in a variant or a method rather than the item itself,
/// such as `crate::PropagationErrorKind::Decayed`. The resolver walks the
/// path from the right and returns the first segment that is a known item,
/// so such a link points at the enum or the trait that declares it.
fn rust_path_target(
url: &str,
items: Option<&std::collections::HashSet<String>>,
) -> Option<String> {
if url.is_empty() || url.contains('/') || url.starts_with('#') {
return None;
}
let segments: Vec<&str> = url
.split("::")
.map(|segment| segment.split(['<', '(']).next().unwrap_or("").trim())
.filter(|segment| !segment.is_empty())
.collect();
if segments.is_empty() {
return None;
}
let is_rust_path =
segments.len() > 1 || segments[0].chars().next().is_some_and(char::is_uppercase);
if !is_rust_path {
return None;
}
let is_name = |segment: &str| {
!segment.is_empty()
&& segment
.chars()
.all(|character| character.is_alphanumeric() || character == '_')
};
match items {
Some(items) => segments
.iter()
.rev()
.find(|segment| is_name(segment) && items.contains(**segment))
.map(|segment| (*segment).to_string()),
None => segments
.iter()
.rev()
.find(|segment| is_name(segment))
.map(|segment| (*segment).to_string()),
}
}
/// Parses a `[label](url)` link starting at `start`, returning the label, /// Parses a `[label](url)` link starting at `start`, returning the label,
/// the URL, and the index just past the link. /// the URL, and the index just past the link.
fn parse_link(characters: &[char], start: usize) -> Option<(String, String, usize)> { fn parse_link(characters: &[char], start: usize) -> Option<(String, String, usize)> {
@ -401,7 +559,7 @@ fn parse_link(characters: &[char], start: usize) -> Option<(String, String, usiz
/// The argument is either `id` or `id|label`. An identifier that is not a /// The argument is either `id` or `id|label`. An identifier that is not a
/// plausible identifier is rendered as literal text, so a stray `[[` in the /// plausible identifier is rendered as literal text, so a stray `[[` in the
/// prose cannot produce a broken link. /// prose cannot produce a broken link.
fn render_concept_link(inner: &str) -> String { fn render_concept_link(inner: &str, items: Option<&std::collections::HashSet<String>>) -> String {
let (id, label) = match inner.split_once('|') { let (id, label) = match inner.split_once('|') {
Some((id, label)) => (id.trim(), label.trim()), Some((id, label)) => (id.trim(), label.trim()),
None => (inner.trim(), inner.trim()), None => (inner.trim(), inner.trim()),
@ -417,7 +575,7 @@ fn render_concept_link(inner: &str) -> String {
"<a class=\"concept-link\" data-concept=\"{}\" href=\"#/concept/{}\">{}</a>", "<a class=\"concept-link\" data-concept=\"{}\" href=\"#/concept/{}\">{}</a>",
escape_attr(id), escape_attr(id),
escape_attr(id), escape_attr(id),
inline(label), inline_items(label, items),
) )
} }
@ -569,6 +727,58 @@ mod tests {
assert!(!html.contains("<script>")); assert!(!html.contains("<script>"));
} }
#[test]
fn a_rust_path_link_points_at_the_interface_page() {
let html = render("Returns [`Epoch`](crate::Epoch) and [more](https://example.com).\n");
assert!(html.contains("data-api=\"Epoch\""), "{html}");
assert!(html.contains("href=\"#/api/Epoch\""), "{html}");
// A web address is left as it is.
assert!(html.contains("href=\"https://example.com\""), "{html}");
}
#[test]
fn a_path_to_a_variant_resolves_to_the_declaring_item() {
let known: std::collections::HashSet<String> = ["PropagationErrorKind"]
.iter()
.map(|n| n.to_string())
.collect();
let html = render_with(
"Returns [`Decayed`](crate::PropagationErrorKind::Decayed).\n",
Some(&known),
);
assert!(html.contains("data-api=\"PropagationErrorKind\""), "{html}");
// A path whose segments are all unknown is left as written.
let html = render_with("[`Nope`](crate::Unknown::Missing).\n", Some(&known));
assert!(!html.contains("data-api"), "{html}");
}
/// The names of two items, for the shortcut link test.
fn items() -> std::collections::HashSet<String> {
["mjd", "Epoch"]
.iter()
.map(|name| name.to_string())
.collect()
}
#[test]
fn a_shortcut_link_points_at_the_interface_page() {
let known = items();
let html = render_with(
"See [`Epoch::mjd`] and [`apsis_time::Epoch`].\n",
Some(&known),
);
assert!(html.contains("data-api=\"mjd\""), "{html}");
assert!(html.contains("href=\"#/api/mjd\""), "{html}");
assert!(html.contains("data-api=\"Epoch\""), "{html}");
// A label that is not an item name stays as a code span.
let html = render_with("The range is [`0, 1`].\n", Some(&known));
assert!(!html.contains("data-api"), "{html}");
assert!(html.contains("<code>0, 1</code>"), "{html}");
// A name that is not in the catalog also stays as a code span.
let html = render_with("See [`NotAnItem`].\n", Some(&known));
assert!(!html.contains("data-api"), "{html}");
}
#[test] #[test]
fn renders_concept_links() { fn renders_concept_links() {
let html = render("See [[sgp4]] and [[tle|the element set]].\n"); let html = render("See [[sgp4]] and [[tle|the element set]].\n");

View File

@ -84,13 +84,16 @@ pub fn load_documents() -> Vec<Document> {
.collect() .collect()
} }
/// Parses the embedded interface catalog. /// Parses the embedded interface catalog, applying the translation when
/// one is embedded.
pub fn load_api_catalog() -> ApiCatalog { pub fn load_api_catalog() -> ApiCatalog {
match content::get("api.json") { let Some(text) = content::get("api.json") else {
Some(text) => api::parse(text), return ApiCatalog {
None => ApiCatalog {
crates: Vec::new(), crates: Vec::new(),
generated_from: Vec::new(), generated_from: Vec::new(),
}, item_names: std::collections::HashSet::new(),
} };
};
let translation = content::get("api.zh.json").and_then(|text| crate::json::parse(text).ok());
api::parse_with(text, translation.as_ref())
} }

View File

@ -173,6 +173,46 @@ fn serves_the_concept_and_crate_graphs() {
assert!(body(&flow).contains("\"edges\""), "{}", body(&flow)); assert!(body(&flow).contains("\"edges\""), "{}", body(&flow));
} }
#[test]
fn interface_documentation_is_translated() {
let service = Service::start();
wait_ready(&service);
let item = service.get("/api/api/items/propagate_minutes");
let body = body(&item);
// The Chinese documentation is served and is rendered to HTML.
assert!(body.contains("传播"), "the Chinese doc should be served");
assert!(body.contains("<p>"), "the doc should be rendered as HTML");
// A doc comment link to another item becomes an internal route.
assert!(
body.contains("data-api=") && body.contains("#/api/"),
"the doc link should be resolved: {body}"
);
// The English documentation is still present, so the language can switch
// without another request.
assert!(
body.contains("Returns the state of the satellite"),
"the English doc should still be present"
);
}
#[test]
fn crate_summaries_are_translated() {
let service = Service::start();
wait_ready(&service);
let response = service.get("/api/api/crates/apsis-sgp4");
let body = body(&response);
// The Chinese summary and its plain form are both served.
assert!(
body.contains("传播器"),
"the Chinese summary should be served"
);
assert!(
body.contains("\"plain\""),
"the plain form should be served"
);
}
#[test] #[test]
fn serves_the_interface_catalog() { fn serves_the_interface_catalog() {
let service = Service::start(); let service = Service::start();