Merge epub-tables: fix(epub): EPUB and HTML export include tables, footnotes and endnotes (#724)

This commit is contained in:
aiongg
2026-10-13 14:31:31 +00:00
3 changed files with 347 additions and 38 deletions
+1
View File
@@ -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.
+345 -37
View File
@@ -4,6 +4,8 @@
//! frame); paragraph styles become CSS classes (`p.<slug>`), character styles become `span`
//! classes, local overrides become inline styles. Placed graphics become `<figure>` elements at
//! their position in the reading order. Parent-page items (folios, running heads) are skipped.
//! Tables become `<table>` 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<String> {
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("&#160;");
/// 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("&#160;");
}
lead = "";
let mut after = String::new();
if !only_table {
self.runs(st, r, depth, out, &mut after);
}
let _ = writeln!(out, "</{tag}>");
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<usize>, 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}</{ctag}>");
}
}
let _ = writeln!(out, "</{tag}>");
}
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,
"<sup><a{} role=\"doc-noteref\" id=\"fnref-{id}\" href=\"#fn-{id}\">{}</a></sup>",
self.epub_type("noteref"),
xml_text(&o.label(n, false))
);
let lead = format!("<a href=\"#fnref-{id}\">{}</a> ", xml_text(&o.label(n, true)));
let mut body = String::new();
self.paragraphs(&note.text, depth + 1, &lead, &mut body);
let _ = write!(after, "<aside{} role=\"doc-footnote\" id=\"fn-{id}\">\n{body}</aside>\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, "<sup><a{} role=\"doc-noteref\" id=\"enref-{id}\" href=\"#en-{id}\">{label}</a></sup>", self.epub_type("noteref"));
let mut body = String::new();
self.paragraphs(&note.text, depth + 1, &format!("<a href=\"#enref-{id}\">{label}</a> "), &mut body);
let item = format!("<li{} id=\"en-{id}\">\n{body}</li>\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!("<section{} role=\"doc-endnotes\" id=\"endnotes\">\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=\"{}\">{}</{tag}>", xml_attr(&class), xml_text(&o.heading));
}
out.push_str("<ol style=\"list-style: none; padding-left: 0\">\n");
for (_, item) in &self.endnotes {
out.push_str(item);
}
out.push_str("</ol>\n</section>\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("<table>\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("<tr>");
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, "</{cell_tag}>");
}
out.push_str("</tr>\n");
}
let _ = writeln!(out, "</{group}>");
}
out.push_str("</table>\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<String, (&'a str, &'a [u8])>, Vec<(String, String)>) {
let order = reading_order(doc);
let mut writer = StoryWriter::new(doc, epub);
let mut body = String::new();
let mut images: BTreeMap<String, (&str, &[u8])> = 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, "<section id=\"{anchor}\">\n{}</section>\n", story_html(doc, *sid));
let _ = write!(body, "<section id=\"{anchor}\">\n{}</section>\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!(
"<!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),
@@ -699,7 +924,7 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, 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!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\" lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><title>{}</title><link rel=\"stylesheet\" type=\"text/css\" href=\"style.css\"/></head>\n<body>\n{body}</body>\n</html>\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("<dc:creator>A &amp; B</dc:creator>"));
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("<table>").unwrap(), html.find("</table>").unwrap());
for cell in ["Cell A1", "Cell B1", "Cell A2", "Cell B2"] {
let at = html.find(&format!("<td><p class=\"basic-paragraph\">{cell}</p>\n</td>")).unwrap_or_else(|| panic!("{cell} in a cell:\n{html}"));
assert!(t0 < at && at < t1);
}
assert_eq!(html.matches("<tr>").count(), 2);
let noteref = "Body<sup><a epub:type=\"noteref\" role=\"doc-noteref\" id=\"fnref-1\" href=\"#fn-1\">1</a></sup> text";
assert!(html.contains(noteref), "footnote reference:\n{html}");
let aside = html.find("<aside epub:type=\"footnote\" role=\"doc-footnote\" id=\"fn-1\">").unwrap();
let aside_end = aside + html[aside..].find("</aside>").unwrap();
assert!(html[aside..aside_end].contains("<a href=\"#fnref-1\">1</a> Foot note text"));
assert!(aside < t0, "the footnote follows its paragraph");
assert!(html.contains("Closing<sup><a epub:type=\"noteref\" role=\"doc-noteref\" id=\"enref-1\" href=\"#en-1\">1</a></sup>"));
let section = html.find("<section epub:type=\"endnotes\" role=\"doc-endnotes\" id=\"endnotes\">").unwrap();
assert!(section > html.find("Closing").unwrap());
assert!(html[section..].contains("<li epub:type=\"endnote\" id=\"en-1\">"));
assert!(html[section..].contains("<a href=\"#enref-1\">1</a> 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("<aside role=\"doc-footnote\" id=\"fn-1\">") && page.contains("Cell B2") && page.contains("End note text"));
assert!(!page.contains("epub:type"), "plain HTML has no EPUB namespace");
}
#[test]
fn table_rows_groups_and_merged_cells() {
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, "", ParaFormat::default()).unwrap();
// One header row, two body rows, one footer row; the header spans both columns and the
// first body cell spans both body rows and runs into the footer.
let mut t = Table::new(1, 2, 2, 1, 1, 200.0);
for (r, c, s) in [(0, 0, "Head"), (1, 0, "Tall"), (1, 1, "B1"), (2, 1, "B2"), (3, 0, "F1"), (3, 1, "F2")] {
t.cell_mut(r, c).unwrap().text = Story::with_text(StoryId(0), s, ParaFormat::default());
}
t.cell_mut(0, 0).unwrap().col_span = 2;
t.cell_mut(1, 0).unwrap().row_span = 3;
d.story_mut(sid).unwrap().insert_table(0, t);
let html = story_html(&d, sid);
let p = |s: &str| format!("<p class=\"basic-paragraph\">{s}</p>\n");
let expected = format!(
"<table>\n<thead>\n<tr><th colspan=\"2\">{}</th>\n</tr>\n</thead>\n<tbody>\n<tr><td rowspan=\"2\">{}</td>\n<td>{}</td>\n</tr>\n<tr><td>{}</td>\n</tr>\n</tbody>\n<tfoot>\n<tr><td>{}</td>\n<td>{}</td>\n</tr>\n</tfoot>\n</table>\n",
p("Head"),
p("Tall"),
p("B1"),
p("B2"),
p("F1"),
p("F2")
);
assert!(html.contains(&expected), "{html}");
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ score below).
| PDF (print) | `.pdf` | ✓ all presets, PDF/X-1a/3/4, PDF/A, PDF/UA | ✓ krilla: real text with subset fonts, DeviceCMYK/RGB, spot Separations, bleed/slug, marks, spreads, compression options, remembered settings (#196); **PDF/X-4** with output intent and a validator (#223, #200); **PDF/A-2b**; tagged PDF | 53 tests in `crates/pdf`; no PDF/X-1a or X-3 (needs flattening to PDF 1.3/1.4); no presets / `.joboptions`; no external validator (veraPDF / callas) in CI | 18% |
| PDF (interactive) | `.pdf` | ✓ | ✓ links, bookmarks, buttons, AcroForm fields, page transitions, object states | | 2% |
| JPEG / PNG | `.jpg`, `.png` | ✓ | PNG from the UI; JPEG from the CLI only | no resolution / colour space options dialog for JPEG | 3% |
| EPUB reflowable | `.epub` | ✓ | ✓ stories in article order, CSS from styles, images, nav | | 2% |
| EPUB reflowable | `.epub` | ✓ | ✓ stories in article order, tables, footnotes and endnotes, CSS from styles, images, nav | | 2% |
| EPUB fixed layout | `.epub` | ✓ (live HTML/CSS) | ~ page images with text kept for search | not live text | 1% |
| HTML | `.html` | ✓ | ~ one self-contained page (#314) | | 1% |
| HTML5 ZIP / Publish Online | | ✓ | ✗ (Publish Online out of scope) | | <1% |