feat(viewer): Markdown and plain-text documents are drawn as text
check / check (push) Canceled after 0s

A text/markdown file (or text/x-markdown) is drawn as formatted text by
egui_commonmark, with no image loading, so a document never makes the
viewer read a file or the network; any other text/* type as its
selectable text. UTF-8 only (a leading byte-order mark dropped), at most
2 MiB; past that or not UTF-8, the viewer says why. The documents page's
tile shows the text's first lines. The SHA-256 row is cut to its column
(it ran off the panel and widened the page under it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-23 09:05:32 -06:00
1 parent d64e0e9cdf
commit 7cc08a0039
6 files changed
+263 -7

No files matched your search

+3
View File
@@ -21,6 +21,9 @@ eframe = { version = "0.36", default-features = false, features = ["default_font
egui = "0.36"
egui_extras = { version = "0.36", features = ["image", "datepicker", "serde"] }
egui-phosphor = "0.14"
# Markdown documents, drawn as text. No image loading (no `load-images`,
# no `fetch`): a document never makes the viewer read a file or the network.
egui_commonmark = { version = "0.25", default-features = false, features = ["pulldown_cmark"] }
hayro = { version = "0.8", features = ["embed-fonts"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
notify = "8"
+13 -1
View File
@@ -157,6 +157,18 @@ fn tile(
.image(tex.id(), img_rect, uv, egui::Color32::WHITE);
}
}
Render::Text { text, .. } => {
// The first lines of the text, as written, clipped to the thumb.
let head: String = text.lines().take(14).collect::<Vec<_>>().join("\n");
let galley =
ui.painter()
.layout(head, theme::regular(10.5), p.muted, thumb.width() - 16.0);
ui.painter().with_clip_rect(thumb.shrink(4.0)).galley(
thumb.min + vec2(8.0, 8.0),
galley,
p.muted,
);
}
Render::Loading => {
ui.painter().text(
thumb.center(),
@@ -273,7 +285,7 @@ fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) {
ui.label(fmt::timestamp(&a.created_at));
ui.end_row();
k(ui, "SHA-256");
widgets::copyable(
widgets::copyable_truncated(
ui,
RichText::new(&a.blob_sha).monospace().size(11.5),
&a.blob_sha,
+24
View File
@@ -631,6 +631,30 @@ pub fn render(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>, wid
.color(p.negative),
);
}
Render::Text { text, markdown } => {
let (text, markdown) = (text.clone(), *markdown);
egui::Frame::new()
.fill(p.surface)
.stroke(Stroke::new(1.0, p.border))
.corner_radius(CornerRadius::same(6))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(width - 30.0);
if markdown {
egui_commonmark::CommonMarkViewer::new()
.max_image_width(Some((width - 30.0) as usize))
.show(ui, &mut shell.receipts.markdown, &text);
} else {
ui.add(
egui::Label::new(
RichText::new(text).font(egui::FontId::monospace(12.0)),
)
.selectable(true)
.wrap(),
);
}
});
}
Render::Ready(pages) => {
for (i, tex) in pages.iter().enumerate() {
let size = tex.size_vec2();
+65 -6
View File
@@ -1,7 +1,9 @@
//! Rendering receipts and documents to textures, off the UI thread.
//!
//! PDFs are rasterized with `hayro` (pure Rust), images decoded with
//! `image`. Results are cached by blob hash: the same bytes never render
//! `image`; Markdown and plain text are read as UTF-8 and drawn as text
//! (Markdown by `egui_commonmark`), so they follow the theme and select
//! like any text. Results are cached by blob hash: the same bytes never render
//! twice, and a hash is the content, so the cache never goes stale. Bytes
//! are read through the verifying blob store: a corrupt blob shows as an
//! error, never as a picture.
@@ -15,9 +17,24 @@ use std::sync::mpsc::{Receiver, Sender, channel};
pub enum State {
Loading,
Ready(Vec<TextureHandle>),
/// A text file: its UTF-8 text, and whether it is Markdown.
Text {
text: String,
markdown: bool,
},
Failed(String),
}
/// What a render made: pages as pictures, or a text file's text.
enum Rendered {
Pages(Vec<ColorImage>),
Text { text: String, markdown: bool },
}
/// The largest text file shown as text: past it the viewer says so
/// rather than lay out megabytes of text every frame.
pub const MAX_TEXT_BYTES: usize = 2 * 1024 * 1024;
struct Job {
sha: String,
mime: String,
@@ -26,11 +43,13 @@ struct Job {
struct Done {
sha: String,
result: Result<Vec<ColorImage>, String>,
result: Result<Rendered, String>,
}
pub struct Receipts {
cache: HashMap<String, State>,
/// egui_commonmark's layout cache, shared by every Markdown file.
pub markdown: egui_commonmark::CommonMarkCache,
jobs: Sender<Job>,
done: Receiver<Done>,
}
@@ -68,6 +87,7 @@ impl Receipts {
}
Self {
cache: HashMap::new(),
markdown: egui_commonmark::CommonMarkCache::default(),
jobs,
done,
}
@@ -95,7 +115,8 @@ impl Receipts {
fn accept(&mut self, ctx: &Context, done: Done) {
let state = match done.result {
Ok(images) => State::Ready(
Ok(Rendered::Text { text, markdown }) => State::Text { text, markdown },
Ok(Rendered::Pages(images)) => State::Ready(
images
.into_iter()
.enumerate()
@@ -129,21 +150,59 @@ impl Receipts {
}
}
fn render(job: &Job) -> Result<Vec<ColorImage>, String> {
fn render(job: &Job) -> Result<Rendered, String> {
let bytes = BlobStore::open_read_only(&job.root)
.get(&job.sha)
.map_err(|e| e.to_string())?;
let is_pdf = job.mime == "application/pdf" || bytes.starts_with(b"%PDF");
if is_pdf {
render_pdf(bytes)
render_pdf(bytes).map(Rendered::Pages)
} else if job.mime.starts_with("image/") {
let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?;
Ok(vec![to_color_image(img.to_rgba8())])
Ok(Rendered::Pages(vec![to_color_image(img.to_rgba8())]))
} else if let Some(markdown) = text_kind(&job.mime) {
let text = text_of(bytes)?;
Ok(Rendered::Text { text, markdown })
} else {
Err(format!("{} can't be previewed", job.mime))
}
}
/// Whether a media type is text the viewer shows as text: `Some(true)`
/// for Markdown (`text/markdown`, RFC 7763, and the older
/// `text/x-markdown`), `Some(false)` for other `text/*` types, `None`
/// otherwise. Parameters (`; charset=utf-8`) and case are ignored.
pub fn text_kind(mime: &str) -> Option<bool> {
let essence = mime
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
match essence.as_str() {
"text/markdown" | "text/x-markdown" => Some(true),
t if t.starts_with("text/") => Some(false),
_ => None,
}
}
/// A text file's text: UTF-8 (a leading byte-order mark dropped), at most
/// [`MAX_TEXT_BYTES`]; anything else is refused with why, never shown
/// half-decoded.
pub fn text_of(bytes: Vec<u8>) -> Result<String, String> {
if bytes.len() > MAX_TEXT_BYTES {
return Err(format!(
"the text is {} bytes, more than the {MAX_TEXT_BYTES} shown",
bytes.len()
));
}
let mut text = String::from_utf8(bytes).map_err(|_| "the text isn't UTF-8".to_string())?;
if text.starts_with('\u{feff}') {
text.drain(..'\u{feff}'.len_utf8());
}
Ok(text)
}
fn render_pdf(bytes: Vec<u8>) -> Result<Vec<ColorImage>, String> {
use hayro::hayro_interpret::InterpreterSettings;
use hayro::hayro_syntax::Pdf;
+123
View File
@@ -0,0 +1,123 @@
//! Text documents in the viewer: a Markdown file is drawn as formatted
//! text, a plain-text file as its text, both on the document's page and
//! on its tile; a text file that isn't UTF-8 says so rather than show
//! half of it.
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use ledger_core::Currency;
use ledger_gui::nav::Route;
use ledger_gui::{App, Source, data};
use ledger_store::Store;
use std::path::PathBuf;
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> TempDir {
let d = std::env::temp_dir().join(format!("ledger-gui-text-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
TempDir(d)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
const PLAN: &str = "\u{feff}# Studio plan\n\nThe studio's goal is **ten apps** that earn.\n\n\
- Chess openings\n- Route planner\n";
fn open(dir: &TempDir, route: Route) -> Harness<'static, App> {
let books = data::load_once(&Source {
data_dir: dir.0.join("books-data"),
today: Some(chrono::NaiveDate::from_ymd_opt(2026, 10, 23).unwrap()),
})
.unwrap();
let mut h = Harness::builder()
.with_size(egui::vec2(1100.0, 900.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, route));
h.run_steps(3);
let ctx = h.ctx.clone();
h.state_mut().settle(&ctx);
h.run_steps(3);
h
}
/// SHOT_DIR set: the page as drawn, to look at.
fn shot(h: &mut Harness<'static, App>, name: &str) {
if let Ok(out) = std::env::var("SHOT_DIR") {
let out = PathBuf::from(out);
std::fs::create_dir_all(&out).unwrap();
h.render()
.unwrap()
.save(out.join(format!("{name}.png")))
.unwrap();
}
}
#[test]
fn a_markdown_document_is_drawn_as_formatted_text() {
let dir = TempDir::new("md");
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
let (md, _, _) = s
.file_document(
"other",
"plan.md",
"text/markdown",
PLAN.as_bytes(),
Some("Plan for the studio"),
)
.unwrap();
drop(s);
let mut h = open(&dir, Route::Document(md));
shot(&mut h, "markdown-document");
// The heading is its own text, without the "#" or the byte-order mark;
// the list items are there; nothing says it can't be shown.
assert!(h.query_by_label("Studio plan").is_some());
assert!(h.query_by_label_contains("# Studio").is_none());
assert!(h.query_by_label_contains("Chess openings").is_some());
assert!(h.query_by_label_contains("Route planner").is_some());
assert!(h.query_by_label_contains("can't be previewed").is_none());
assert!(h.query_by_label_contains("Can't show").is_none());
}
#[test]
fn a_plain_text_document_is_its_text_and_bad_text_says_so() {
let dir = TempDir::new("txt");
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
let (txt, _, _) = s
.file_document(
"other",
"notes.txt",
"text/plain; charset=utf-8",
b"Meeting notes\nall agreed",
None,
)
.unwrap();
let (bad, _, _) = s
.file_document("other", "bad.md", "text/markdown", b"caf\xe9", None)
.unwrap();
drop(s);
let h = open(&dir, Route::Document(txt));
assert!(h.query_by_label_contains("Meeting notes").is_some());
assert!(h.query_by_label_contains("Can't show").is_none());
let h = open(&dir, Route::Document(bad));
assert!(h.query_by_label_contains("the text isn't UTF-8").is_some());
}
#[test]
fn the_documents_page_draws_text_tiles() {
let dir = TempDir::new("tiles");
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
s.file_document("other", "plan.md", "text/markdown", PLAN.as_bytes(), None)
.unwrap();
drop(s);
let mut h = open(&dir, Route::Documents);
h.run_steps(2);
shot(&mut h, "text-tiles");
assert_eq!(h.state_mut().route(), &Route::Documents);
}