//! Export a document (and any nested content) to an OpenDocument Text `.odt` //! file, entirely in the browser. An ODT is a ZIP of ODF XML parts; we build a //! minimal, valid one (mimetype + content.xml + styles.xml + manifest) with a //! tiny stored-entry ZIP writer so no zip/compression crate is needed in WASM. //! Mirrors the reference app's DOCX export, but to ODT. use serde_json::Value; use std::cell::RefCell; use std::rc::Rc; /// CRC-32 (IEEE, the ZIP variant) of `data`. fn crc32(data: &[u8]) -> u32 { let mut crc: u32 = 0xFFFF_FFFF; for &byte in data { crc ^= byte as u32; for _ in 0..8 { let mask = (crc & 1).wrapping_neg(); crc = (crc >> 1) ^ (0xEDB8_8320 & mask); } } !crc } /// One file to place in the archive. struct ZipEntry { name: String, data: Vec, } /// Build a ZIP archive from `entries`, all **stored** (uncompressed). That is /// all an ODT needs, and it keeps the writer dependency-free. The first entry /// (the mimetype) must be stored and unpadded, which this guarantees. fn build_zip(entries: &[ZipEntry]) -> Vec { let mut out = Vec::new(); let mut central = Vec::new(); let mut offsets = Vec::new(); for entry in entries { let crc = crc32(&entry.data); let size = entry.data.len() as u32; let name = entry.name.as_bytes(); offsets.push(out.len() as u32); // Local file header. out.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); // signature out.extend_from_slice(&20u16.to_le_bytes()); // version needed out.extend_from_slice(&0u16.to_le_bytes()); // flags out.extend_from_slice(&0u16.to_le_bytes()); // method: stored out.extend_from_slice(&0u16.to_le_bytes()); // mod time out.extend_from_slice(&0u16.to_le_bytes()); // mod date out.extend_from_slice(&crc.to_le_bytes()); out.extend_from_slice(&size.to_le_bytes()); // compressed size out.extend_from_slice(&size.to_le_bytes()); // uncompressed size out.extend_from_slice(&(name.len() as u16).to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); // extra length out.extend_from_slice(name); out.extend_from_slice(&entry.data); } for (i, entry) in entries.iter().enumerate() { let crc = crc32(&entry.data); let size = entry.data.len() as u32; let name = entry.name.as_bytes(); central.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); // signature central.extend_from_slice(&20u16.to_le_bytes()); // version made by central.extend_from_slice(&20u16.to_le_bytes()); // version needed central.extend_from_slice(&0u16.to_le_bytes()); // flags central.extend_from_slice(&0u16.to_le_bytes()); // method: stored central.extend_from_slice(&0u16.to_le_bytes()); // mod time central.extend_from_slice(&0u16.to_le_bytes()); // mod date central.extend_from_slice(&crc.to_le_bytes()); central.extend_from_slice(&size.to_le_bytes()); central.extend_from_slice(&size.to_le_bytes()); central.extend_from_slice(&(name.len() as u16).to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); // extra length central.extend_from_slice(&0u16.to_le_bytes()); // comment length central.extend_from_slice(&0u16.to_le_bytes()); // disk number central.extend_from_slice(&0u16.to_le_bytes()); // internal attrs central.extend_from_slice(&0u32.to_le_bytes()); // external attrs central.extend_from_slice(&offsets[i].to_le_bytes()); central.extend_from_slice(name); } let central_offset = out.len() as u32; let central_size = central.len() as u32; out.extend_from_slice(¢ral); // End of central directory record. out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); // disk number out.extend_from_slice(&0u16.to_le_bytes()); // disk with central dir out.extend_from_slice(&(entries.len() as u16).to_le_bytes()); out.extend_from_slice(&(entries.len() as u16).to_le_bytes()); out.extend_from_slice(¢ral_size.to_le_bytes()); out.extend_from_slice(¢ral_offset.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); // comment length out } /// Escape text for XML content. fn xml_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 } /// One Slate text leaf as escaped ODF, wrapped in spans/link for its marks /// (bold, italic, underline, code, link). Soft breaks (`\n`) become /// ``. Mirrors the old wiki's inline `format`. fn leaf_to_odf(leaf: &Value) -> String { let raw = leaf.get("text").and_then(|t| t.as_str()).unwrap_or(""); let mut s = xml_escape(raw).replace('\n', ""); let mark = |key: &str| leaf.get(key).and_then(|v| v.as_bool()).unwrap_or(false); for (active, style) in [ (mark("bold"), "Bold"), (mark("italic"), "Emphasis"), (mark("underline"), "Underline"), (mark("strikethrough"), "Strikethrough"), (mark("code"), "Code"), ] { if active { s = format!("{s}"); } } if let Some(link) = leaf.get("link").and_then(|l| l.as_str()) { if !link.is_empty() { s = format!( "{s}", xml_escape(link) ); } } s } /// The inline content of a block: each child leaf run, concatenated. fn inline_children(block: &Value) -> String { block .get("children") .and_then(|c| c.as_array()) .map(|children| children.iter().map(leaf_to_odf).collect()) .unwrap_or_default() } /// The base paragraph kind of a block, used to pick its named ODF style and, /// when the block carries an `align`, the matching alignment automatic style. enum Base { Paragraph, Heading(usize), Quote, Pre, } /// The `fo:text-align` value for a Slate `align`, or `None` for the default /// (left / unset), which needs no override. fn align_value(align: Option<&str>) -> Option<&'static str> { match align { Some("center") => Some("center"), Some("right") => Some("end"), Some("justify") => Some("justify"), _ => None, } } /// The style key (`p`, `h1`..`h6`, `quote`, `pre`) used to name a base's /// alignment automatic styles; see [`automatic_styles`]. fn base_key(base: &Base) -> String { match base { Base::Paragraph => "p".to_string(), Base::Heading(l) => format!("h{}", (*l).clamp(1, 6)), Base::Quote => "quote".to_string(), Base::Pre => "pre".to_string(), } } /// The named ODF style for a base with no alignment (empty for a plain /// paragraph, which needs no style attribute). fn base_style(base: &Base) -> String { match base { Base::Paragraph => String::new(), Base::Heading(l) => format!("Heading_20_{}", (*l).clamp(1, 6)), Base::Quote => "Quotations".to_string(), Base::Pre => "Preformatted_20_Text".to_string(), } } /// Render a paragraph-like block (`inner` already XML-escaped) with its base /// style and, if present, its alignment. Headings emit `` with the /// outline level; everything else emits ``. #[expect( clippy::needless_pass_by_value, reason = "Base is a small Copy-like enum passed by value for call-site brevity across dozens of uses" )] fn styled_block(base: Base, align: Option<&str>, inner: &str) -> String { // With an alignment, use the generated `Al__` automatic style // (which inherits from the base style); otherwise the base style itself. let style = match align_value(align) { Some(value) => format!("Al_{}_{}", base_key(&base), value), None => base_style(&base), }; match base { Base::Heading(level) => { let l = level.clamp(1, 6); let name = if style.is_empty() { format!("Heading_20_{l}") } else { style }; format!( "{inner}" ) } _ if style.is_empty() => format!("{inner}"), _ => format!("{inner}"), } } /// A styled ODF heading with no alignment (document title, node names). fn heading_el(level: usize, inner: &str) -> String { styled_block(Base::Heading(level), None, inner) } /// A `` with the given list style, one `` per child. fn list_to_odf(block: &Value, style: &str) -> String { let items: String = block .get("children") .and_then(|c| c.as_array()) .map(|children| { children .iter() .map(|item| { format!( "{}", inline_children(item) ) }) .collect() }) .unwrap_or_default(); format!("{items}") } /// One embedded picture in the ODT package: its in-package path (`Pictures/…`), /// media type, and bytes. struct Picture { path: String, media_type: &'static str, data: Vec, } /// Detect an image's format from its magic bytes: returns (file extension, ODF /// media type, width px, height px). Covers PNG, JPEG and GIF — what the editor /// and paste flows produce. `None` if unrecognised or dimensions can't be read. fn image_info(bytes: &[u8]) -> Option<(&'static str, &'static str, u32, u32)> { // PNG: 8-byte signature, then IHDR (width/height big-endian at offset 16). if bytes.len() >= 24 && bytes[..8] == [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] { let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]); let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); return Some(("png", "image/png", w, h)); } // GIF: "GIF8", then width/height little-endian at offset 6. if bytes.len() >= 10 && &bytes[..4] == b"GIF8" { let w = u16::from_le_bytes([bytes[6], bytes[7]]) as u32; let h = u16::from_le_bytes([bytes[8], bytes[9]]) as u32; return Some(("gif", "image/gif", w, h)); } // JPEG: starts FF D8; scan segments for a Start-Of-Frame marker (C0..CF, // excluding the restart/APP markers), whose 5th+ bytes hold height then width. if bytes.len() >= 4 && bytes[0] == 0xFF && bytes[1] == 0xD8 { let mut i = 2; while i + 9 < bytes.len() { if bytes[i] != 0xFF { i += 1; continue; } let marker = bytes[i + 1]; // SOF0..SOF3, SOF5..SOF7, SOF9..SOF11, SOF13..SOF15 carry dimensions. let is_sof = matches!(marker, 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF); if is_sof { let h = u16::from_be_bytes([bytes[i + 5], bytes[i + 6]]) as u32; let w = u16::from_be_bytes([bytes[i + 7], bytes[i + 8]]) as u32; return Some(("jpg", "image/jpeg", w, h)); } // Skip this segment (2-byte length after the marker). let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize; i += 2 + len; } } None } /// Convert pixel dimensions to the ODF frame's `svg:width`/`svg:height` in cm, /// assuming 96 DPI and capping the width to the page's text area (~16.5cm) so a /// large image doesn't overflow the margins. fn frame_size_cm(w: u32, h: u32) -> (f64, f64) { const MAX_W: f64 = 16.5; let px_to_cm = |px: u32| px as f64 / 96.0 * 2.54; let (mut wc, mut hc) = (px_to_cm(w.max(1)), px_to_cm(h.max(1))); if wc > MAX_W { hc *= MAX_W / wc; wc = MAX_W; } (wc, hc) } /// Fetch an image's bytes for embedding. Returns `None` on any error (network, /// CORS, etc.) so the caller can fall back to emitting the URL as a link. A /// `data:` URI (how the insert-image button stores an inline image) is decoded /// in-place rather than fetched, so those images embed on export too. async fn fetch_image_bytes(url: &str) -> Option> { if let Some(rest) = url.strip_prefix("data:") { // data:[][;base64], — only base64 payloads carry bytes. let (meta, payload) = rest.split_once(',')?; if !meta.contains("base64") { return None; } // Decode via atob (the inverse of the btoa in download_bytes): each code // unit of the result is one byte, so no base64 crate dependency is needed. let bin = web_sys::window()?.atob(payload).ok()?; return Some(bin.chars().map(|c| c as u8).collect()); } let resp = reqwest::Client::new().get(url).send().await.ok()?; if !resp.status().is_success() { return None; } resp.bytes().await.ok().map(|b| b.to_vec()) } /// An `image` block, embedded into the ODT when its bytes can be fetched and /// decoded (recorded in `pics`), else falling back to [`image_to_odf`] (a link). /// Facebook-style emoji-image blocks are emitted as their character instead. async fn image_block_to_odf(block: &Value, pics: &RefCell>) -> String { let Some(url) = block .get("url") .and_then(|u| u.as_str()) .filter(|u| !u.is_empty()) else { return String::new(); }; if let Some(emoji) = crate::components::content::emoji_from_image_url(url) { return format!("{}", xml_escape(&emoji)); } let Some(bytes) = fetch_image_bytes(url).await else { return image_to_odf(block); }; let Some((ext, media_type, w, h)) = image_info(&bytes) else { return image_to_odf(block); }; let idx = pics.borrow().len() + 1; let path = format!("Pictures/image{idx}.{ext}"); pics.borrow_mut().push(Picture { path: path.clone(), media_type, data: bytes, }); let (wc, hc) = frame_size_cm(w, h); format!( "\ " ) } /// A void `image` block: when the bytes can't be embedded, rather than lose the /// reference we emit the source as a link. fn image_to_odf(block: &Value) -> String { match block .get("url") .and_then(|u| u.as_str()) .filter(|u| !u.is_empty()) { Some(url) => { let escaped = xml_escape(url); format!("{escaped}") } None => String::new(), } } /// Render one Slate block as ODF: headings, bulleted/numbered lists, /// block-quotes, preformatted blocks, images and paragraphs, preserving inline /// marks and per-block alignment. Mirrors the old wiki's editor schema. fn block_to_odf(block: &Value) -> String { let ty = block .get("type") .and_then(|t| t.as_str()) .unwrap_or("paragraph"); let align = block.get("align").and_then(|a| a.as_str()); match ty { "heading-one" | "h1" => styled_block(Base::Heading(1), align, &inline_children(block)), "heading-two" | "h2" => styled_block(Base::Heading(2), align, &inline_children(block)), "heading-three" | "h3" => styled_block(Base::Heading(3), align, &inline_children(block)), "heading-four" | "h4" => styled_block(Base::Heading(4), align, &inline_children(block)), "heading-five" | "h5" => styled_block(Base::Heading(5), align, &inline_children(block)), "heading-six" | "h6" => styled_block(Base::Heading(6), align, &inline_children(block)), "bulleted-list" | "ul" => list_to_odf(block, "ListBullet"), "numbered-list" | "ol" => list_to_odf(block, "ListNumber"), "block-quote" => styled_block(Base::Quote, align, &inline_children(block)), "block-pre" | "pre" => styled_block(Base::Pre, align, &inline_children(block)), "image" => image_to_odf(block), _ => styled_block(Base::Paragraph, align, &inline_children(block)), } } /// The `` block: one alignment style per (base kind × /// non-default alignment), each inheriting from the base's named style. Kept /// small and always-present so [`block_to_odf`] can reference them by name /// without threading a style collector through the recursion. fn automatic_styles() -> String { let bases = [ ("p", "Standard"), ("h1", "Heading_20_1"), ("h2", "Heading_20_2"), ("h3", "Heading_20_3"), ("h4", "Heading_20_4"), ("h5", "Heading_20_5"), ("h6", "Heading_20_6"), ("quote", "Quotations"), ("pre", "Preformatted_20_Text"), ]; let mut out = String::from(""); for (key, parent) in bases { for value in ["center", "end", "justify"] { out.push_str(&format!( "\ " )); } } out.push_str(""); out } /// Wrap a pre-built ODF text `body` in a full `content.xml`, including the /// alignment automatic styles the body may reference. fn wrap_content(body: &str) -> String { format!( "\ \ {}\ {body}\ ", automatic_styles() ) } /// The ODF `content.xml` for a single document `title` + its Slate `content`. /// Test-only: `export_tree` builds the body itself; this exists so the ODT /// block mapping can be unit-tested. #[cfg(test)] fn content_xml(title: &str, content: Option<&Value>) -> String { let mut body = heading_el(1, &xml_escape(title)); if let Some(Value::Array(blocks)) = content { for block in blocks { body.push_str(&block_to_odf(block)); } } wrap_content(&body) } /// Named styles referenced by the exported content: `Heading 1`..`Heading 6` /// (bold, graduated sizes, tied to their outline level), the `Bold` / `Emphasis` /// / `Underline` / `Strikethrough` / `Code` run styles for inline marks, the /// indented `Quotations` and monospace `Preformatted Text` paragraphs, and the /// `ListBullet` / `ListNumber` list styles (so bulleted and numbered lists /// actually show bullets and numbers). Without these the headings render as /// plain body text and lists as run-on paragraphs. Mirrors the old wiki, where /// `html-to-docx` mapped `

`..`

`, `