Merge pull request #2250

This commit is contained in:
aiongg committed 2026-11-04 04:14:12 +00:00
commit c3ea317837
4 files changed
+108 -11

No files matched your search

+26 -2
View File
@@ -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<ItemId, &Item> = 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, "<section id=\"{anchor}\">\n{}</section>\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, "<figure>{markup}</figure>");
@@ -185,13 +196,26 @@ pub struct HtmlOptions {
pub generic_families: BTreeMap<String, String>,
}
/// 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!(
"<!DOCTYPE html>\n<html lang=\"{lang}\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"generator\" content=\"DesignCraft\">\n<title>{}</title>\n<style>\nbody {{ max-width: 40em; margin: 2em auto; padding: 0 1em; }}\nfigure {{ margin: 1em 0; }} figure img {{ max-width: 100%; height: auto; }}\n{}</style>\n</head>\n<body>\n{body}</body>\n</html>\n",
xml_text(&title),
+13 -2
View File
@@ -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<Vec<u8>, 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<Item = (St
/// Export the document as a reflowable EPUB 3.
pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, 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());
+39 -7
View File
@@ -50,8 +50,17 @@ pub(crate) struct StoryWriter<'a, 'f> {
heading_ids: Option<String>,
/// 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<usize, (Vec<usize>, Vec<usize>)>,
/// 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::<Vec<_>>();
(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();
+30
View File
@@ -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]