10 / 10 · 20 分钟
导出 HTML
浏览器打开 lantern.html,能看到这五天和那句咖啡还差 70 g。
你的项目里会动到:src/report.rs · src/lib.rs · src/main.rs
最后新建 src/report.rs。render_html 返回一整份 HTML,CSS 写在文件里面,打开时不用联网。main 只负责把它写到 --out,默认文件名 lantern.html。
笔记是人写的。如果里面有 < 或 &,直接拼进 HTML 会变成标记。每个插进页面的字符串都先过 escape_html。这一份已经写好,当作对照,不是作业。
对照
把笔记里的标记变成文本
确认四个字符被换掉:& < > "。其他字符原样留下。这一章没有要你补的空函数。写完 report.rs 之后,用浏览器把文件打开。
按字符 match。& 放在同一次扫描里换成 &,不会被再转一次。
report.rs 保存之后,再在 lib.rs 末尾加上下面这一行,并在 Commands 里加上 Report。加上变体以后,run 里的 match 必须处理它,否则编不过。
加在 pub mod store; 后面。
pub mod report;你的 lib.rs 到这里结束。没有 summary。
pub mod briefing;
pub mod error;
pub mod model;
pub mod report;
pub mod sample;
pub mod stats;
pub mod store;use crate::briefing::{coffee_outlook, coffee_sentence, date_range, CoffeeOutlook};
use crate::model::{terrain_label, weather_label, Journal};
use crate::stats::{
average_pace_kmh, format_hours, format_km, format_pace, moving_minutes, total_distance_m,
total_elevation_m,
};
const CSS: &str = r#"
:root {
color-scheme: light;
--paper: #f4efe4;
--ink: #1c1915;
--soft: #5e564c;
--line: #ddd2c0;
--pine: #1e4636;
--lantern: #b8431f;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: "Iowan Old Style", Palatino, "Palatino Linotype", Georgia, serif;
line-height: 1.55;
}
article { max-width: 42rem; margin: 0 auto; padding: 3rem 1.25rem 4rem; }
.kicker {
margin: 0;
letter-spacing: 0.14em;
text-transform: uppercase;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.72rem;
color: var(--pine);
}
h1 { font-size: 2.6rem; line-height: 1.05; font-weight: 560; margin: 0.4rem 0 0.3rem; }
.range { margin: 0; color: var(--soft); }
dl {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.8rem 1rem;
margin: 1.6rem 0;
padding: 1rem 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
div { display: contents; }
dt { font-family: "IBM Plex Mono", ui-monospace, monospace; font-size: 0.72rem; color: var(--soft); }
dd { margin: 0.15rem 0 0; font-size: 1.25rem; }
.callout { padding: 0.8rem 1rem; border-left: 3px solid var(--pine); background: #efe7d8; }
.callout.short { border-color: var(--lantern); }
ol { list-style: none; padding: 0; margin: 1.5rem 0 0; }
li { padding: 1.1rem 0; border-top: 1px solid var(--line); }
li header { display: flex; justify-content: space-between; gap: 1rem; align-items: baseline; }
h2 { margin: 0; font-size: 1.35rem; font-weight: 560; }
.date, .nums, footer { font-family: "IBM Plex Mono", ui-monospace, monospace; }
.date, .weather { color: var(--soft); font-size: 0.85rem; }
.nums { margin: 0.35rem 0; font-size: 0.82rem; color: var(--soft); }
.note { margin: 0.2rem 0 0; }
footer { margin-top: 2rem; color: var(--soft); font-size: 0.75rem; }
@media (max-width: 640px) {
dl { grid-template-columns: repeat(2, 1fr); }
h1 { font-size: 2.1rem; }
}
"#;
// region: escape_html
pub fn escape_html(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
_ => out.push(ch),
}
}
out
}
// endregion: escape_html
pub fn render_html(journal: &Journal) -> String {
let days = journal.days();
let pace = average_pace_kmh(days)
.map(|value| format!("{value:.1} km/h"))
.unwrap_or_else(|| "没有移动".to_string());
let coffee = coffee_sentence(
journal.coffee_grams,
journal.coffee_per_day,
days.len() as u32,
);
let coffee_class = match coffee_outlook(
journal.coffee_grams,
journal.coffee_per_day,
days.len() as u32,
) {
CoffeeOutlook::Short { .. } => "callout short",
CoffeeOutlook::Enough { .. } => "callout",
};
let mut body = String::new();
for day in days {
body.push_str(&format!(
"<li><header><div><p class=\"date\">{date}</p><h2>{title}</h2></div><p class=\"weather\">{weather}</p></header><p class=\"nums\">{distance} km · 爬升 {elevation} m · {hours} · {pace} · {terrain} · {meals} 顿</p><p class=\"note\">{note}</p></li>\n",
date = escape_html(&day.date),
title = escape_html(&day.title),
weather = escape_html(weather_label(day.weather)),
distance = format_km(day.distance_m),
elevation = day.elevation_m,
hours = format_hours(day.minutes),
pace = format_pace(day.distance_m, day.minutes),
terrain = escape_html(terrain_label(day.terrain)),
meals = day.meals,
note = escape_html(&day.note),
));
}
format!(
r#"<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} · 野外报告</title>
<style>{css}</style>
</head>
<body>
<article>
<p class="kicker">fieldlog</p>
<h1>{title}</h1>
<p class="range">{range}</p>
<dl>
<div><dt>里程</dt><dd>{distance} km</dd></div>
<div><dt>爬升</dt><dd>{elevation} m</dd></div>
<div><dt>移动</dt><dd>{hours}</dd></div>
<div><dt>均速</dt><dd>{pace}</dd></div>
<div><dt>口粮剩余</dt><dd>{rations} 顿</dd></div>
<div><dt>天数</dt><dd>{count}</dd></div>
</dl>
<p class="{coffee_class}">{coffee}</p>
<ol>
{body}</ol>
<footer>由 fieldlog 从日志生成。数字来自记录,不是估计。</footer>
</article>
</body>
</html>
"#,
title = escape_html(&journal.expedition),
css = CSS,
range = escape_html(&date_range(journal)),
distance = format_km(total_distance_m(days)),
elevation = total_elevation_m(days),
hours = escape_html(&format_hours(moving_minutes(days))),
pace = escape_html(&pace),
rations = journal.rations_remaining(),
count = days.len(),
coffee_class = coffee_class,
coffee = escape_html(&coffee),
body = body,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{DayLog, Journal, Terrain, Weather};
use crate::sample::lantern_traverse;
#[test]
fn escapes_notes_before_they_become_markup() {
assert_eq!(
escape_html(r#"<b>炉头 & "茶"</b>"#),
"<b>炉头 & "茶"</b>"
);
}
#[test]
fn lantern_report_shows_the_shortage() {
let html = render_html(&lantern_traverse());
assert!(html.contains("<h1>灯塔山脊</h1>"));
assert!(html.contains("灯塔石"));
assert!(html.contains("还差 70 g"));
assert!(html.contains("47.2 km"));
}
#[test]
fn report_escapes_titles_and_notes() {
let mut journal = Journal::new("灯塔山脊", 16, 80, 30).unwrap();
journal
.add(DayLog {
date: "2026-09-23".to_string(),
title: "<出口>".to_string(),
distance_m: 1000,
minutes: 30,
elevation_m: 10,
weather: Weather::Clear,
terrain: Terrain::Trail,
meals: 1,
note: "a < b & c".to_string(),
})
.unwrap();
let html = render_html(&journal);
assert!(html.contains("<出口>"));
assert!(html.contains("a < b & c"));
assert!(!html.contains("<出口>"));
assert!(!html.contains("a < b"));
}
}Commands 里多了 Report。
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use fieldlog::briefing::{Briefing, NarrativeBrief, TerseBrief};
use fieldlog::error::FieldError;
use fieldlog::model::Journal;
use fieldlog::report::render_html;
use fieldlog::sample::lantern_traverse;
use fieldlog::store::{self, load};
#[derive(Parser)]
#[command(
name = "fieldlog",
version,
about = "把一次穿越记成可以测试的野外日志",
arg_required_else_help = true
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
// region: commands
#[derive(Subcommand)]
enum Commands {
/// 一行简报。省略 --file 时使用灯塔山脊样例
Brief {
#[arg(short, long)]
file: Option<PathBuf>,
},
/// 叙述简报
Story {
#[arg(short, long)]
file: Option<PathBuf>,
},
/// 写出 HTML 野外报告
Report {
#[arg(short, long)]
file: Option<PathBuf>,
/// 输出路径
#[arg(short, long, default_value = "lantern.html")]
out: PathBuf,
},
/// 把灯塔山脊样例写成 JSON
Export {
#[arg(short, long, default_value = "lantern.json")]
out: PathBuf,
},
/// 检查日志能否通过不变量
Check {
#[arg(short, long)]
file: Option<PathBuf>,
},
}
// endregion: commands
fn main() -> ExitCode {
match run(Cli::parse()) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("fieldlog: {err}");
ExitCode::from(1)
}
}
}
fn run(cli: Cli) -> Result<(), FieldError> {
match cli.command {
Commands::Brief { file } => {
let journal = open(file.as_deref())?;
print!("{}", TerseBrief.render(&journal));
}
Commands::Story { file } => {
let journal = open(file.as_deref())?;
print!("{}", NarrativeBrief.render(&journal));
}
Commands::Report { file, out } => {
let journal = open(file.as_deref())?;
let html = render_html(&journal);
std::fs::write(&out, html).map_err(|err| FieldError::Io(err.to_string()))?;
println!("已写出 {}", out.display());
}
Commands::Export { out } => {
store::save(&lantern_traverse(), &out)?;
println!("已写出 {}", out.display());
}
Commands::Check { file } => {
let journal = open(file.as_deref())?;
println!(
"ok {} {} 天 口粮剩余 {}",
journal.expedition,
journal.days().len(),
journal.rations_remaining()
);
}
}
Ok(())
}
fn open(file: Option<&Path>) -> Result<Journal, FieldError> {
match file {
Some(path) => load(path),
None => Ok(lantern_traverse()),
}
}cargo test
cargo run -- report --out lantern.html用浏览器打开 lantern.html。页面上有灯塔山脊、五天的标题、47.2 km,以及咖啡还差 70 g。仓库里的对照程序还会生成网站用的摘要,那一步不在这十章里。
检查点
cargo test cargo run -- report --out lantern.html
全部测试通过。lantern.html 里有 <h1>灯塔山脊</h1>,也有「还差 70 g」。