From c91b9af16d9e26981b16cae25e3631d9adafebd4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 14:19:45 +0000 Subject: [PATCH] fix(epub): EPUB and HTML export include tables, footnotes and endnotes (#724) --- ROADMAP.md | 1 + crates/epub/src/lib.rs | 382 +++++++++++++++++++++++++++++++++---- docs/file-format-parity.md | 2 +- 3 files changed, 347 insertions(+), 38 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..04addb11e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Reflowable EPUB and HTML export include tables (header and footer rows, merged cells), footnotes (a linked note after the paragraph that refers to it) and endnotes (a linked section at the end of the book). No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 77b509d0b..cedcbff76 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -4,6 +4,8 @@ //! frame); paragraph styles become CSS classes (`p.`), character styles become `span` //! classes, local overrides become inline styles. Placed graphics become `
` elements at //! their position in the reading order. Parent-page items (folios, running heads) are skipped. +//! Tables become `` elements, footnotes linked `aside`s after the paragraph that refers to +//! them, and endnotes a linked section at the end. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] @@ -11,7 +13,10 @@ use std::collections::BTreeMap; use std::fmt::Write as _; use std::io::Write; -use designcraft_doc::{Align, CharAttrs, CharProps, Content, Document, Item, ItemId, ParaProps, SpreadRef, StoryId, Styles, story}; +use designcraft_doc::{ + Align, CharAttrs, CharProps, Content, Document, ENDNOTE_REF, FOOTNOTE_REF, Item, ItemId, ParaProps, SpreadRef, Story, StoryId, Styles, Table, + story, +}; #[derive(Clone, Debug)] pub struct EpubOptions { @@ -425,24 +430,96 @@ fn class_selector(tag: &str, class: &str) -> Option { Some(selector) } -/// One story as XHTML paragraphs. -pub fn story_html(doc: &Document, sid: StoryId) -> String { - let Some(st) = doc.story(sid) else { return String::new() }; - let (pc, cc) = (para_classes(&doc.styles), char_classes(&doc.styles)); - let mut out = String::new(); - for (pi, r) in st.para_ranges().iter().enumerate() { - let pf = &st.paras[pi]; - // Export Tagging: the style's element and class. - let (tag, class) = export_mapping(&doc.styles, &pf.style, false, &pc); - let text_empty = st.text[r.clone()].trim().is_empty(); - let rtl = doc.styles.resolve_para(pf).0.direction == designcraft_doc::TextDirection::RightToLeft; - let _ = write!(out, "<{tag} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" }); - if text_empty { - out.push_str(" "); +/// Nesting limit for stories inside stories (table cells, footnotes, tables in cells), so a deeply +/// nested or cyclic document can't recurse without bound. +const MAX_DEPTH: usize = 8; + +/// Writes stories with their tables and notes, numbering notes across the stories of one export. +struct StoryWriter<'a> { + doc: &'a Document, + pc: BTreeMap<&'a str, String>, + cc: BTreeMap<&'a str, String>, + /// EPUB structural semantics (`epub:type`); plain HTML doesn't declare the namespace. + epub: bool, + /// Notes written so far, for unique element ids. + footnote_ids: u32, + endnote_ids: u32, + /// Number shown on the next footnote and endnote of the current story. + next_footnote: u32, + next_endnote: u32, + /// Endnotes met so far: (number, list item markup). + endnotes: Vec<(u32, String)>, +} + +impl<'a> StoryWriter<'a> { + fn new(doc: &'a Document, epub: bool) -> Self { + StoryWriter { + doc, + pc: para_classes(&doc.styles), + cc: char_classes(&doc.styles), + epub, + footnote_ids: 0, + endnote_ids: 0, + next_footnote: 1, + next_endnote: 1, + endnotes: Vec::new(), } + } + + fn epub_type(&self, t: &str) -> String { + if self.epub { format!(" epub:type=\"{t}\"") } else { String::new() } + } + + /// One document story: paragraphs, tables, and each footnote after the paragraph that refers + /// to it. + fn story(&mut self, sid: StoryId) -> String { + let Some(st) = self.doc.story(sid) else { return String::new() }; + self.next_footnote = self.doc.footnote_start(sid); + self.next_endnote = self.doc.endnote_start(sid); + let mut out = String::new(); + self.paragraphs(st, 0, "", &mut out); + out + } + + /// `st`'s paragraphs. `lead` (a note's number) starts the first paragraph. + fn paragraphs(&mut self, st: &Story, depth: usize, lead: &str, out: &mut String) { + let doc = self.doc; + let mut lead = lead; + for (r, pf) in st.para_ranges().into_iter().zip(&st.paras) { + let Some(text) = st.text.get(r.clone()) else { continue }; + let table = pf.table.and_then(|id| st.tables.get(&id)).filter(|_| text.contains(story::TABLE_ANCHOR)); + let only_table = table.is_some() && text.chars().all(|c| c == story::TABLE_ANCHOR || c.is_whitespace()); + if !only_table || !lead.is_empty() { + // Export Tagging: the style's element and class. + let (tag, class) = export_mapping(&doc.styles, &pf.style, false, &self.pc); + let rtl = doc.styles.resolve_para(pf).0.direction == designcraft_doc::TextDirection::RightToLeft; + let _ = write!(out, "<{tag} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" }); + out.push_str(lead); + if lead.is_empty() && text.trim().is_empty() { + out.push_str(" "); + } + lead = ""; + let mut after = String::new(); + if !only_table { + self.runs(st, r, depth, out, &mut after); + } + let _ = writeln!(out, ""); + out.push_str(&after); + } + if let Some(t) = table + && depth < MAX_DEPTH + { + self.table(t, depth + 1, out); + } + } + } + + /// The runs of paragraph `para` of `st`; footnotes go to `after`. + fn runs(&mut self, st: &Story, para: std::ops::Range, depth: usize, out: &mut String, after: &mut String) { + let doc = self.doc; for (rr, f) in st.runs() { - let a = rr.start.max(r.start); - let b = rr.end.min(r.end); + let a = rr.start.max(para.start); + let b = rr.end.min(para.end); if a >= b { continue; } @@ -450,21 +527,26 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { if f.over.conditions.as_deref().is_some_and(|c| doc.conditions_hide(c)) || f.over.change == Some(designcraft_doc::ChangeMark::Deleted) { continue; } - let raw = &st.text[a..b]; - let t = 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(), - None => c.to_string(), - }) - .collect(); - story_markup(&sub) - } else { - story_markup(raw) - }; + let Some(raw) = st.text.get(a..b) else { continue }; + let mut t = String::new(); + let mut seg_start = 0; + for (i, c) in raw.char_indices() { + if c != FOOTNOTE_REF && c != ENDNOTE_REF { + continue; + } + t.push_str(&run_text(doc, raw.get(seg_start..i).unwrap_or_default())); + seg_start = i + c.len_utf8(); + if depth < MAX_DEPTH { + if c == FOOTNOTE_REF { + self.footnote(st, a + i, depth, &mut t, after); + } else { + self.endnote(st, a + i, depth, &mut t); + } + } + } + t.push_str(&run_text(doc, raw.get(seg_start..).unwrap_or_default())); let (ctag, cls) = if f.style != story::NO_CHAR_STYLE { - let (tag, class) = export_mapping(&doc.styles, &f.style, true, &cc); + let (tag, class) = export_mapping(&doc.styles, &f.style, true, &self.cc); (tag, format!(" class=\"{}\"", xml_attr(&class))) } else { ("span", String::new()) @@ -477,9 +559,139 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { let _ = write!(out, "<{ctag}{cls}{style_attr}>{t}"); } } - let _ = writeln!(out, ""); } - out + + /// 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 doc = self.doc; + let o = &doc.footnote_options; + let n = self.next_footnote; + self.next_footnote = n.saturating_add(1); + self.footnote_ids = self.footnote_ids.saturating_add(1); + let id = self.footnote_ids; + let _ = write!( + out, + "{}", + self.epub_type("noteref"), + xml_text(&o.label(n, false)) + ); + let lead = format!("{} ", xml_text(&o.label(n, true))); + let mut body = String::new(); + self.paragraphs(¬e.text, depth + 1, &lead, &mut body); + let _ = write!(after, "\n{body}\n", self.epub_type("footnote")); + } + + /// 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 n = self.next_endnote; + self.next_endnote = n.saturating_add(1); + self.endnote_ids = self.endnote_ids.saturating_add(1); + let id = self.endnote_ids; + let label = xml_text(&self.doc.endnote_options.label(n)); + let _ = write!(out, "{label}", self.epub_type("noteref")); + let mut body = String::new(); + self.paragraphs(¬e.text, depth + 1, &format!("{label} "), &mut body); + let item = format!("\n{body}\n", self.epub_type("endnote")); + self.endnotes.push((n, item)); + } + + /// The endnotes met so far, in number order, as one section (empty when there are none). + fn endnotes_section(&mut self) -> String { + if self.endnotes.is_empty() { + return String::new(); + } + self.endnotes.sort_by_key(|(n, _)| *n); + let doc = self.doc; + let o = &doc.endnote_options; + let mut out = format!("\n", self.epub_type("endnotes")); + if !o.heading.trim().is_empty() { + let (tag, class) = export_mapping(&doc.styles, &o.heading_style, false, &self.pc); + let _ = writeln!(out, "<{tag} class=\"{}\">{}", xml_attr(&class), xml_text(&o.heading)); + } + out.push_str("
    \n"); + for (_, item) in &self.endnotes { + out.push_str(item); + } + out.push_str("
\n\n"); + out + } + + /// A table: header rows as `thead`, footer rows as `tfoot`, merged cells as row and column + /// spans (kept within their row group). + fn table(&mut self, t: &Table, depth: usize, out: &mut String) { + let (nr, nc) = (t.nrows(), t.ncols()); + if nr.checked_mul(nc) != Some(t.cells.len()) { + return; + } + let (h, f) = (t.header_rows(), t.footer_rows()); + let body_end = nr.saturating_sub(f).max(h); + let mut covered = vec![false; t.cells.len()]; + out.push_str("
\n"); + for (rows, group, cell_tag) in [(0..h, "thead", "th"), (h..body_end, "tbody", "td"), (body_end..nr, "tfoot", "td")] { + if rows.is_empty() { + continue; + } + let _ = writeln!(out, "<{group}>"); + for r in rows.clone() { + out.push_str(""); + for c in 0..nc { + let i = r * nc + c; + if covered.get(i).copied().unwrap_or(true) { + continue; + } + let Some(cell) = t.cells.get(i) else { continue }; + let rs = (cell.row_span.max(1) as usize).min(rows.end - r); + let cs = (cell.col_span.max(1) as usize).min(nc - c); + for rr in r..r + rs { + for cc in c..c + cs { + if let Some(slot) = covered.get_mut(rr * nc + cc) { + *slot = true; + } + } + } + let _ = write!(out, "<{cell_tag}"); + if rs > 1 { + let _ = write!(out, " rowspan=\"{rs}\""); + } + if cs > 1 { + let _ = write!(out, " colspan=\"{cs}\""); + } + out.push('>'); + self.paragraphs(&cell.text, depth, "", out); + let _ = writeln!(out, ""); + } + out.push_str("\n"); + } + let _ = writeln!(out, ""); + } + out.push_str("
\n"); + } +} + +/// Run text with variables replaced by their values. +fn run_text(doc: &Document, raw: &str) -> 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(), + None => c.to_string(), + }) + .collect(); + story_markup(&sub) + } else { + story_markup(raw) + } +} + +/// One story as XHTML: paragraphs, tables, and each footnote after the paragraph that refers to +/// it. Endnote references link to the endnotes section that the full export ends with. +pub fn story_html(doc: &Document, sid: StoryId) -> String { + StoryWriter::new(doc, true).story(sid) } /// Reading order of document content: (page, y, x) of each story's first frame and of graphics. @@ -569,14 +781,19 @@ fn ext(mime: &str) -> &'static str { #[allow(clippy::type_complexity)] fn body_html<'a>( doc: &'a Document, + epub: bool, img_src: &dyn Fn(&str, &str, &[u8]) -> String, ) -> (String, BTreeMap, Vec<(String, String)>) { let order = reading_order(doc); + let mut writer = StoryWriter::new(doc, epub); let mut body = String::new(); let mut images: BTreeMap = BTreeMap::new(); let mut toc: Vec<(String, String)> = Vec::new(); 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 + // linked section instead. + Block::Story(sid) if doc.endnote_story == Some(*sid) => {} Block::Story(sid) => { let anchor = format!("s{k}"); // First paragraph text as the TOC label. @@ -587,7 +804,7 @@ fn body_html<'a>( toc.push((anchor.clone(), first)); } } - let _ = write!(body, "
\n{}
\n", story_html(doc, *sid)); + let _ = write!(body, "
\n{}
\n", writer.story(*sid)); } Block::Image(iid) => { let Some(Content::Graphic(g)) = doc.item(*iid).map(|i| &i.content) else { continue }; @@ -644,6 +861,14 @@ fn body_html<'a>( } } } + let endnotes = writer.endnotes_section(); + if !endnotes.is_empty() { + let heading = &doc.endnote_options.heading; + if !heading.trim().is_empty() { + toc.push(("endnotes".into(), heading.clone())); + } + body.push_str(&endnotes); + } (body, images, toc) } @@ -660,7 +885,7 @@ pub fn export_html(doc: &Document, opts: &HtmlOptions) -> String { let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let language = opts.language.as_deref().unwrap_or("en"); let lang = language_tag(language); - let (body, _, _) = body_html(doc, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))); + let (body, _, _) = body_html(doc, false, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))); format!( "\n\n\n\n\n\n{}\n\n\n\n{body}\n\n", xml_text(&title), @@ -686,7 +911,7 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title))); - let (body, images, toc) = body_html(doc, &|name, _, _| name.to_string()); + let (body, images, toc) = body_html(doc, true, &|name, _, _| name.to_string()); let lang = language_tag(&opts.language); let chapter = format!( "\n\n\n{}\n\n{body}\n\n", @@ -763,7 +988,7 @@ mod tests { use super::*; use designcraft_doc::build::NewDocument; use designcraft_doc::geom::{Affine, Rect, shapes}; - use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef}; + use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef, Story, StoryId, Table}; use std::io::Read; use std::sync::Arc; @@ -913,4 +1138,87 @@ mod tests { assert!(opf.contains("A & B")); assert!(opf.contains("media-type=\"image/png\"")); } + + /// "Body¹ text", a 2×2 table, then "Closing" with an endnote. + fn tables_and_notes() -> Document { + 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, "Body text", ParaFormat::default()).unwrap(); + let st = d.story_mut(sid).unwrap(); + st.insert_note(4, "Foot note text", ParaFormat::default()); + let mut t = Table::new(1, 2, 2, 0, 0, 200.0); + for (r, c, s) in [(0, 0, "Cell A1"), (0, 1, "Cell B1"), (1, 0, "Cell A2"), (1, 1, "Cell B2")] { + t.cell_mut(r, c).unwrap().text = Story::with_text(StoryId(0), s, ParaFormat::default()); + } + let end = st.len(); + st.insert_table(end, t); + let end = st.len(); + st.insert(end, "Closing"); + let end = st.len(); + st.insert_endnote(end, "End note text", ParaFormat::default()); + d + } + + #[test] + fn tables_footnotes_and_endnotes_are_exported() { + let d = tables_and_notes(); + let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut html = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); + let (t0, t1) = (html.find("").unwrap(), html.find("
").unwrap()); + for cell in ["Cell A1", "Cell B1", "Cell A2", "Cell B2"] { + let at = html.find(&format!("

{cell}

\n")).unwrap_or_else(|| panic!("{cell} in a cell:\n{html}")); + assert!(t0 < at && at < t1); + } + assert_eq!(html.matches("").count(), 2); + + let noteref = "Body1 text"; + assert!(html.contains(noteref), "footnote reference:\n{html}"); + let aside = html.find("").unwrap(); + assert!(html[aside..aside_end].contains("1 Foot note text")); + assert!(aside < t0, "the footnote follows its paragraph"); + + assert!(html.contains("Closing1")); + let section = html.find("
").unwrap(); + assert!(section > html.find("Closing").unwrap()); + assert!(html[section..].contains("
  • ")); + assert!(html[section..].contains("1 End note text")); + let mut nav = String::new(); + z.by_name("OEBPS/nav.xhtml").unwrap().read_to_string(&mut nav).unwrap(); + assert!(nav.contains("href=\"content.xhtml#endnotes\"")); + + let page = export_html(&d, &HtmlOptions::default()); + assert!(page.contains("