rust101-fieldlog

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。& 放在同一次扫描里换成 &amp;,不会被再转一次。

report.rs 保存之后,再在 lib.rs 末尾加上下面这一行,并在 Commands 里加上 Report。加上变体以后,run 里的 match 必须处理它,否则编不过。

加在 pub mod store; 后面。

src/lib.rs
pub mod report;

你的 lib.rs 到这里结束。没有 summary。

src/lib.rs
pub mod briefing;
pub mod error;
pub mod model;
pub mod report;
pub mod sample;
pub mod stats;
pub mod store;
src/report.rs
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("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            _ => 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>"#),
            "&lt;b&gt;炉头 &amp; &quot;茶&quot;&lt;/b&gt;"
        );
    }

    #[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("&lt;出口&gt;"));
        assert!(html.contains("a &lt; b &amp; c"));
        assert!(!html.contains("<出口>"));
        assert!(!html.contains("a < b"));
    }
}

Commands 里多了 Report。

src/main.rs
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。仓库里的对照程序还会生成网站用的摘要,那一步不在这十章里。

检查点

在你的 fieldlog 目录
cargo test
cargo run -- report --out lantern.html

全部测试通过。lantern.html 里有 <h1>灯塔山脊</h1>,也有「还差 70 g」。