mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-08 03:23:11 +00:00
Merge pull request #2250
This commit is contained in:
commit
c3ea317837
4 files changed
+108
-11
No files matched your search
+26
-2
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user