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..62683f91a 100644
--- a/crates/epub/src/tests.rs
+++ b/crates/epub/src/tests.rs
@@ -26,6 +26,36 @@ fn exports_valid_package_in_reading_order() {
assert!(z.by_name("OEBPS/nav.xhtml").is_ok());
}
+/// 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());
+}
+
/// #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]