feat(viewer): Markdown and plain-text documents are drawn as text
check / check (push) Canceled after 0s
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:
6 files changed
+263
-7
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user