From 032fd7c08acd30da0d03b805e640dab5b7d28df3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 20:41:04 +0000 Subject: [PATCH 1/2] epub: export budgets for data URIs and variables, note index lookups, graphics index, title cap --- crates/epub/src/html.rs | 28 ++++++++++++++++++-- crates/epub/src/package.rs | 15 +++++++++-- crates/epub/src/story_writer.rs | 46 ++++++++++++++++++++++++++++----- crates/epub/src/tests.rs | 30 +++++++++++++++++++++ 4 files changed, 108 insertions(+), 11 deletions(-) diff --git a/crates/epub/src/html.rs b/crates/epub/src/html.rs index 7f7fbe26f..6442d5045 100644 --- a/crates/epub/src/html.rs +++ b/crates/epub/src/html.rs @@ -129,6 +129,17 @@ pub(crate) fn body_html<'a>(doc: &'a Document, epub: bool, split: Option<&EpubSp } let mut body = String::new(); let mut toc = toc_entries(doc, &order); + // `Document::item` searches every spread: one pre-order index for all the placed graphics. + let mut items: std::collections::HashMap = std::collections::HashMap::new(); + if order.iter().any(|b| matches!(b, Block::Image(_))) { + for sp in doc.spreads.iter().map(|s| &**s).chain(doc.parents.iter().map(|s| &**s)) { + for it in &sp.items { + it.walk(&mut |i| { + items.entry(i.id).or_insert(i); + }); + } + } + } for (k, b) in order.iter().enumerate() { match b { // The endnote frame's text is generated from the endnotes, which end the body as a @@ -139,7 +150,7 @@ pub(crate) fn body_html<'a>(doc: &'a Document, epub: bool, split: Option<&EpubSp let _ = write!(body, "
\n{}
\n", writer.story(*sid, epub.then_some(anchor.as_str()))); } Block::Image(iid) => { - let Some(it) = doc.item(*iid) else { continue }; + let Some(&it) = items.get(iid) else { continue }; let Some((markup, media)) = writer.graphic(it) else { continue }; if media { let _ = writeln!(body, "
{markup}
"); @@ -185,13 +196,26 @@ pub struct HtmlOptions { pub generic_families: BTreeMap, } +/// Bytes of data URIs one HTML export may write (a document can place one large asset many times). +const MAX_DATA_URI_BYTES: usize = 512 * 1024 * 1024; + /// File › Export › HTML: one self-contained page (styles inline, images as data URIs) with the /// document's stories and graphics in reading order. pub fn export_html(doc: &Document, opts: &HtmlOptions) -> String { let title = opts.title.clone().unwrap_or_else(|| doc.metadata_title().to_string()); let language = opts.language.as_deref().unwrap_or("en"); let lang = language_tag(language); - let body = body_html(doc, false, None, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))).html; + // Each placement embeds its own copy of the asset; past the budget a placement gets no source. + let spent = std::cell::Cell::new(0usize); + let body = body_html(doc, false, None, &|_, mime, data| { + let n = data.len().div_ceil(3).saturating_mul(4).saturating_add(mime.len() + 13); + if n > MAX_DATA_URI_BYTES - spent.get() { + return String::new(); + } + spent.set(spent.get() + n); + format!("data:{mime};base64,{}", base64(data)) + }) + .html; format!( "\n\n\n\n\n\n{}\n\n\n\n{body}\n\n", xml_text(&title), diff --git a/crates/epub/src/package.rs b/crates/epub/src/package.rs index fff4fd6fe..63e0399f0 100644 --- a/crates/epub/src/package.rs +++ b/crates/epub/src/package.rs @@ -21,6 +21,17 @@ fn modified(doc: &Document, opts: &EpubOptions) -> String { t.map_or_else(|| "2026-01-01T00:00:00Z".to_string(), iso8601) } +/// Characters of the title the package keeps: it is copied into every content file, so a huge one +/// would multiply by the number of chapters or pages. +const MAX_TITLE_CHARS: usize = 1024; + +fn capped_title(title: String) -> String { + match title.char_indices().nth(MAX_TITLE_CHARS) { + Some((at, _)) => title[..at].to_string(), + None => title, + } +} + /// The book's identifier: the given one, else a `urn:uuid` derived from the title and the /// document's creation time, so a book keeps its identifier from export to export and two books /// with the same title don't share one. @@ -70,7 +81,7 @@ pub struct FixedPage { /// A pre-paginated (fixed-layout) EPUB: one XHTML page per document page. pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions) -> Result, EpubError> { - let title = opts.title.clone().unwrap_or_else(|| doc.metadata_title().to_string()); + let title = capped_title(opts.title.clone().unwrap_or_else(|| doc.metadata_title().to_string())); let lang = language_tag(&opts.language); let id = identifier(doc, opts, &title); let modified = modified(doc, opts); @@ -161,7 +172,7 @@ fn write_package<'a>(opf: &str, nav: &str, entries: impl IntoIterator Result, EpubError> { - let title = opts.title.clone().unwrap_or_else(|| doc.metadata_title().to_string()); + let title = capped_title(opts.title.clone().unwrap_or_else(|| doc.metadata_title().to_string())); let id = identifier(doc, opts, &title); let modified = modified(doc, opts); let Body { html: body, images, toc, .. } = body_html(doc, true, opts.split.as_ref(), &|name, _, _| name.to_string()); diff --git a/crates/epub/src/story_writer.rs b/crates/epub/src/story_writer.rs index 40c95f135..d6fb05159 100644 --- a/crates/epub/src/story_writer.rs +++ b/crates/epub/src/story_writer.rs @@ -50,8 +50,17 @@ pub(crate) struct StoryWriter<'a, 'f> { heading_ids: Option, /// Where EPUB content files start (see [`starts_chapter`]). pub(crate) split: EpubSplit, + /// Per story (by address): the byte offsets of its footnote and endnote references, so a note's + /// index is a search, not a count over the text before it. + note_refs: std::collections::HashMap, Vec)>, + /// Bytes of variable values still allowed into the output (see [`MAX_VARIABLE_BYTES`]). + variable_bytes: usize, } +/// Bytes of variable values one export may write: one stored value referenced many times would +/// otherwise multiply into the output. +const MAX_VARIABLE_BYTES: usize = 64 * 1024 * 1024; + impl<'a, 'f> StoryWriter<'a, 'f> { pub(crate) fn new(doc: &'a Document, epub: bool, img_src: Option<&'f dyn Fn(&str, &str, &[u8]) -> String>) -> Self { StoryWriter { @@ -69,9 +78,22 @@ impl<'a, 'f> StoryWriter<'a, 'f> { no_alt: Vec::new(), heading_ids: None, split: doc.settings.epub_split.clone(), + note_refs: std::collections::HashMap::new(), + variable_bytes: MAX_VARIABLE_BYTES, } } + /// Index of the footnote (or endnote) whose reference is at byte `pos` of `st` + /// (`Story::notes_before` / `endnotes_before`, from offsets found once per story). + fn note_index(&mut self, st: &Story, pos: usize, endnote: bool) -> usize { + let (foot, end) = self.note_refs.entry(std::ptr::from_ref(st) as usize).or_insert_with(|| { + let at = |c: char| st.text.match_indices(c).map(|(i, _)| i).collect::>(); + (at(FOOTNOTE_REF), at(ENDNOTE_REF)) + }); + let list = if endnote { end } else { foot }; + list.partition_point(|&p| p < pos) + } + fn epub_type(&self, t: &str) -> String { if self.epub { format!(" epub:type=\"{t}\"") } else { String::new() } } @@ -182,7 +204,7 @@ impl<'a, 'f> StoryWriter<'a, 'f> { if c != FOOTNOTE_REF && c != ENDNOTE_REF && c != OBJECT_MARK { continue; } - t.push_str(&run_text(doc, raw.get(seg_start..i).unwrap_or_default())); + t.push_str(&run_text(doc, raw.get(seg_start..i).unwrap_or_default(), &mut self.variable_bytes)); seg_start = i + c.len_utf8(); if c == OBJECT_MARK { let k = mark.unwrap_or_else(|| st.text.get(..a).map_or(0, |t| t.matches(OBJECT_MARK).count())); @@ -198,7 +220,7 @@ impl<'a, 'f> StoryWriter<'a, 'f> { } } } - t.push_str(&run_text(doc, raw.get(seg_start..).unwrap_or_default())); + t.push_str(&run_text(doc, raw.get(seg_start..).unwrap_or_default(), &mut self.variable_bytes)); t } @@ -303,7 +325,8 @@ impl<'a, 'f> StoryWriter<'a, 'f> { /// The footnote whose reference is at byte `pos` of `st`: a note reference in `out`, the note /// itself in `after`. fn footnote(&mut self, st: &Story, pos: usize, depth: usize, out: &mut String, after: &mut String) { - let Some(note) = st.notes.get(st.notes_before(pos)) else { return }; + let k = self.note_index(st, pos, false); + let Some(note) = st.notes.get(k) else { return }; let doc = self.doc; let o = &doc.footnote_options; let n = self.next_footnote; @@ -325,7 +348,8 @@ impl<'a, 'f> StoryWriter<'a, 'f> { /// The endnote whose reference is at byte `pos` of `st`: a note reference in `out`; the note /// joins the endnotes section. fn endnote(&mut self, st: &Story, pos: usize, depth: usize, out: &mut String) { - let Some(note) = st.endnotes.get(st.endnotes_before(pos)) else { return }; + let k = self.note_index(st, pos, true); + let Some(note) = st.endnotes.get(k) else { return }; let n = self.next_endnote; self.next_endnote = n.saturating_add(1); self.endnote_ids = self.endnote_ids.saturating_add(1); @@ -416,13 +440,21 @@ impl<'a, 'f> StoryWriter<'a, 'f> { } } -/// Run text with variables replaced by their values. -fn run_text(doc: &Document, raw: &str) -> String { +/// Run text with variables replaced by their values; `left` is what the export may still write +/// of them (a value that doesn't fit is left out). +fn run_text(doc: &Document, raw: &str, left: &mut usize) -> String { if raw.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()) { let sub: String = raw .chars() .map(|c| match designcraft_doc::vars::var_index(c) { - Some(i) => doc.variable_value(i, None).unwrap_or_default(), + Some(i) => { + let v = doc.variable_value(i, None).unwrap_or_default(); + if v.len() > *left { + return String::new(); + } + *left -= v.len(); + v + } None => c.to_string(), }) .collect(); diff --git a/crates/epub/src/tests.rs b/crates/epub/src/tests.rs index b3865dfc1..1fdd25157 100644 --- a/crates/epub/src/tests.rs +++ b/crates/epub/src/tests.rs @@ -28,6 +28,36 @@ fn exports_valid_package_in_reading_order() { /// #766: anchored graphics are exported where they are anchored (inline in the text, an /// above-line object as a block of its own), and their images go into the package. +/// Many footnotes and endnotes each export with their own text, in reference order. +#[test] +fn many_notes_keep_their_own_text() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "x", ParaFormat::default()).unwrap(); + let st = d.story_mut(sid).unwrap(); + for i in 0..300 { + let end = st.len(); + st.insert_note(end, &format!("foot{i}z"), ParaFormat::default()); + let end = st.len(); + st.insert_endnote(end, &format!("end{i}z"), ParaFormat::default()); + } + let html = export_html(&d, &HtmlOptions::default()); + let at = |needle: &str| html.find(needle).unwrap_or_else(|| panic!("{needle}")); + assert!(at("foot0z") < at("foot1z") && at("foot298z") < at("foot299z")); + assert!(at("end0z") < at("end1z") && at("end298z") < at("end299z")); +} + +/// A huge title is cut before it is copied into every content file. +#[test] +fn a_huge_title_is_capped() { + let d = tables_and_notes(); + let opts = EpubOptions { title: Some("T".repeat(5000)), ..EpubOptions::default() }; + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &opts).unwrap())).unwrap(); + let mut s = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut s).unwrap(); + assert!(s.contains(&"T".repeat(1024)) && !s.contains(&"T".repeat(1025)), "{}", s.len()); +} + #[test] fn anchored_objects_are_exported_in_place() { let mut d = Document::new(&NewDocument::default()); From a2cc32a8d1898a532f1749f328e3d159e2b806b8 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:50:09 +0000 Subject: [PATCH 2/2] Apply review fixes - crates/epub/src/tests.rs --- crates/epub/src/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/epub/src/tests.rs b/crates/epub/src/tests.rs index 1fdd25157..62683f91a 100644 --- a/crates/epub/src/tests.rs +++ b/crates/epub/src/tests.rs @@ -26,8 +26,6 @@ fn exports_valid_package_in_reading_order() { assert!(z.by_name("OEBPS/nav.xhtml").is_ok()); } -/// #766: anchored graphics are exported where they are anchored (inline in the text, an -/// above-line object as a block of its own), and their images go into the package. /// Many footnotes and endnotes each export with their own text, in reference order. #[test] fn many_notes_keep_their_own_text() { @@ -58,6 +56,8 @@ fn a_huge_title_is_capped() { assert!(s.contains(&"T".repeat(1024)) && !s.contains(&"T".repeat(1025)), "{}", s.len()); } +/// #766: anchored graphics are exported where they are anchored (inline in the text, an +/// above-line object as a block of its own), and their images go into the package. #[test] fn anchored_objects_are_exported_in_place() { let mut d = Document::new(&NewDocument::default());