Files
wordcraft/crates/doc/src/byte_store.rs
T
asystemoffieldsandClaude Opus 5.5 5795259c45 File: recovery copies and .json files store pictures as base64 (#586 follow-up)
Native JSON wrote Document::media and Document::passthrough with serde's
default for bytes, an array of numbers. With a synthetic 27 MiB picture a
recovery copy (compact) was 96.4 MiB and a .json file (pretty-printed,
one number per line) 285.5 MiB.

Each part is now one standard base64 string (doc::byte_store, using the
workspace base64 crate), encoded straight into the writer. Reading accepts
base64 or the old array of numbers, so recovery copies and .json files
written before still open; anything else is an error, never a panic.

Measured (release, same synthetic picture): compact 96.4 -> 36.0 MiB,
pretty 285.5 -> 36.1 MiB, write 252/614 -> 52/57 ms, read 404/533 ->
46/37 ms. A recovery snapshot of a 6,000-paragraph document with the
picture: 99 -> 39 MB, worker time 2.3 -> 0.26 s, start-up scan 1.03 ->
0.14 s.

Tests: byte_store unit tests (round trip, old arrays, hostile values);
recovery_format.rs native_json_stores_bytes_as_base64,
native_json_with_byte_arrays_still_opens, native_json_with_bad_bytes_is_an_error,
ignored measurement native_json_size_with_a_large_picture; desktop.rs
copies_with_byte_arrays_written_before_still_restore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:10:23 -04:00

131 lines
4.6 KiB
Rust

//! How [`crate::Document::media`] and [`crate::Document::passthrough`] are written in the native
//! JSON form (`.json` files and crash-recovery copies): each part's bytes as one base64 string.
//! serde's default for bytes, an array of numbers, took about 3.6 times their size compact and
//! over six times pretty-printed (a 27 MB picture made a 99 MB recovery copy, #586).
//!
//! Reading takes either form, so `.json` files and recovery copies written before still open.
use std::collections::BTreeMap;
use std::fmt;
use std::sync::Arc;
use base64::Engine as _;
use base64::display::Base64Display;
use base64::engine::general_purpose::STANDARD;
use serde::de::{self, Deserializer, MapAccess, SeqAccess, Visitor};
use serde::ser::{Serialize, Serializer};
type Store = BTreeMap<String, Arc<Vec<u8>>>;
/// One part's bytes, encoded as it is written out: no second copy of a large picture is made.
struct Encoded<'a>(&'a [u8]);
impl Serialize for Encoded<'_> {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(&Base64Display::new(self.0, &STANDARD))
}
}
pub(crate) fn serialize<S: Serializer>(store: &Store, s: S) -> Result<S::Ok, S::Error> {
s.collect_map(store.iter().map(|(name, bytes)| (name, Encoded(bytes))))
}
pub(crate) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Store, D::Error> {
d.deserialize_map(StoreVisitor)
}
struct StoreVisitor;
impl<'de> Visitor<'de> for StoreVisitor {
type Value = Store;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a map of part names to base64 text or byte arrays")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Store, A::Error> {
let mut store = Store::new();
while let Some((name, Bytes(bytes))) = map.next_entry::<String, Bytes>()? {
store.insert(name, Arc::new(bytes));
}
Ok(store)
}
}
/// A part's bytes as either form.
struct Bytes(Vec<u8>);
impl<'de> de::Deserialize<'de> for Bytes {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
d.deserialize_any(BytesVisitor).map(Bytes)
}
}
struct BytesVisitor;
impl<'de> Visitor<'de> for BytesVisitor {
type Value = Vec<u8>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("base64 text or an array of bytes")
}
fn visit_str<E: de::Error>(self, text: &str) -> Result<Vec<u8>, E> {
STANDARD.decode(text).map_err(|e| E::custom(format!("bytes are not valid base64: {e}")))
}
fn visit_bytes<E: de::Error>(self, bytes: &[u8]) -> Result<Vec<u8>, E> {
Ok(bytes.to_vec())
}
/// The form written before: one number per byte.
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<u8>, A::Error> {
// The length hint comes from the file: trust it only so far.
let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0).min(1 << 20));
while let Some(b) = seq.next_element::<u8>()? {
out.push(b);
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug, Default)]
struct Parts {
#[serde(default, with = "super")]
media: Store,
}
fn parts(entries: &[(&str, &[u8])]) -> Parts {
Parts { media: entries.iter().map(|(n, b)| ((*n).to_string(), Arc::new(b.to_vec()))).collect() }
}
#[test]
fn bytes_are_written_as_base64_and_read_back() {
let all: Vec<u8> = (0..=255).collect();
for n in [0, 1, 2, 3, 4, 255, 256] {
let p = parts(&[("a.png", &all[..n.min(all.len())])]);
let text = serde_json::to_string(&p).unwrap();
assert_eq!(serde_json::from_str::<Parts>(&text).unwrap(), p, "{n}");
}
assert_eq!(serde_json::to_string(&parts(&[("m", b"Man"), ("ma", b"Ma")])).unwrap(), r#"{"media":{"m":"TWFu","ma":"TWE="}}"#);
}
#[test]
fn number_arrays_written_before_still_read() {
let old = r#"{"media":{"a.png":[0,1,127,128,254,255],"empty":[]}}"#;
assert_eq!(serde_json::from_str::<Parts>(old).unwrap(), parts(&[("a.png", &[0, 1, 127, 128, 254, 255]), ("empty", &[])]));
}
#[test]
fn hostile_values_are_errors_not_panics() {
for bad in [r#""T""#, r#""TWFu!""#, r#""-_""#, r#""TQ=x""#, "[256]", "[-1]", "[1.5]", "true", "{}", r#"["a"]"#] {
let text = format!(r#"{{"media":{{"a.png":{bad}}}}}"#);
assert!(serde_json::from_str::<Parts>(&text).is_err(), "{bad}");
}
assert!(serde_json::from_str::<Parts>(r#"{"media":"x"}"#).is_err());
}
}