diff --git a/ROADMAP.md b/ROADMAP.md index bba19216f..4ef94eb52 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,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-13** · Word import: table cells read text by the same rules as body text, so deleted text, moved-away text and field codes stay out (#721). Word's Normal comes in as its own paragraph style "Normal" over the document defaults, so text formatted only by Normal keeps its look; it conflicts only with a document's own Normal, and the style map can send it to [Basic Paragraph] (#722). No readiness percentage change. - **2026-10-13** · Text recomposes when the document baseline grid changes, when a layer holding a text-wrap object is shown or hidden, or when a section's start number, numbering style, prefix or marker changes; the composition cache keyed only on the section count and item identities and kept the old layout. No readiness percentage change. - **2026-10-13** · Path edits (`path.setAnchor`, `path.moveAnchors`, `path.appendAnchor`, `path.reverse`, `path.close`, anchor add/delete/convert, Erase, Smooth and Scissors) refuse a path that is locked, sits in a locked group or on a locked layer (#735). Properties links to the Stroke, Character, Paragraph, Effects, Text Wrap and Info panels open them in a customized panel layout too (#736). No readiness percentage change. diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 6ee9ca157..f08b6253b 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)] @@ -12,7 +14,10 @@ use std::fmt::Write as _; use std::io::Write; use designcraft_doc::geom::{Affine, Rect}; -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 { @@ -426,24 +431,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; } @@ -451,21 +528,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()) @@ -478,9 +560,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. @@ -582,14 +794,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. @@ -600,7 +817,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 }; @@ -657,6 +874,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) } @@ -673,7 +898,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), @@ -699,7 +924,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", @@ -776,7 +1001,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; @@ -927,4 +1152,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("