This commit is contained in:
Mark
2026-03-24 22:44:54 +01:00
commit da22776f24
10 changed files with 1653 additions and 0 deletions

543
src/to/html.rs Normal file
View File

@@ -0,0 +1,543 @@
use std::{borrow::Cow, collections::BTreeMap, process::Command};
use crate::{
doc::{Component, Document, Section, Text},
to::{Spec, SpecsExt},
};
pub struct State<'a> {
pub head_to_body: &'a str,
cache_docs: BTreeMap<&'a Document<'a>, String>,
cache_secs: BTreeMap<&'a Section<'a>, String>,
}
impl<'a> State<'a> {
pub fn new() -> Self {
State {
head_to_body: "</head>\n<body>",
cache_docs: Default::default(),
cache_secs: Default::default(),
}
}
}
pub fn page<'a>(doc: &'a Document<'a>, specs: &[impl Spec], state: &mut State<'a>) -> String {
let mut out = String::new();
page_to(doc, specs, state, &mut out);
out
}
pub fn page_to<'a>(
doc: &'a Document<'a>,
specs: &[impl Spec],
state: &mut State<'a>,
out: &mut String,
) {
let doc_start = format!(
r##"<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="color-scheme" content="light dark">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{}</title>
<style>
@media (prefers-color-scheme: light) {{ :root {{ --bg: #FFF; --bg2: #FFF4F4; --fg: #000; }} }}
@media (prefers-color-scheme: dark) {{ :root {{ --bg: #070002; --bg2: #070010; --fg: #E0B8A0; }} }}
:root {{ color: var(--fg); background: var(--bg);
--fgl: color-mix(var(--fg), var(--bg) 50%); --fgd: color-mix(var(--fg), var(--bg) 65%); --fgt: color-mix(var(--fg), var(--bg) 80%);
--fsmall: 66%; --fless: 77%; --fmore: 110%; --ftitle: 132%; }}
/*
.x: actual content (incl. titles)
.y: section elements except sub and title
.z: section elements
*/
.doc.d0 {{
margin-left: auto;
margin-right: auto;
max-width: 90%;
width: fit-content;
}}
.z {{
width: fit-content;
max-width: 100%;
}}
.z {{
margin-bottom: 0.5em;
}}
.sub > .doc, .doc > .doc {{
margin-left: 0.15em;
border-left: 2pt solid;
padding-left: 0.6em;
border-image: linear-gradient(to bottom, var(--fgl), var(--fgt)) 1;
}}
.doc:has(> .doc) {{
border-image: linear-gradient(to bottom, var(--fgd), transparent) 1;
}}
.y * {{
margin-top: 0;
margin-bottom: 0;
}}
.x {{
white-space: pre-wrap;
}}
.doc {{
white-space: normal;
}}
.y.d0 .x {{
font-size: var(--fless);
}}
.z.title.d0 .x {{
font-size: var(--fmore);
}}
.z.title:not(.d0) .x {{
font-size: var(--ftitle);
}}
.z.sub {{
margin-top: 1em;
}}
.z.title .lnpt {{
color: var(--fgl);
font-size: var(--fsmall);
}}
.z.title .lnpt::before {{
font-size: var(--fless);
content: " / ";
}}
.y.list ul {{
padding-left: 1em;
}}
.y.code .head {{
color: var(--fgl);
font-size: var(--fsmall);
}}
.y.code .head::before {{
content: "#! ";
font-size: var(--fless);
}}
.y.quote .head {{
color: var(--fgl);
font-size: var(--fless);
}}
.y.quote .tail {{
color: var(--fgl);
font-size: var(--fsmall);
text-align: right;
}}
.y.quote .head::before {{
content: "@ ";
font-size: var(--fless);
}}
.y.quote:not(.c-) .tail::before {{
content: " (";
font-size: var(--fless);
}}
.y.quote:not(.c-) .tail::after {{
content: ") ";
font-size: var(--fless);
}}
.y.quote.c- .x::after {{
color: var(--fgl);
font-size: var(--fsmall);
content: "”";
}}
.y.ext.spec .head {{
color: var(--fgl);
}}
.y.ext.nospec .head {{
color: red;
}}
.y.ext .head {{
font-size: var(--fsmall);
}}
.y.ext .head::before {{
content: "#/ ";
font-size: var(--fless);
}}
.y.ext.spec.e-html .head {{
display: none;
}}
.y.ext.spec.e-texm .head {{
display: none;
}}
.link {{
color: var(--fg);
text-decoration: underline var(--fgl);
position: relative;
}}
.linkwpreview:has(> .link:hover) .lnpreview {{
display: block;
}}
.lnpreview:hover {{
display: block;
}}
.linkwpreview:has(> .link:active) .lnpreview {{
display: none;
}}
.lnpreview {{
display: none;
z-index: 2;
position: absolute;
max-width: 70%;
max-height: 40%;
overflow-y: hidden;
background: var(--bg2);
padding: 1em;
border: 2pt solid var(--fgd);
}}
.note, .lnref {{
font-size: var(--fsmall);
}}
</style>
{}
"##,
escape(&crate::to::plain::text_minimal(&doc.title)),
state.head_to_body,
);
if out.is_empty() {
*out = doc_start;
} else {
out.push_str(&doc_start);
}
doc_to(doc, specs, out, state);
out.push_str("</body>\n</html>\n");
}
struct DocArgs<'a, 'b> {
state: &'b mut State<'a>,
root: &'a Document<'a>,
depth: usize,
max_len: Option<usize>,
}
fn doc_to<'a>(doc: &'a Document<'a>, specs: &[impl Spec], out: &mut String, state: &mut State<'a>) {
doc_to_impl(
doc,
specs,
out,
&mut DocArgs {
state,
root: doc,
depth: 0,
max_len: None,
},
)
}
fn doc_preview_to<'a>(
doc: &'a Document<'a>,
root: &'a Document<'a>,
specs: &[impl Spec],
out: &mut String,
state: &mut State<'a>,
) {
doc_to_impl(
doc,
specs,
out,
&mut DocArgs {
state,
root,
depth: doc.depth,
max_len: Some(1024),
},
)
}
fn doc_to_impl<'a>(
doc: &'a Document<'a>,
specs: &[impl Spec],
out: &mut String,
doc_args: &mut DocArgs<'a, '_>,
) {
if let Some(prev) = doc_args.state.cache_docs.get(doc) {
out.push_str(prev);
return;
}
let out_start = out.len();
let depth = doc_args.depth;
let end_at = doc_args.max_len.map(|len| out.len() + len);
for d in depth..=doc.depth {
out.push_str(&format!(r#"<div class="doc d{d}">{}"#, '\n'));
}
if !doc.title.is_empty() {
let a = if !doc.link.is_empty() && doc_args.max_len.is_none() {
let link = doc.link.replace(char::is_whitespace, "-");
let link = escape_quot(&link);
format!(
r##"<a class="lnpt" name="{}" href="#{}">{}</a>"##,
link,
link,
escape(doc.link),
)
} else {
String::new()
};
out.push_str(&format!(
r#"<div class="z d{} title"><span class="x">"#,
doc.depth,
));
text_to(&doc.title, specs, out, doc_args);
out.push_str(&format!("</span>{a}</div>\n"));
}
for section in &doc.sections {
if let Some(prev) = doc_args.state.cache_secs.get(section) {
out.push_str(prev);
continue;
}
let section_start = out.len();
if end_at.is_some_and(|len| out.len() > len) {
break;
}
out.push_str(&format!(
r#"<div class="z d{} {}"#,
doc.depth,
match section {
Section::Paragraph(..) => "y par",
Section::List(..) => "y list",
Section::Code(..) => "y code",
Section::Quote(..) => "y quote",
Section::Ext(..) => "y ext",
Section::Document(..) => "sub",
},
));
match section {
Section::Paragraph(text) => {
out.push_str(r#""><div class="x">"#);
text_to(text, specs, out, doc_args);
out.push_str("</div>");
}
Section::List(list) => {
out.push_str(r#""><ul class="x">"#);
for elem in list {
out.push_str("<li>");
text_to(elem, specs, out, doc_args);
out.push_str("</li>");
}
out.push_str("</ul>");
}
Section::Code(lang, text) => {
out.push_str(&format!(
r#" l-{}"><div class="head">{}</div><pre class="x">"#,
escape_quot(&lang.replace(char::is_whitespace, "-")),
escape(lang),
));
text_to(text, specs, out, doc_args);
out.push_str("</pre>");
}
Section::Quote(attributed, context, text) => {
out.push_str(&format!(
r#" a-{} c-{}"><div class="head">{}</div><div class="x">"#,
escape_quot(&attributed.replace(char::is_whitespace, "-")),
escape_quot(&context.replace(char::is_whitespace, "-")),
escape(attributed),
));
text_to(text, specs, out, doc_args);
out.push_str(&format!(
r#"</div><div class="tail">{}</div>"#,
escape(context),
));
}
Section::Ext(spec @ "html", source) if specs.has(spec) => {
out.push_str(&format!(
r#" s3 spec e-{}"><div class="head">{}</div><div class="x">"#,
escape_quot(spec),
escape(spec),
));
out.push_str(source);
out.push_str("</div>");
}
Section::Ext(spec @ "texm", source) if specs.has(spec) => {
let l = out.len() + 2;
out.push_str(&format!(
r#" s0 spec e-{}"><div class="head">{}</div><div class="x">"#,
escape_quot(spec),
escape(spec),
));
let status = texm_to(source, out);
out.replace_range(l..=l, status);
out.push_str("</div>");
}
Section::Ext(spec, source) => {
out.push_str(&format!(
r#" nospec e-{}"><div class="head">{}</div><div class="x">"#,
escape_quot(spec),
escape(spec),
));
out.push_str(&escape(source));
out.push_str("</div>");
}
Section::Document(inner) => {
out.push_str(r#"">"#);
let mut args = DocArgs {
state: doc_args.state,
root: doc_args.root,
max_len: doc_args.max_len.map(|v| v / 2),
depth: doc.depth + 1,
};
doc_to_impl(inner, specs, out, &mut args);
}
}
out.push_str("</div>\n");
doc_args
.state
.cache_secs
.insert(section, out[section_start..].to_owned());
}
for _ in depth..=doc.depth {
out.push_str("</div>\n");
}
doc_args
.state
.cache_docs
.insert(doc, out[out_start..].to_owned());
}
fn text_to<'a: 'b, 'b>(
text: &'a Text<'a>,
specs: &[impl Spec],
out: &mut String,
args: &mut DocArgs<'a, 'b>,
) {
for component in &text.0 {
match component {
Component::Char(ch) => escape_to(*ch, out),
Component::Text(text) => out.push_str(escape(text).as_ref()),
Component::Note(text) => {
out.push_str(r#"<small class="note">"#);
text_to(text, specs, out, args);
out.push_str("</small>");
}
Component::Wrong(text) => {
out.push_str(r#"<del class="wrong">"#);
text_to(text, specs, out, args);
out.push_str("</del>");
}
Component::Special(text) => {
out.push_str(r#"<u class="special">"#);
text_to(text, specs, out, args);
out.push_str("</u>");
}
Component::Emphasis(text) => {
out.push_str(r#"<em class="emphasis">"#);
text_to(text, specs, out, args);
out.push_str("</em>");
}
Component::Important(text) => {
out.push_str(r#"<strong class="important">"#);
text_to(text, specs, out, args);
out.push_str("</strong>");
}
Component::Link(target, text) => {
let link_target: Option<&'a Document<'a>> = args
.max_len
.is_none()
.then(|| args.root.find_link_target(target))
.flatten();
if link_target.is_some() {
out.push_str(r#"<span class="linkwpreview">"#);
}
out.push_str(&format!(
r##"<a class="link" href="#{}">"##,
escape_quot(&target.replace(char::is_whitespace, "-")),
));
text_to(text, specs, out, args);
out.push_str(&format!(
r#"<small class="lnref">[{}]</small>"#,
escape(target),
));
out.push_str("</a>");
if let Some(doc) = link_target {
out.push_str(r#"<div class="lnpreview">"#);
doc_preview_to(doc, args.root, specs, out, args.state);
out.push_str("</div></span>");
}
}
Component::Ext(spec, source) => match if specs.has(spec) { *spec } else { "" } {
"html" => {
out.push_str(&format!(
r#"<span class="s3 spec e-{}">"#,
escape_quot(spec)
));
out.push_str(source);
out.push_str("</span>");
}
"texm" => {
out.push_str(r#"<span class="s"#);
let l = out.len();
out.push_str(&format!(r#"0 spec e-{}">"#, escape_quot(spec)));
let status = texm_to(source, out);
out.replace_range(l..=l, status);
out.push_str("</span>");
}
_ => {
out.push_str("?[");
out.push_str(&escape(spec));
out.push(':');
out.push_str(source);
out.push_str(&escape(source));
out.push_str("]?");
}
},
}
}
}
fn escape_to(ch: char, out: &mut String) {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
ch => out.push(ch),
}
}
fn escape(str: &str) -> Cow<'_, str> {
let mut out = Cow::Borrowed(str);
if out.contains('&') {
out = Cow::Owned(out.replace('&', "&amp;"));
}
if out.contains('<') {
out = Cow::Owned(out.replace('<', "&lt;"));
}
if out.contains('>') {
out = Cow::Owned(out.replace('<', "&gt;"));
}
out
}
fn escape_quot(str: &str) -> Cow<'_, str> {
let mut out = Cow::Borrowed(str);
if out.contains('"') {
out = Cow::Owned(out.replace('"', "\\\""));
}
if out.contains('\'') {
out = Cow::Owned(out.replace('\'', "\\'"));
}
out
}
fn texm_to(source: &str, out: &mut String) -> &'static str {
if let Ok(res) = Command::new("latex2mathml").args(["-t", source]).output() {
if res.status.success() {
out.push_str(String::from_utf8_lossy(&res.stdout).trim());
"5"
} else {
out.push('$');
out.push_str(&escape(source));
out.push_str("$[");
out.push_str(&escape(String::from_utf8_lossy(&res.stderr).trim()));
out.push(']');
"1"
}
} else {
out.push('$');
out.push_str(&escape(source));
out.push('$');
"1"
}
}

13
src/to/mod.rs Normal file
View File

@@ -0,0 +1,13 @@
pub mod html;
pub mod plain;
pub trait Spec: AsRef<str> {}
pub trait SpecsExt {
fn has(&self, spec: &str) -> bool;
}
impl<T: Spec> SpecsExt for [T] {
fn has(&self, spec: &str) -> bool {
self.iter().any(|s| s.as_ref() == spec)
}
}
impl<T: AsRef<str>> Spec for T {}

98
src/to/plain.rs Normal file
View File

@@ -0,0 +1,98 @@
use crate::{
doc::{Component, Text},
to::{Spec, SpecsExt},
};
// pub fn page(doc: &Document<'_>, specs: &Specs<impl Spec>) -> String {
// }
// fn doc_to(doc: &Document<'_>, depth: usize, specs: &Specs<impl Spec>, out: &mut String) {
// }
pub fn text_minimal(text: &Text<'_>) -> String {
let mut out = String::new();
let specs: &'static [&'static str] = &[];
text_to(text, false, specs, &mut out);
out
}
fn text_to(text: &Text<'_>, complete: bool, specs: &[impl Spec], out: &mut String) {
for component in &text.0 {
match component {
Component::Char(ch) => out.push(*ch),
Component::Text(text) => out.push_str(text),
Component::Note(_) => {
if complete {
out.push('-');
text_to(text, complete, specs, out);
out.push('-');
}
}
Component::Wrong(text) => {
if complete {
out.push('~');
text_to(text, complete, specs, out);
out.push('~');
}
}
Component::Special(text) => {
if complete {
out.push('_');
}
text_to(text, complete, specs, out);
if complete {
out.push('_');
}
}
Component::Emphasis(text) => {
if complete {
out.push('*');
}
text_to(text, complete, specs, out);
if complete {
out.push('*');
}
}
Component::Important(text) => {
if complete {
out.push_str("**");
} else {
out.push('*');
}
text_to(text, complete, specs, out);
if complete {
out.push_str("**");
} else {
out.push('*');
}
}
Component::Link(target, text) => {
if complete {
out.push('_');
}
text_to(text, complete, specs, out);
if complete {
out.push_str("_ (→ ");
out.push_str(target);
out.push(')');
}
}
Component::Ext(spec, source) => {
if complete {
#[allow(clippy::match_single_binding)]
match if specs.has(spec) { *spec } else { "" } {
_ => {
out.push_str("?[");
out.push_str(spec);
out.push(':');
out.push_str(source);
out.push_str("]?");
}
}
} else {
out.push('…');
}
}
}
}
}