use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as B64;
use super::{Limits, PortableError, container, inspect};
const OPEN: &str = r#"<script type="application/vnd.rizzma.figure+base64" id="riz">"#;
const CLOSE: &str = "</script>";
#[derive(Debug, Clone, Copy)]
pub struct HtmlRuntime<'a> {
pub wasm: &'a [u8],
pub glue: &'a str,
pub loader: &'a str,
}
fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
fn wrapper_text(bytes: &[u8], limits: &Limits) -> Result<(String, String), PortableError> {
let info = inspect(bytes, limits)?;
let meta = info.meta;
let title = meta
.as_ref()
.and_then(|m| m.title.clone())
.unwrap_or_else(|| "portable figure".to_string());
let alt = meta
.as_ref()
.and_then(|m| m.alt.clone())
.unwrap_or_else(|| title.clone());
Ok((escape(&title), escape(&alt)))
}
fn page(title: &str, alt: &str, artifact_b64: &str, extra: &str) -> String {
format!(
r#"<!doctype html>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title} — rizzma portable figure</title>
<style>body{{margin:0;background:#1a1b26;color:#c0caf5;font:14px system-ui;display:grid;place-items:center;min-height:100vh;gap:8px}}img,canvas{{max-width:95vw;height:auto}}p{{opacity:.7;margin:4px}}#riz-controls{{display:flex;gap:20px;flex-wrap:wrap;justify-content:center;max-width:95vw}}#riz-controls label{{display:flex;align-items:center;gap:8px}}#riz-controls input{{accent-color:#7aa2f7}}#riz-controls output{{font-variant-numeric:tabular-nums;min-width:4ch;opacity:.8}}</style>
{OPEN}{artifact_b64}{CLOSE}
<img id="riz-poster" alt="{alt}">
<p id="riz-note">{title} — a rizzma portable figure. This preview is its embedded
poster; a rizzma-aware host renders it with pan, zoom, and animation.</p>
<script>
// Exporter-authored constant code; the only variable inputs are base64 (inert)
// and pre-escaped text. Nothing artifact-derived is assigned via innerHTML.
(() => {{
const b64 = document.getElementById("riz").textContent;
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
const v = new DataView(bytes.buffer);
let pos = 12;
while (pos < bytes.length) {{
const len = v.getUint32(pos, true);
const tag = String.fromCharCode(...bytes.subarray(pos + 4, pos + 8));
if (tag === "PSTR") {{
document.getElementById("riz-poster").src =
URL.createObjectURL(new Blob([bytes.subarray(pos + 8, pos + 8 + len)], {{type: "image/png"}}));
break;
}}
pos += 8 + len;
while (pos % 8) pos += 1;
}}
}})();
</script>
{extra}"#
)
}
pub fn wrap_html(bytes: &[u8], limits: &Limits) -> Result<String, PortableError> {
let (title, alt) = wrapper_text(bytes, limits)?;
Ok(page(&title, &alt, &B64.encode(bytes), ""))
}
pub fn wrap_html_live(
bytes: &[u8],
limits: &Limits,
rt: &HtmlRuntime<'_>,
) -> Result<String, PortableError> {
let (title, alt) = wrapper_text(bytes, limits)?;
let extra = format!(
r#"<script type="application/octet-stream" id="riz-rt-wasm">{wasm}</script>
<script type="text/plain" id="riz-rt-glue">{glue}</script>
<script type="text/plain" id="riz-rt-loader">{loader}</script>
<canvas id="riz-live" hidden></canvas>
<div id="riz-controls" hidden></div>
<script type="module">
// Poster-first upgrade: any failure below leaves the poster showing.
try {{
const un64 = (id) => Uint8Array.from(atob(document.getElementById(id).textContent), c => c.charCodeAt(0));
const src = (id) => new TextDecoder().decode(un64(id));
const blobUrl = (code) => URL.createObjectURL(new Blob([code], {{type: "text/javascript"}}));
const loader = await import(blobUrl(src("riz-rt-loader")));
const canvas = document.getElementById("riz-live");
canvas.hidden = false;
const handle = await loader.mount(canvas, un64("riz"), {{
renderer: {{ wasm: un64("riz-rt-wasm"), glue: blobUrl(src("riz-rt-glue")) }},
autoplay: true,
}});
document.getElementById("riz-poster").hidden = true;
document.getElementById("riz-note").textContent =
"drag to pan, wheel to zoom, double-click to reset";
// Sliders are host DOM (design/12 §5), and this page is the host. Labels
// come through textContent, so an artifact-authored label stays text.
const row = document.getElementById("riz-controls");
for (const [i, c] of handle.controls.entries()) {{
row.hidden = false;
const label = document.createElement("label");
const name = document.createElement("span");
name.textContent = c.label;
const value = document.createElement("output");
const slider = document.createElement("input");
slider.type = "range";
slider.min = c.min;
slider.max = c.max;
slider.step = c.step ?? (c.max - c.min) / 1000;
slider.value = c.value;
value.textContent = c.value.toPrecision(3);
slider.addEventListener("input", () => {{
// Display what was applied (clamped, snapped), not what was sent.
value.textContent = handle.setControl(i, slider.valueAsNumber).toPrecision(3);
}});
label.append(name, slider, value);
row.append(label);
}}
}} catch (err) {{
document.getElementById("riz-live").hidden = true;
console.warn("riz: live upgrade failed, poster stands:", err);
}}
</script>"#,
wasm = B64.encode(rt.wasm),
glue = B64.encode(rt.glue.as_bytes()),
loader = B64.encode(rt.loader.as_bytes()),
);
Ok(page(&title, &alt, &B64.encode(bytes), &extra))
}
pub fn unwrap_html(html: &[u8], limits: &Limits) -> Result<Vec<u8>, PortableError> {
let text = std::str::from_utf8(html)
.map_err(|_| PortableError::Malformed("wrapper is not valid UTF-8".to_string()))?;
let Some(start) = text.find(OPEN) else {
return Err(PortableError::Malformed(
"no portable-figure carrier element in this HTML".to_string(),
));
};
if text[start + OPEN.len()..].contains(OPEN) {
return Err(PortableError::Malformed(
"more than one portable-figure carrier element; refusing to guess".to_string(),
));
}
let payload_start = start + OPEN.len();
let Some(rel_end) = text[payload_start..].find(CLOSE) else {
return Err(PortableError::Malformed(
"unterminated portable-figure carrier element".to_string(),
));
};
let payload = &text[payload_start..payload_start + rel_end];
if payload.len() % 4 != 0 {
return Err(PortableError::Malformed(format!(
"carrier base64 length {} is not a multiple of 4",
payload.len()
)));
}
let padding = payload.bytes().rev().take_while(|&b| b == b'=').count();
if padding > 2 {
return Err(PortableError::Malformed(
"carrier base64 has more than two padding characters".to_string(),
));
}
if payload[..payload.len() - padding].contains('=') {
return Err(PortableError::Malformed(
"carrier base64 has padding before the end".to_string(),
));
}
let decoded_exact = payload.len() / 4 * 3 - padding;
if decoded_exact > limits.max_total_bytes {
return Err(PortableError::Budget(format!(
"carrier decodes to {decoded_exact} bytes, over the {} byte limit",
limits.max_total_bytes
)));
}
let bytes = B64
.decode(payload)
.map_err(|e| PortableError::Malformed(format!("carrier base64 does not decode: {e}")))?;
if bytes.len() > limits.max_total_bytes {
return Err(PortableError::Budget(format!(
"carrier decoded to {} bytes, over the {} byte limit",
bytes.len(),
limits.max_total_bytes
)));
}
Ok(bytes)
}
#[must_use]
pub fn is_raw_riz(bytes: &[u8]) -> bool {
bytes.get(..4) == Some(&container::MAGIC)
}