mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 04:06:54 -06:00
refactor(epub): split lib.rs into modules
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
use super::css::{export_mapping, para_classes};
|
||||
use super::html::Block;
|
||||
use super::markup::xml_text;
|
||||
use super::*;
|
||||
|
||||
/// Whether an EPUB chapter file starts before a paragraph of `style` (Split Document): never
|
||||
/// when it's off, before each paragraph of the one style it names, or before each paragraph of
|
||||
/// a style whose Export Tagging has Split Document on.
|
||||
pub(crate) fn starts_chapter(split: &EpubSplit, st: &Styles, style: &str) -> bool {
|
||||
match split {
|
||||
EpubSplit::Off => false,
|
||||
EpubSplit::Style(name) => name == style,
|
||||
EpubSplit::ExportTags => st.export_tag(style, false).is_some_and(|e| e.split),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the EPUB body splits into chapter files (see [`starts_chapter`]).
|
||||
pub(crate) const CHAPTER_MARK: &str = "<!--dc:chapter-->";
|
||||
|
||||
/// The body split at its [`CHAPTER_MARK`]s into chapter bodies, each with its sections closed
|
||||
/// and the one a split goes through reopened in the next; a part with nothing but the start of a
|
||||
/// section (a story that opens with its `h1`) moves that start into the next part.
|
||||
pub(crate) fn split_chapters(body: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut carry: Option<String> = None;
|
||||
for piece in body.split(CHAPTER_MARK) {
|
||||
let mut part = carry.take().unwrap_or_default();
|
||||
part.push_str(piece);
|
||||
if let Some(open) = part.rfind("<section").filter(|i| !part[*i..].contains("</section>")) {
|
||||
let tag_end = part[open..].find('>').map_or(part.len(), |e| open + e + 1);
|
||||
if part[tag_end..].trim().is_empty() {
|
||||
carry = Some(part[open..].to_string());
|
||||
part.truncate(open);
|
||||
} else {
|
||||
part.push_str("</section>\n");
|
||||
carry = Some("<section>\n".to_string());
|
||||
}
|
||||
}
|
||||
if !part.trim().is_empty() {
|
||||
out.push(part);
|
||||
}
|
||||
}
|
||||
if let Some(rest) = carry.filter(|c| !c.trim().is_empty())
|
||||
&& let Some(last) = out.last_mut()
|
||||
{
|
||||
// Only an opening tag can be left over; close it where it stands.
|
||||
last.push_str(&rest);
|
||||
last.push_str("</section>\n");
|
||||
}
|
||||
if out.is_empty() {
|
||||
out.push(String::new());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `chapter` (the file `own`) with each in-document link (`href="#id"`) to an element in another
|
||||
/// chapter file pointed at that file.
|
||||
pub(crate) fn link_across_chapters(chapter: &str, own: &str, files: &BTreeMap<String, String>) -> String {
|
||||
const HREF: &str = "href=\"#";
|
||||
let mut out = String::with_capacity(chapter.len());
|
||||
let mut rest = chapter;
|
||||
while let Some(i) = rest.find(HREF) {
|
||||
out.push_str(rest.get(..i).unwrap_or_default());
|
||||
let tail = rest.get(i + HREF.len()..).unwrap_or_default();
|
||||
let id = tail.split('"').next().unwrap_or_default();
|
||||
match files.get(id) {
|
||||
Some(file) if file != own => {
|
||||
let _ = write!(out, "href=\"{file}#");
|
||||
}
|
||||
_ => out.push_str(HREF),
|
||||
}
|
||||
rest = tail;
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// The package file of chapter `k` (0-based): `content.xhtml`, then `content2.xhtml`, ….
|
||||
pub(crate) fn chapter_file(k: usize) -> String {
|
||||
if k == 0 { "content.xhtml".to_string() } else { format!("content{}.xhtml", k + 1) }
|
||||
}
|
||||
|
||||
/// The level of a heading element (`h1` → 1 … `h6` → 6).
|
||||
pub(crate) fn heading_level(tag: &str) -> Option<u8> {
|
||||
tag.strip_prefix('h').and_then(|n| n.parse::<u8>().ok()).filter(|n| (1..=6).contains(n))
|
||||
}
|
||||
|
||||
/// Table of contents entries (target, label, level): the `h1`–`h3` paragraphs of the stories in
|
||||
/// reading order, or, in a document without headings, each story's first paragraph (level 1).
|
||||
pub(crate) fn toc_entries(doc: &Document, order: &[Block]) -> Vec<(String, String, u8)> {
|
||||
let pc = para_classes(&doc.styles);
|
||||
let label = |t: &str| -> String { t.chars().filter(|c| !('\u{E000}'..='\u{E0FF}').contains(c)).take(80).collect::<String>().trim().to_string() };
|
||||
let mut headings = Vec::new();
|
||||
let mut firsts = Vec::new();
|
||||
for (k, b) in order.iter().enumerate() {
|
||||
let Block::Story(sid) = b else { continue };
|
||||
// The endnote story isn't written (the endnotes section stands in for it).
|
||||
if doc.endnote_story == Some(*sid) {
|
||||
continue;
|
||||
}
|
||||
let Some(st) = doc.story(*sid) else { continue };
|
||||
let first = label(st.text.split('\n').next().unwrap_or(""));
|
||||
if !first.is_empty() {
|
||||
firsts.push((format!("s{k}"), first, 1));
|
||||
}
|
||||
for (pi, r) in st.para_ranges().iter().enumerate() {
|
||||
let Some(pf) = st.paras.get(pi) else { continue };
|
||||
let (tag, _) = export_mapping(&doc.styles, &pf.style, false, &pc);
|
||||
let Some(level) = heading_level(tag).filter(|l| *l <= 3) else { continue };
|
||||
let text = label(st.text.get(r.clone()).unwrap_or(""));
|
||||
if !text.is_empty() {
|
||||
headings.push((format!("s{k}-p{pi}"), text, level));
|
||||
}
|
||||
}
|
||||
}
|
||||
if headings.is_empty() { firsts } else { headings }
|
||||
}
|
||||
|
||||
/// The table of contents as nested `li`/`ol` items (the content of the navigation's outer
|
||||
/// `ol`): a deeper level goes into the previous item, skipped levels count as one.
|
||||
pub(crate) fn nav_list(entries: &[(String, String, u8)], files: &BTreeMap<String, String>) -> String {
|
||||
let mut out = String::new();
|
||||
let mut open: Vec<u8> = Vec::new();
|
||||
for (target, label, level) in entries {
|
||||
let file = files.get(target).map_or("content.xhtml", String::as_str);
|
||||
let item = format!("<li><a href=\"{file}#{target}\">{}</a>", xml_text(label));
|
||||
match open.last().copied() {
|
||||
None => open.push(*level),
|
||||
Some(top) if *level > top => {
|
||||
out.push_str("<ol>");
|
||||
open.push(*level);
|
||||
}
|
||||
Some(_) => {
|
||||
// Close the levels whose parent is as deep as this entry or deeper.
|
||||
while open.len() > 1 && open.len().checked_sub(2).and_then(|i| open.get(i)).is_some_and(|parent| *parent >= *level) {
|
||||
open.pop();
|
||||
out.push_str("</li></ol>");
|
||||
}
|
||||
out.push_str("</li>");
|
||||
if let Some(t) = open.last_mut() {
|
||||
*t = (*t).min(*level);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(&item);
|
||||
}
|
||||
for i in (0..open.len()).rev() {
|
||||
out.push_str("</li>");
|
||||
if i > 0 {
|
||||
out.push_str("</ol>");
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
use super::fonts::{EmbeddedFont, css_weight_style, font_file, generic_family};
|
||||
use super::markup::{css_string, slug, valid_xml_char};
|
||||
use super::*;
|
||||
|
||||
/// Class names for `names`, in order: [`slug`]s made unique with a `-2`, `-3`… suffix, so styles
|
||||
/// whose names slug alike ("Body Text" / "Body-Text", or non-ASCII names) keep their own rules.
|
||||
fn class_names<'a>(names: impl Iterator<Item = &'a str>) -> BTreeMap<&'a str, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut used = std::collections::BTreeSet::new();
|
||||
for name in names {
|
||||
let base = slug(name);
|
||||
let mut class = base.clone();
|
||||
let mut n = 1;
|
||||
while !used.insert(class.clone()) {
|
||||
n += 1;
|
||||
class = format!("{base}-{n}");
|
||||
}
|
||||
out.insert(name, class);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn para_classes(st: &Styles) -> BTreeMap<&str, String> {
|
||||
class_names(st.paragraph.iter().map(|s| s.name.as_str()))
|
||||
}
|
||||
|
||||
pub(crate) fn char_classes(st: &Styles) -> BTreeMap<&str, String> {
|
||||
class_names(st.character.iter().map(|s| s.name.as_str()))
|
||||
}
|
||||
|
||||
fn hex(doc: &Document, swatch: &str, tint: f32) -> Option<String> {
|
||||
let c = doc.resolve_color(swatch, tint)?;
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
Some(format!("#{r:02x}{g:02x}{b:02x}"))
|
||||
}
|
||||
|
||||
pub(crate) fn char_css(doc: &Document, c: &CharProps, base_size: f64, generics: &BTreeMap<String, String>, out: &mut String) {
|
||||
let _ = write!(out, "font-family: \"{}\", {}; ", css_string(&c.font_family), generic_family(&c.font_family, generics));
|
||||
// The face's own weight, so an embedded Semibold or Light face is the one chosen.
|
||||
let (weight, style) = css_weight_style(&c.font_style);
|
||||
let _ = write!(out, "font-weight: {weight}; font-style: {style}; ");
|
||||
let _ = write!(out, "font-size: {:.3}em; ", c.size / base_size);
|
||||
if c.tracking != 0.0 {
|
||||
let _ = write!(out, "letter-spacing: {:.3}em; ", c.tracking / 1000.0);
|
||||
}
|
||||
match c.capitalization {
|
||||
designcraft_doc::Capitalization::AllCaps => out.push_str("text-transform: uppercase; "),
|
||||
designcraft_doc::Capitalization::SmallCaps | designcraft_doc::Capitalization::OpenTypeAllSmallCaps => {
|
||||
out.push_str("font-variant: small-caps; ")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// One declaration: a later `text-decoration` would replace the earlier one.
|
||||
match (c.underline, c.strikethrough) {
|
||||
(true, true) => out.push_str("text-decoration: underline line-through; "),
|
||||
(true, false) => out.push_str("text-decoration: underline; "),
|
||||
(false, true) => out.push_str("text-decoration: line-through; "),
|
||||
(false, false) => {}
|
||||
}
|
||||
if let Some(h) = hex(doc, &c.fill, c.fill_tint) {
|
||||
let _ = write!(out, "color: {h}; ");
|
||||
}
|
||||
}
|
||||
|
||||
fn para_css(p: &ParaProps, base_size: f64, line_px: f64, out: &mut String) {
|
||||
let align = match p.align {
|
||||
Align::Left | Align::TowardsSpine => "left",
|
||||
Align::Center => "center",
|
||||
Align::Right | Align::AwayFromSpine => "right",
|
||||
_ => "justify",
|
||||
};
|
||||
let _ = write!(out, "text-align: {align}; ");
|
||||
let em = |v: f64| v / base_size;
|
||||
let _ = write!(
|
||||
out,
|
||||
"margin: {:.3}em {:.3}em {:.3}em {:.3}em; text-indent: {:.3}em; line-height: {:.3}; ",
|
||||
em(p.space_before),
|
||||
em(p.right_indent),
|
||||
em(p.space_after),
|
||||
em(p.left_indent),
|
||||
em(p.first_line_indent),
|
||||
line_px
|
||||
);
|
||||
out.push_str(if p.hyphenate { "hyphens: auto; -webkit-hyphens: auto; " } else { "hyphens: manual; " });
|
||||
}
|
||||
|
||||
/// The stylesheet for all paragraph and character styles.
|
||||
pub fn stylesheet(doc: &Document) -> String {
|
||||
stylesheet_with(doc, &BTreeMap::new(), &[])
|
||||
}
|
||||
|
||||
/// [`stylesheet`] with the generic families to fall back on and `@font-face` rules for the
|
||||
/// embedded fonts (packaged as [`font_file`] names them).
|
||||
pub(crate) fn stylesheet_with(doc: &Document, generics: &BTreeMap<String, String>, fonts: &[EmbeddedFont]) -> String {
|
||||
let st: &Styles = &doc.styles;
|
||||
let mut faces = String::new();
|
||||
for (k, f) in fonts.iter().enumerate() {
|
||||
let (path, _) = font_file(k, f);
|
||||
let (weight, style) = css_weight_style(&f.style);
|
||||
let _ = writeln!(
|
||||
faces,
|
||||
"@font-face {{ font-family: \"{}\"; src: url(\"{path}\"); font-weight: {weight}; font-style: {style}; }}",
|
||||
css_string(&f.family)
|
||||
);
|
||||
}
|
||||
let (_, basic) = st.resolve_para_style(story::BASIC_PARAGRAPH);
|
||||
let base = basic.size.max(1.0);
|
||||
let mut css = faces;
|
||||
css.push_str(
|
||||
"body { margin: 0 5%; font-size: 1em; }\nfigure { margin: 1em 0; text-align: center; }\nfigure img { max-width: 100%; }\nh1, h2, h3 { font-size: inherit; margin: 0; }\n",
|
||||
);
|
||||
let (pc, cc) = (para_classes(st), char_classes(st));
|
||||
for ps in &st.paragraph {
|
||||
if ps.name == designcraft_doc::NO_PARA_STYLE {
|
||||
continue;
|
||||
}
|
||||
let (pp, cp) = st.resolve_para_style(&ps.name);
|
||||
let lead = match cp.leading {
|
||||
designcraft_doc::Leading::Auto => pp.auto_leading,
|
||||
designcraft_doc::Leading::Points(v) => v / cp.size.max(1.0),
|
||||
};
|
||||
let mut rule = String::new();
|
||||
para_css(&pp, base, lead, &mut rule);
|
||||
char_css(doc, &cp, base, generics, &mut rule);
|
||||
let (tag, class) = export_mapping(st, &ps.name, false, &pc);
|
||||
if let Some(selector) = class_selector(tag, &class) {
|
||||
let _ = writeln!(css, "{selector} {{ {rule}}}");
|
||||
}
|
||||
}
|
||||
for cs in &st.character {
|
||||
if cs.name == story::NO_CHAR_STYLE {
|
||||
continue;
|
||||
}
|
||||
let mut rule = String::new();
|
||||
let c = &cs.chars;
|
||||
if let Some(f) = &c.font_style {
|
||||
let f = f.to_lowercase();
|
||||
if f.contains("bold") {
|
||||
rule.push_str("font-weight: bold; ");
|
||||
}
|
||||
if f.contains("italic") {
|
||||
rule.push_str("font-style: italic; ");
|
||||
}
|
||||
}
|
||||
if let Some(fill) = &c.fill
|
||||
&& let Some(h) = hex(doc, fill, c.fill_tint.unwrap_or(1.0))
|
||||
{
|
||||
let _ = write!(rule, "color: {h}; ");
|
||||
}
|
||||
if let Some(sz) = c.size {
|
||||
let _ = write!(rule, "font-size: {:.3}em; ", sz / base);
|
||||
}
|
||||
let (tag, class) = export_mapping(st, &cs.name, true, &cc);
|
||||
if let Some(selector) = class_selector(tag, &class) {
|
||||
let _ = writeln!(css, "{selector} {{ {rule}}}");
|
||||
}
|
||||
}
|
||||
css
|
||||
}
|
||||
|
||||
pub(crate) fn override_css(doc: &Document, o: &CharAttrs) -> String {
|
||||
let mut s = String::new();
|
||||
if let Some(f) = &o.font_style {
|
||||
let f = f.to_lowercase();
|
||||
if f.contains("bold") {
|
||||
s.push_str("font-weight: bold; ");
|
||||
}
|
||||
if f.contains("italic") {
|
||||
s.push_str("font-style: italic; ");
|
||||
}
|
||||
}
|
||||
if let Some(fill) = &o.fill
|
||||
&& let Some(h) = hex(doc, fill, o.fill_tint.unwrap_or(1.0))
|
||||
{
|
||||
let _ = write!(s, "color: {h}; ");
|
||||
}
|
||||
if o.underline == Some(true) {
|
||||
s.push_str("text-decoration: underline; ");
|
||||
}
|
||||
if o.capitalization == Some(designcraft_doc::Capitalization::AllCaps) {
|
||||
s.push_str("text-transform: uppercase; ");
|
||||
}
|
||||
s.trim_end().to_string()
|
||||
}
|
||||
|
||||
/// Elements Export Tagging may choose (paragraph or character level).
|
||||
fn is_tag(t: &str, character: bool) -> bool {
|
||||
if character {
|
||||
matches!(t, "span" | "em" | "strong" | "i" | "b" | "code" | "sup" | "sub" | "small" | "mark" | "cite" | "q")
|
||||
} else {
|
||||
matches!(t, "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote" | "pre" | "li" | "figcaption" | "aside" | "div")
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve Export Tagging once for both markup and its stylesheet. Keep the generated,
|
||||
/// collision-free class when no custom class was supplied, and the existing tag fallback.
|
||||
pub(crate) fn export_mapping<'a>(st: &'a Styles, name: &str, character: bool, classes: &BTreeMap<&str, String>) -> (&'a str, String) {
|
||||
let et = st.export_tag(name, character);
|
||||
let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, character)).unwrap_or(if character { "span" } else { "p" });
|
||||
let class = et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| classes.get(name).cloned()).unwrap_or_else(|| slug(name));
|
||||
// Match the characters xml_attr actually emits, before escaping either output context.
|
||||
let class = class.chars().filter(|c| valid_xml_char(*c) && !('\u{E000}'..='\u{E1FF}').contains(c)).collect();
|
||||
(tag, class)
|
||||
}
|
||||
|
||||
/// Each class token must match on the same element. Escape CSS identifier punctuation and
|
||||
/// leading digits without changing the class written into XHTML.
|
||||
fn class_selector(tag: &str, class: &str) -> Option<String> {
|
||||
let mut tokens = class.split_ascii_whitespace().peekable();
|
||||
// A nonempty but whitespace-only custom class has no token to select. Never emit a bare
|
||||
// element selector, which would apply this style to unrelated paragraphs or character runs.
|
||||
tokens.peek()?;
|
||||
let mut selector = tag.to_string();
|
||||
for token in tokens {
|
||||
selector.push('.');
|
||||
for (i, c) in token.chars().enumerate() {
|
||||
let leading_digit = c.is_ascii_digit() && (i == 0 || i == 1 && token.starts_with('-'));
|
||||
if !leading_digit && token != "-" && (c.is_ascii_alphanumeric() || matches!(c, '-' | '_') || !c.is_ascii()) {
|
||||
selector.push(c);
|
||||
} else {
|
||||
let _ = write!(selector, "\\{:x} ", c as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(selector)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
use super::*;
|
||||
|
||||
/// A font file packaged with an EPUB: the face of `family` in `style` (`Bold Italic`…).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EmbeddedFont {
|
||||
pub family: String,
|
||||
pub style: String,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The `(family, style)` pairs the stylesheet names, one each, in style order: the fonts an
|
||||
/// export can embed.
|
||||
pub fn stylesheet_fonts(doc: &Document) -> Vec<(String, String)> {
|
||||
let st: &Styles = &doc.styles;
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
for ps in &st.paragraph {
|
||||
if ps.name == designcraft_doc::NO_PARA_STYLE {
|
||||
continue;
|
||||
}
|
||||
let (_, cp) = st.resolve_para_style(&ps.name);
|
||||
let f = (cp.font_family.clone(), cp.font_style.clone());
|
||||
if !f.0.is_empty() && !out.contains(&f) {
|
||||
out.push(f);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The CSS generic family for `family`: the known one, else a guess from the name
|
||||
/// (`… Mono`/`Courier`/`… Code` → monospace, `… Sans`/`Gothic`/`Grotesk`/`Helvetica`/`Arial`/
|
||||
/// `Verdana` → sans-serif), else serif.
|
||||
pub(crate) fn generic_family<'a>(family: &str, known: &'a BTreeMap<String, String>) -> &'a str {
|
||||
const GENERIC: [&str; 6] = ["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui"];
|
||||
if let Some(g) = known.get(family).and_then(|g| GENERIC.iter().find(|x| **x == g.as_str())) {
|
||||
return g;
|
||||
}
|
||||
let f = family.to_lowercase();
|
||||
let has = |words: &[&str]| words.iter().any(|w| f.contains(w));
|
||||
if has(&["mono", "courier", " code", "consol"]) {
|
||||
"monospace"
|
||||
} else if has(&["sans", "gothic", "grotesk", "grotesque", "helvetica", "arial", "verdana"]) {
|
||||
"sans-serif"
|
||||
} else {
|
||||
"serif"
|
||||
}
|
||||
}
|
||||
|
||||
/// CSS weight and style of a style name (`Semibold Italic` → 600, italic).
|
||||
pub(crate) fn css_weight_style(style: &str) -> (u16, &'static str) {
|
||||
let s = style.to_lowercase().replace([' ', '-'], "");
|
||||
let weight = [
|
||||
("thin", 100),
|
||||
("extralight", 200),
|
||||
("ultralight", 200),
|
||||
("light", 300),
|
||||
("medium", 500),
|
||||
("semibold", 600),
|
||||
("demibold", 600),
|
||||
("extrabold", 800),
|
||||
("ultrabold", 800),
|
||||
("bold", 700),
|
||||
("black", 900),
|
||||
("heavy", 900),
|
||||
]
|
||||
.iter()
|
||||
.find(|(n, _)| s.contains(n))
|
||||
.map_or(400, |(_, w)| *w);
|
||||
(weight, if s.contains("italic") || s.contains("oblique") { "italic" } else { "normal" })
|
||||
}
|
||||
|
||||
/// Package path and media type of an embedded font (`fonts/{k}-{family}-{style}.{ext}`).
|
||||
pub(crate) fn font_file(k: usize, f: &EmbeddedFont) -> (String, &'static str) {
|
||||
let (ext, mime) = match f.data.get(..4) {
|
||||
Some(b"OTTO") => ("otf", "font/otf"),
|
||||
Some(b"wOFF") => ("woff", "font/woff"),
|
||||
Some(b"wOF2") => ("woff2", "font/woff2"),
|
||||
_ => ("ttf", "font/ttf"),
|
||||
};
|
||||
let name: String =
|
||||
format!("{}-{}", f.family, f.style).chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect();
|
||||
let name = name.split('-').filter(|p| !p.is_empty()).collect::<Vec<_>>().join("-");
|
||||
(format!("fonts/{k}-{name}.{ext}"), mime)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
use super::chapters::toc_entries;
|
||||
use super::css::stylesheet_with;
|
||||
use super::markup::{language_tag, xml_attr, xml_text};
|
||||
use super::story_writer::StoryWriter;
|
||||
use super::*;
|
||||
|
||||
/// Reading order of document content: (page, y, x) of each story's first frame and of graphics.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Block {
|
||||
Story(StoryId),
|
||||
Image(ItemId),
|
||||
}
|
||||
|
||||
pub fn reading_order(doc: &Document) -> Vec<Block> {
|
||||
// Articles, when there are any to export, decide the order (and what's included).
|
||||
if doc.articles.iter().any(|a| a.export) {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = Vec::new();
|
||||
for a in doc.articles.iter().filter(|a| a.export) {
|
||||
for id in &a.items {
|
||||
let Some(top) = doc.item(*id) else { continue };
|
||||
let mut push = |it: &Item| match &it.content {
|
||||
Content::Text(tf) if !seen.contains(&tf.story) => {
|
||||
seen.push(tf.story);
|
||||
out.push(Block::Story(tf.story));
|
||||
}
|
||||
Content::Graphic(_) => out.push(Block::Image(it.id)),
|
||||
_ => {}
|
||||
};
|
||||
top.walk(&mut |i| push(i));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let mut keyed: Vec<((usize, i64, i64), Block)> = Vec::new();
|
||||
let mut seen_stories = Vec::new();
|
||||
for (si, sp) in doc.spreads.iter().enumerate() {
|
||||
let first = doc.first_page_of_spread(si);
|
||||
// Preserve traversal order while carrying each group's transform to spread space.
|
||||
// The iterator stack grows with nesting depth, without repeated document lookups.
|
||||
let mut stack = vec![(sp.items.iter(), Affine::IDENTITY)];
|
||||
while let Some((items, parent)) = stack.last_mut() {
|
||||
let Some(it) = items.next() else {
|
||||
stack.pop();
|
||||
continue;
|
||||
};
|
||||
let xf = *parent * it.xf;
|
||||
let children = it.children();
|
||||
if !children.is_empty() {
|
||||
stack.push((children.iter(), xf));
|
||||
}
|
||||
if it.hidden {
|
||||
continue;
|
||||
}
|
||||
let block = match &it.content {
|
||||
Content::Text(tf) => {
|
||||
let Some(story) = doc.story(tf.story) else { continue };
|
||||
// A story is placed where its first frame is.
|
||||
if story.frames.first() != Some(&it.id) || seen_stories.contains(&tf.story) || story.text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
seen_stories.push(tf.story);
|
||||
Block::Story(tf.story)
|
||||
}
|
||||
Content::Graphic(_) => Block::Image(it.id),
|
||||
_ => continue,
|
||||
};
|
||||
// Transform the path once: rotating a local axis-aligned box again would
|
||||
// enlarge it and give a different top-left from the actual spread geometry.
|
||||
let b = it.path.transformed(xf).bounds().unwrap_or(Rect::ZERO);
|
||||
let page = first + sp.page_at_x(b.center().x).unwrap_or(0);
|
||||
let key = (page, (b.y0 * 10.0) as i64, (b.x0 * 10.0) as i64);
|
||||
keyed.push((key, block));
|
||||
}
|
||||
}
|
||||
let _ = SpreadRef::Doc(0);
|
||||
keyed.sort_by_key(|(k, _)| *k);
|
||||
keyed.into_iter().map(|(_, b)| b).collect()
|
||||
}
|
||||
|
||||
/// Package path of an asset: `{dir}/{name}-{id}.{ext}`, the name from its original file name
|
||||
/// (lowercase ASCII, so the package stays readable) and the id keeping two assets of the same
|
||||
/// name apart.
|
||||
pub(crate) fn asset_path(dir: &str, a: &designcraft_doc::Asset, mime: &str) -> String {
|
||||
let stem = a.name.rsplit(['/', '\\']).next().unwrap_or("");
|
||||
let stem = stem.rsplit_once('.').map_or(stem, |(s, _)| s);
|
||||
let name: String = stem.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect();
|
||||
let name: String = name.split('-').filter(|p| !p.is_empty()).collect::<Vec<_>>().join("-").chars().take(40).collect();
|
||||
if name.is_empty() { format!("{dir}/{}.{}", a.id.0, ext(mime)) } else { format!("{dir}/{name}-{}.{}", a.id.0, ext(mime)) }
|
||||
}
|
||||
|
||||
fn ext(mime: &str) -> &'static str {
|
||||
match mime {
|
||||
"image/jpeg" => "jpg",
|
||||
"image/gif" => "gif",
|
||||
"image/webp" => "webp",
|
||||
"image/svg+xml" => "svg",
|
||||
"video/mp4" => "mp4",
|
||||
"video/quicktime" => "mov",
|
||||
"video/webm" => "webm",
|
||||
"video/ogg" => "ogv",
|
||||
"audio/mpeg" => "mp3",
|
||||
"audio/mp4" => "m4a",
|
||||
"audio/wav" => "wav",
|
||||
"audio/ogg" => "ogg",
|
||||
"audio/aac" => "aac",
|
||||
_ => "png",
|
||||
}
|
||||
}
|
||||
|
||||
/// The document content as HTML, with the files it names.
|
||||
pub(crate) struct Body<'a> {
|
||||
pub(crate) html: String,
|
||||
/// Images, video and sound by package name: (media type, bytes).
|
||||
pub(crate) images: BTreeMap<String, (&'a str, &'a [u8])>,
|
||||
/// Table of contents: (element id, label, level).
|
||||
pub(crate) toc: Vec<(String, String, u8)>,
|
||||
/// Asset names of the images written without alt text.
|
||||
no_alt: Vec<String>,
|
||||
}
|
||||
|
||||
/// The content in reading order as HTML sections and figures; `img_src` names each image
|
||||
/// (a package path, or a data URI for single-file HTML).
|
||||
pub(crate) fn body_html<'a>(doc: &'a Document, epub: bool, split: Option<&EpubSplit>, img_src: &dyn Fn(&str, &str, &[u8]) -> String) -> Body<'a> {
|
||||
let order = reading_order(doc);
|
||||
let mut writer = StoryWriter::new(doc, epub, Some(img_src));
|
||||
if let Some(split) = split {
|
||||
writer.split = split.clone();
|
||||
}
|
||||
let mut body = String::new();
|
||||
let mut toc = toc_entries(doc, &order);
|
||||
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}");
|
||||
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((markup, media)) = writer.graphic(it) else { continue };
|
||||
if media {
|
||||
let _ = writeln!(body, "<figure>{markup}</figure>");
|
||||
continue;
|
||||
}
|
||||
// Object Export Options: alignment and a page break before.
|
||||
let eo = &it.export_options;
|
||||
let mut css = String::new();
|
||||
if matches!(eo.align.as_str(), "left" | "center" | "right") {
|
||||
css.push_str(&format!("text-align:{};", eo.align));
|
||||
}
|
||||
if eo.page_break_before {
|
||||
css.push_str("page-break-before:always;break-before:page;");
|
||||
}
|
||||
let style = if css.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&css)) };
|
||||
let _ = writeln!(body, "<figure{style}>{markup}</figure>");
|
||||
}
|
||||
}
|
||||
}
|
||||
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(), 1));
|
||||
}
|
||||
body.push_str(&endnotes);
|
||||
}
|
||||
Body { html: body, images: writer.images, toc, no_alt: writer.no_alt }
|
||||
}
|
||||
|
||||
/// Placed and anchored images (not video or sound) without alt text, by asset name: they are exported as
|
||||
/// decorative (`alt=""`), which an export can report.
|
||||
pub fn images_without_alt(doc: &Document) -> Vec<String> {
|
||||
body_html(doc, true, None, &|name, _, _| name.to_string()).no_alt
|
||||
}
|
||||
|
||||
/// Options for [`export_html`].
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct HtmlOptions {
|
||||
pub title: Option<String>,
|
||||
pub language: Option<String>,
|
||||
/// Generic CSS family per font family (see [`EpubOptions::generic_families`]).
|
||||
pub generic_families: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// 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;
|
||||
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),
|
||||
stylesheet_with(doc, &opts.generic_families, &[])
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn base64(data: &[u8]) -> String {
|
||||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
||||
for c in data.chunks(3) {
|
||||
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
|
||||
for (i, shift) in [18, 12, 6, 0].into_iter().enumerate() {
|
||||
out.push(if i <= c.len() { T[(n >> shift & 63) as usize] as char } else { '=' });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
+25
-1437
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
use std::fmt::Write as _;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EpubError(pub String);
|
||||
|
||||
pub(crate) fn valid_xml_char(c: char) -> bool {
|
||||
matches!(c, '\u{9}' | '\u{A}' | '\u{D}' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
|
||||
}
|
||||
|
||||
fn escape_markup(s: &str, attr: bool, story_breaks: bool) -> String {
|
||||
let mut o = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => o.push_str("&"),
|
||||
'<' => o.push_str("<"),
|
||||
'>' => o.push_str(">"),
|
||||
'"' => o.push_str("""),
|
||||
'\'' if attr => o.push_str("'"),
|
||||
'\u{E000}'..='\u{E1FF}' => {}
|
||||
'\u{2028}' if story_breaks => o.push_str("<br/>"),
|
||||
'\u{AD}' => o.push_str("­"),
|
||||
c if valid_xml_char(c) => o.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
pub(crate) fn xml_text(s: &str) -> String {
|
||||
escape_markup(s, false, false)
|
||||
}
|
||||
|
||||
pub(crate) fn xml_attr(s: &str) -> String {
|
||||
escape_markup(s, true, false)
|
||||
}
|
||||
|
||||
/// Story content permits the document's discretionary line-separator marker to become markup.
|
||||
/// Other contexts always serialize it as ordinary text.
|
||||
pub(crate) fn story_markup(s: &str) -> String {
|
||||
escape_markup(s, false, true)
|
||||
}
|
||||
|
||||
/// A conservative BCP 47 subset suitable for both HTML and EPUB language attributes.
|
||||
pub(crate) fn language_tag(s: &str) -> &str {
|
||||
if s.len() > 63 {
|
||||
return "und";
|
||||
}
|
||||
let mut parts = s.split('-');
|
||||
let Some(first) = parts.next() else { return "und" };
|
||||
if !(2..=8).contains(&first.len()) || !first.bytes().all(|b| b.is_ascii_alphabetic()) {
|
||||
return "und";
|
||||
}
|
||||
if parts.any(|p| p.is_empty() || p.len() > 8 || !p.bytes().all(|b| b.is_ascii_alphanumeric())) {
|
||||
return "und";
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Return a MIME type that is safe to place in an attribute and a data URL. Parameters are omitted
|
||||
/// because exported assets are always represented as their original binary bytes.
|
||||
pub(crate) fn safe_mime(mime: &str) -> &str {
|
||||
fn token(s: &str) -> bool {
|
||||
!s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'+' | b'-'))
|
||||
}
|
||||
|
||||
let mime = mime.split_once(';').map_or(mime, |(essence, _)| essence).trim();
|
||||
if mime.len() <= 127
|
||||
&& let Some((kind, subtype)) = mime.split_once('/')
|
||||
&& !subtype.contains('/')
|
||||
&& token(kind)
|
||||
&& token(subtype)
|
||||
{
|
||||
mime
|
||||
} else {
|
||||
"image/png"
|
||||
}
|
||||
}
|
||||
|
||||
/// Quote arbitrary document metadata as one CSS string token. Escaping `<` is important because
|
||||
/// HTML parses the contents of a `style` element before CSS parsing sees it.
|
||||
pub(crate) fn css_string(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
if matches!(c, '"' | '\\' | '<' | '>' | '&') || c.is_control() {
|
||||
let _ = write!(out, "\\{:x} ", c as u32);
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// CSS class name for a style name.
|
||||
pub fn slug(name: &str) -> String {
|
||||
let s: String = name.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect();
|
||||
let s = s.trim_matches('-').to_string();
|
||||
if s.is_empty() {
|
||||
"basic".into()
|
||||
} else if s.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
format!("s-{s}")
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
use super::chapters::{chapter_file, link_across_chapters, nav_list, split_chapters};
|
||||
use super::css::stylesheet_with;
|
||||
use super::fonts::font_file;
|
||||
use super::html::{Body, body_html};
|
||||
use super::markup::{EpubError, language_tag, story_markup, xml_attr, xml_text};
|
||||
use super::*;
|
||||
|
||||
/// An ISO 8601 UTC timestamp (`2026-10-13T09:30:00Z`) for Unix seconds `t`.
|
||||
/// A time outside chrono's year range still converts: the calendar repeats every 400 years, so the
|
||||
/// instant is moved into range by whole cycles and only the year is carried (matches the old
|
||||
/// `civil_from_days`), rather than resetting to the epoch.
|
||||
pub(crate) fn iso8601(t: i64) -> String {
|
||||
let (y, dt) = designcraft_doc::time::utc_parts(t, 0);
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%SZ"))
|
||||
}
|
||||
|
||||
/// The package's `dcterms:modified`: the given time, else the document's last save or creation.
|
||||
fn modified(doc: &Document, opts: &EpubOptions) -> String {
|
||||
let t = opts.modified.or((doc.modified > 0).then_some(doc.modified)).or((doc.created > 0).then_some(doc.created));
|
||||
t.map_or_else(|| "2026-01-01T00:00:00Z".to_string(), iso8601)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn identifier(doc: &Document, opts: &EpubOptions, title: &str) -> String {
|
||||
if let Some(id) = opts.identifier.clone().filter(|i| !i.trim().is_empty()) {
|
||||
return id;
|
||||
}
|
||||
// Two FNV-1a 64-bit hashes (different offsets) as 128 bits, shaped as a version-5-style UUID.
|
||||
let seed = format!("{title}\n{}", doc.created);
|
||||
let fnv = |offset: u64| seed.bytes().fold(offset, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3));
|
||||
let (a, b) = (fnv(0xcbf2_9ce4_8422_2325), fnv(0x84222325_cbf29ce4));
|
||||
let hi = (a & 0xffff_ffff_ffff_0fff) | 0x5000;
|
||||
let lo = (b & 0x3fff_ffff_ffff_ffff) | 0x8000_0000_0000_0000;
|
||||
format!("urn:uuid:{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", hi >> 32, (hi >> 16) & 0xffff, hi & 0xffff, lo >> 48, lo & 0xffff_ffff_ffff)
|
||||
}
|
||||
|
||||
/// Package name and media type of a cover image: JPEG by its signature, else PNG.
|
||||
fn cover_file(data: &[u8]) -> (&'static str, &'static str) {
|
||||
if data.starts_with(&[0xFF, 0xD8, 0xFF]) { ("images/cover.jpg", "image/jpeg") } else { ("images/cover.png", "image/png") }
|
||||
}
|
||||
|
||||
/// The table of contents' title in `language` (a BCP 47 tag or a language name's code), English
|
||||
/// for languages without one here.
|
||||
pub fn contents_title(language: &str) -> &'static str {
|
||||
let code = language.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase();
|
||||
match code.as_str() {
|
||||
"nl" => "Inhoud",
|
||||
"de" => "Inhalt",
|
||||
"fr" => "Table des matières",
|
||||
"es" | "pt" => "Índice",
|
||||
"it" => "Indice",
|
||||
"ja" => "目次",
|
||||
"zh" => "目录",
|
||||
_ => "Contents",
|
||||
}
|
||||
}
|
||||
|
||||
/// A page of a fixed-layout EPUB: its image (PNG), size in points and text (kept for search,
|
||||
/// read-aloud and accessibility).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FixedPage {
|
||||
pub png: Vec<u8>,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// 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 lang = language_tag(&opts.language);
|
||||
let id = identifier(doc, opts, &title);
|
||||
let modified = modified(doc, opts);
|
||||
let mut manifest = String::from("<item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/>\n");
|
||||
let mut spine = String::new();
|
||||
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
let mut nav_items = String::new();
|
||||
for (i, p) in pages.iter().enumerate() {
|
||||
let n = i + 1;
|
||||
let (w, h) = (p.width.round().max(1.0) as u32, p.height.round().max(1.0) as u32);
|
||||
let xhtml = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xml:lang=\"{lang}\" lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><meta name=\"viewport\" content=\"width={w}, height={h}\"/><title>{} — {n}</title>\n<style>body{{margin:0;width:{w}px;height:{h}px;position:relative}} img{{position:absolute;left:0;top:0;width:{w}px;height:{h}px}} .t{{position:absolute;left:0;top:0;width:{w}px;color:transparent;font-size:1px;overflow:hidden}}</style></head>\n<body><img src=\"pages/p{n}.png\" alt=\"\"/><div class=\"t\">{}</div></body>\n</html>\n",
|
||||
xml_text(&title),
|
||||
story_markup(&p.text).replace('\n', "<br/>")
|
||||
);
|
||||
let _ = writeln!(
|
||||
manifest,
|
||||
"<item id=\"page{n}\" href=\"page{n}.xhtml\" media-type=\"application/xhtml+xml\"/>\n<item id=\"pimg{n}\" href=\"pages/p{n}.png\" media-type=\"image/png\"{}/>",
|
||||
if n == 1 { " properties=\"cover-image\"" } else { "" }
|
||||
);
|
||||
let _ = writeln!(spine, "<itemref idref=\"page{n}\"/>");
|
||||
let _ = write!(nav_items, "<li><a href=\"page{n}.xhtml\">{n}</a></li>");
|
||||
files.push((format!("OEBPS/page{n}.xhtml"), xhtml.into_bytes()));
|
||||
files.push((format!("OEBPS/pages/p{n}.png"), p.png.clone()));
|
||||
}
|
||||
let (vw, vh) = pages.first().map_or((612, 792), |p| (p.width.round() as u32, p.height.round() as u32));
|
||||
let nav = 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}\">\n<head><meta charset=\"utf-8\"/><title>{}</title></head>\n<body><nav epub:type=\"page-list\" id=\"toc\"><h1>Pages</h1><ol>{nav_items}</ol></nav></body>\n</html>\n",
|
||||
xml_text(&title)
|
||||
);
|
||||
let rendition = format!(
|
||||
"\n<meta property=\"rendition:layout\">pre-paginated</meta>\n<meta property=\"rendition:spread\">auto</meta>\n<meta name=\"original-resolution\" content=\"{vw}x{vh}\"/>"
|
||||
);
|
||||
let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref().or(doc.metadata_author()), modified: &modified };
|
||||
let opf = package_opf(&head, " prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\"", &rendition, &manifest, &spine);
|
||||
let entries = files.iter().map(|(name, data)| (name.clone(), data.as_slice(), name.ends_with(".png")));
|
||||
write_package(&opf, &nav, entries)
|
||||
}
|
||||
|
||||
/// The package metadata both EPUB flavours share.
|
||||
struct PackageHead<'a> {
|
||||
lang: &'a str,
|
||||
id: &'a str,
|
||||
title: &'a str,
|
||||
author: Option<&'a str>,
|
||||
modified: &'a str,
|
||||
}
|
||||
|
||||
/// The OPF package document. `prefix` goes on `<package>` (with its leading space), `meta` after
|
||||
/// `dcterms:modified` (with its leading newline); `manifest` and `spine` are the item lines.
|
||||
fn package_opf(h: &PackageHead, prefix: &str, meta: &str, manifest: &str, spine: &str) -> String {
|
||||
let PackageHead { lang, id, title, author, modified } = h;
|
||||
let author = author.map(|a| format!("<dc:creator>{}</dc:creator>", xml_text(a))).unwrap_or_default();
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\"{prefix}>\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>{author}\n<meta property=\"dcterms:modified\">{modified}</meta>{meta}\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n{spine}</spine>\n</package>\n",
|
||||
xml_text(id),
|
||||
xml_text(title)
|
||||
)
|
||||
}
|
||||
|
||||
/// Zips an EPUB: the stored `mimetype`, `container.xml`, the OPF and nav, then `entries` in order
|
||||
/// as (path, bytes, stored uncompressed).
|
||||
fn write_package<'a>(opf: &str, nav: &str, entries: impl IntoIterator<Item = (String, &'a [u8], bool)>) -> Result<Vec<u8>, EpubError> {
|
||||
let io = |e: std::io::Error| EpubError(e.to_string());
|
||||
let zerr = |e: zip::result::ZipError| EpubError(e.to_string());
|
||||
let container = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\"><rootfiles><rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/></rootfiles></container>\n";
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
{
|
||||
let mut z = zip::ZipWriter::new(&mut buf);
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
z.start_file("mimetype", stored).map_err(zerr)?;
|
||||
z.write_all(b"application/epub+zip").map_err(io)?;
|
||||
for (name, data) in
|
||||
[("META-INF/container.xml", container.as_bytes()), ("OEBPS/content.opf", opf.as_bytes()), ("OEBPS/nav.xhtml", nav.as_bytes())]
|
||||
{
|
||||
z.start_file(name, deflate).map_err(zerr)?;
|
||||
z.write_all(data).map_err(io)?;
|
||||
}
|
||||
for (name, data, store) in entries {
|
||||
z.start_file(name, if store { stored } else { deflate }).map_err(zerr)?;
|
||||
z.write_all(data).map_err(io)?;
|
||||
}
|
||||
z.finish().map_err(zerr)?;
|
||||
}
|
||||
Ok(buf.into_inner())
|
||||
}
|
||||
|
||||
/// 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 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());
|
||||
let lang = language_tag(&opts.language);
|
||||
// One file per chapter (see `starts_chapter`); one file in all when Split Document is off.
|
||||
let chapters: Vec<String> = split_chapters(&body)
|
||||
.into_iter()
|
||||
.map(|part| {
|
||||
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{part}</body>\n</html>\n",
|
||||
xml_text(&title)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// The file each navigation target (an element id) ended up in.
|
||||
let mut files: BTreeMap<String, String> = BTreeMap::new();
|
||||
for (k, c) in chapters.iter().enumerate() {
|
||||
for rest in c.split(" id=\"").skip(1) {
|
||||
if let Some(id) = rest.split('"').next() {
|
||||
files.entry(id.to_string()).or_insert_with(|| chapter_file(k));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note links whose target ended up in another file (endnotes end the book) point at it.
|
||||
let chapters: Vec<String> = chapters.iter().enumerate().map(|(k, c)| link_across_chapters(c, &chapter_file(k), &files)).collect();
|
||||
let mut nav_items = nav_list(&toc, &files);
|
||||
if nav_items.is_empty() {
|
||||
nav_items = format!("<li><a href=\"content.xhtml\">{}</a></li>", xml_text(&title));
|
||||
}
|
||||
let nav = 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}\">\n<head><meta charset=\"utf-8\"/><title>{}</title></head>\n<body><nav epub:type=\"toc\" id=\"toc\"><h1>{}</h1><ol>{nav_items}</ol></nav></body>\n</html>\n",
|
||||
xml_text(&title),
|
||||
xml_text(opts.toc_title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(contents_title(&opts.language)))
|
||||
);
|
||||
let mut manifest = String::from(
|
||||
"<item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/>\n<item id=\"css\" href=\"style.css\" media-type=\"text/css\"/>\n",
|
||||
);
|
||||
let mut spine = String::new();
|
||||
for k in 0..chapters.len() {
|
||||
let item = if k == 0 { "content".to_string() } else { format!("content{}", k + 1) };
|
||||
let _ = writeln!(manifest, "<item id=\"{item}\" href=\"{}\" media-type=\"application/xhtml+xml\"/>", chapter_file(k));
|
||||
let _ = writeln!(spine, "<itemref idref=\"{item}\"/>");
|
||||
}
|
||||
for (k, (name, (mime, _))) in images.iter().enumerate() {
|
||||
let _ = writeln!(manifest, "<item id=\"img{k}\" href=\"{}\" media-type=\"{}\"/>", xml_attr(name), xml_attr(mime));
|
||||
}
|
||||
for (k, f) in opts.fonts.iter().enumerate() {
|
||||
let (name, mime) = font_file(k, f);
|
||||
let _ = writeln!(manifest, "<item id=\"font{k}\" href=\"{}\" media-type=\"{mime}\"/>", xml_attr(&name));
|
||||
}
|
||||
// Cover: an image page first in reading order.
|
||||
if let Some(c) = &opts.cover {
|
||||
let (name, mime) = cover_file(c);
|
||||
let _ = writeln!(
|
||||
manifest,
|
||||
"<item id=\"cover-img\" href=\"{name}\" media-type=\"{mime}\" properties=\"cover-image\"/>\n<item id=\"cover\" href=\"cover.xhtml\" media-type=\"application/xhtml+xml\"/>"
|
||||
);
|
||||
}
|
||||
let cover_spine = if opts.cover.is_some() { "<itemref idref=\"cover\"/>\n" } else { "" };
|
||||
let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref().or(doc.metadata_author()), modified: &modified };
|
||||
let opf = package_opf(&head, "", "", &manifest, &format!("{cover_spine}{spine}"));
|
||||
let css = stylesheet_with(doc, &opts.generic_families, &opts.fonts);
|
||||
let mut entries: Vec<(String, &[u8], bool)> = vec![("OEBPS/style.css".into(), css.as_bytes(), false)];
|
||||
entries.extend(chapters.iter().enumerate().map(|(k, c)| (format!("OEBPS/{}", chapter_file(k)), c.as_bytes(), false)));
|
||||
entries.extend(images.iter().map(|(name, (_, data))| (format!("OEBPS/{name}"), *data, true)));
|
||||
entries.extend(opts.fonts.iter().enumerate().map(|(k, f)| (format!("OEBPS/{}", font_file(k, f).0), f.data.as_slice(), false)));
|
||||
let cover_xhtml = opts.cover.as_ref().map(|c| {
|
||||
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}\">\n<head><meta charset=\"utf-8\"/><title>{}</title><style>body{{margin:0;text-align:center}} img{{max-width:100%;max-height:100vh}}</style></head>\n<body epub:type=\"cover\"><img src=\"{}\" alt=\"{}\"/></body>\n</html>\n",
|
||||
xml_text(&title),
|
||||
cover_file(c).0,
|
||||
xml_attr(&title)
|
||||
)
|
||||
});
|
||||
if let (Some(c), Some(xhtml)) = (&opts.cover, &cover_xhtml) {
|
||||
entries.push((format!("OEBPS/{}", cover_file(c).0), c.as_slice(), true));
|
||||
entries.push(("OEBPS/cover.xhtml".into(), xhtml.as_bytes(), false));
|
||||
}
|
||||
write_package(&opf, &nav, entries)
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
use super::chapters::{CHAPTER_MARK, heading_level, starts_chapter};
|
||||
use super::css::{char_classes, export_mapping, override_css, para_classes};
|
||||
use super::html::asset_path;
|
||||
use super::markup::{safe_mime, story_markup, xml_attr, xml_text};
|
||||
use super::*;
|
||||
|
||||
/// Closes the open list, if any.
|
||||
fn close_list(list: &mut Option<&str>, out: &mut String) {
|
||||
if let Some(l) = list.take() {
|
||||
let _ = writeln!(out, "</{}>", l.split(' ').next().unwrap_or(l));
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes the open preformatted block and list, if any.
|
||||
fn close_blocks(list: &mut Option<&str>, pre: &mut Option<(String, bool)>, out: &mut String) {
|
||||
if pre.take().is_some() {
|
||||
out.push_str("</pre>\n");
|
||||
}
|
||||
close_list(list, out);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(crate) struct StoryWriter<'a, 'f> {
|
||||
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)>,
|
||||
/// Names each packaged file (a package path, or a data URI for single-file HTML). Without
|
||||
/// it, anchored objects are left out.
|
||||
img_src: Option<&'f dyn Fn(&str, &str, &[u8]) -> String>,
|
||||
/// Images, video and sound met so far, by package name.
|
||||
pub(crate) images: BTreeMap<String, (&'a str, &'a [u8])>,
|
||||
/// Asset names of the images written without alt text (decorative).
|
||||
pub(crate) no_alt: Vec<String>,
|
||||
/// Prefix of the `id`s the current story's headings get (`{prefix}-p{paragraph index}`, the
|
||||
/// targets of the table of contents); only top-level paragraphs get one.
|
||||
heading_ids: Option<String>,
|
||||
/// Where EPUB content files start (see [`starts_chapter`]).
|
||||
pub(crate) split: EpubSplit,
|
||||
}
|
||||
|
||||
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 {
|
||||
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(),
|
||||
img_src,
|
||||
images: BTreeMap::new(),
|
||||
no_alt: Vec::new(),
|
||||
heading_ids: None,
|
||||
split: doc.settings.epub_split.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
pub(crate) fn story(&mut self, sid: StoryId, heading_ids: Option<&str>) -> String {
|
||||
let Some(st) = self.doc.story(sid) else { return String::new() };
|
||||
self.heading_ids = heading_ids.map(str::to_string);
|
||||
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.
|
||||
///
|
||||
/// Consecutive `li` paragraphs share one list (`ol` for a numbered style, `ul` for a bulleted
|
||||
/// one, an unmarked `ul` when the style has no list type of its own, e.g. typed bullets), and
|
||||
/// consecutive `pre` paragraphs of one style one block, their lines separated by newlines.
|
||||
fn paragraphs(&mut self, st: &Story, depth: usize, lead: &str, out: &mut String) {
|
||||
let doc = self.doc;
|
||||
let mut lead = lead;
|
||||
let mut list: Option<&str> = None;
|
||||
let mut pre: Option<(String, bool)> = None;
|
||||
for (pi, (r, pf)) in st.para_ranges().into_iter().zip(&st.paras).enumerate() {
|
||||
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 props = doc.styles.resolve_para(pf).0;
|
||||
let rtl = props.direction == designcraft_doc::TextDirection::RightToLeft;
|
||||
// Chapter files start only at top-level paragraphs, after any open list or
|
||||
// preformatted block is closed, so each file stays balanced.
|
||||
if depth == 0 && self.heading_ids.is_some() && starts_chapter(&self.split, &doc.styles, &pf.style) {
|
||||
close_blocks(&mut list, &mut pre, out);
|
||||
out.push_str(CHAPTER_MARK);
|
||||
}
|
||||
let continues_pre = tag == "pre" && pre.as_ref() == Some(&(class.clone(), rtl));
|
||||
if !continues_pre && pre.take().is_some() {
|
||||
out.push_str("</pre>\n");
|
||||
}
|
||||
let list_tag = (tag == "li").then_some(match props.list_type {
|
||||
designcraft_doc::ListType::Numbers => "ol",
|
||||
designcraft_doc::ListType::Bullets => "ul",
|
||||
designcraft_doc::ListType::None => "ul style=\"list-style:none\"",
|
||||
});
|
||||
if list != list_tag {
|
||||
close_list(&mut list, out);
|
||||
if let Some(l) = list_tag {
|
||||
let _ = writeln!(out, "<{l}>");
|
||||
}
|
||||
list = list_tag;
|
||||
}
|
||||
if continues_pre {
|
||||
out.push('\n');
|
||||
} else {
|
||||
let id = match &self.heading_ids {
|
||||
Some(prefix) if depth == 0 && heading_level(tag).is_some() => format!(" id=\"{prefix}-p{pi}\""),
|
||||
_ => String::new(),
|
||||
};
|
||||
let _ = write!(out, "<{tag}{id} 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);
|
||||
}
|
||||
if tag == "pre" {
|
||||
// Closed when the next paragraph isn't a line of the same block.
|
||||
pre = Some((class, rtl));
|
||||
} else {
|
||||
let _ = writeln!(out, "</{tag}>");
|
||||
}
|
||||
// Footnotes follow the paragraph outside any list or preformatted block.
|
||||
if !after.is_empty() {
|
||||
close_blocks(&mut list, &mut pre, out);
|
||||
out.push_str(&after);
|
||||
}
|
||||
}
|
||||
if let Some(t) = table
|
||||
&& depth < MAX_DEPTH
|
||||
{
|
||||
close_blocks(&mut list, &mut pre, out);
|
||||
self.table(t, depth + 1, out);
|
||||
}
|
||||
}
|
||||
close_blocks(&mut list, &mut pre, out);
|
||||
}
|
||||
|
||||
/// The markup for one run's text `raw` (starting at byte `a` of the story): anchored objects
|
||||
/// and notes where their marks are.
|
||||
fn run_markup(&mut self, st: &Story, raw: &str, a: usize, depth: usize, after: &mut String) -> String {
|
||||
let doc = self.doc;
|
||||
let mut t = String::new();
|
||||
let mut seg_start = 0;
|
||||
// Index of the next anchored object: marks count from the story start, as
|
||||
// `Story::object_at` counts them (counted once, at the run's first mark).
|
||||
let mut mark: Option<usize> = None;
|
||||
for (i, c) in raw.char_indices() {
|
||||
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()));
|
||||
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()));
|
||||
mark = Some(k.saturating_add(1));
|
||||
if let Some(o) = st.objects.get(k) {
|
||||
t.push_str(&self.object(o));
|
||||
}
|
||||
} else 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()));
|
||||
t
|
||||
}
|
||||
|
||||
/// 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(para.start);
|
||||
let b = rr.end.min(para.end);
|
||||
if a >= b {
|
||||
continue;
|
||||
}
|
||||
// Hidden conditional text isn't exported.
|
||||
if f.over.conditions.as_deref().is_some_and(|c| doc.conditions_hide(c)) || f.over.change == Some(designcraft_doc::ChangeMark::Deleted) {
|
||||
continue;
|
||||
}
|
||||
let Some(raw) = st.text.get(a..b) else { continue };
|
||||
let t = self.run_markup(st, raw, a, depth, after);
|
||||
let (ctag, cls) = if f.style != story::NO_CHAR_STYLE {
|
||||
let (tag, class) = export_mapping(&doc.styles, &f.style, true, &self.cc);
|
||||
(tag, format!(" class=\"{}\"", xml_attr(&class)))
|
||||
} else {
|
||||
("span", String::new())
|
||||
};
|
||||
let style = override_css(doc, &f.over);
|
||||
let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&style)) };
|
||||
if cls.is_empty() && style_attr.is_empty() {
|
||||
out.push_str(&t);
|
||||
} else {
|
||||
let _ = write!(out, "<{ctag}{cls}{style_attr}>{t}</{ctag}>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An anchored object where it is anchored: its graphics in the text, an above-line object as
|
||||
/// a block of its own. Anchored text frames are left out.
|
||||
fn object(&mut self, o: &AnchoredObject) -> String {
|
||||
let mut out = String::new();
|
||||
o.item.walk(&mut |it| {
|
||||
if let Some((markup, _)) = self.graphic(it) {
|
||||
out.push_str(&markup);
|
||||
}
|
||||
});
|
||||
match &o.position {
|
||||
AnchorPosition::AboveLine { align, .. } if !out.is_empty() => {
|
||||
let align = match align {
|
||||
designcraft_doc::anchored::AnchorAlign::Left => "left",
|
||||
designcraft_doc::anchored::AnchorAlign::Center => "center",
|
||||
designcraft_doc::anchored::AnchorAlign::Right => "right",
|
||||
};
|
||||
format!("<span class=\"anchored\" style=\"display:block;text-align:{align}\">{out}</span>")
|
||||
}
|
||||
_ => out,
|
||||
}
|
||||
}
|
||||
|
||||
/// The markup for one graphic item: an `<img>`, or an HTML5 player for video and sound (with
|
||||
/// `true`), its file registered in `images`. `None` for an item that isn't a graphic, whose
|
||||
/// asset has no data, or when the writer names no files.
|
||||
pub(crate) fn graphic(&mut self, it: &Item) -> Option<(String, bool)> {
|
||||
let img_src = self.img_src?;
|
||||
let doc = self.doc;
|
||||
let Content::Graphic(g) = &it.content else { return None };
|
||||
let a = doc.assets.get(&g.asset)?;
|
||||
if a.data.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mime = safe_mime(&a.mime);
|
||||
// Video and sound: an HTML5 player (with the poster, when there is one).
|
||||
if let Some(kind) = designcraft_doc::media_kind(mime) {
|
||||
let name = asset_path("media", a, mime);
|
||||
self.images.insert(name.clone(), (mime, a.data.as_slice()));
|
||||
let m = it.media.clone().unwrap_or_default();
|
||||
let mut attrs = String::new();
|
||||
for (on, attr) in [(m.controls, " controls=\"controls\""), (m.play_on_page_load, " autoplay=\"autoplay\""), (m.looping, " loop=\"loop\"")]
|
||||
{
|
||||
if on {
|
||||
attrs.push_str(attr);
|
||||
}
|
||||
}
|
||||
if let Some(pa) = m.poster.and_then(|p| doc.assets.get(&p)) {
|
||||
let poster_mime = safe_mime(&pa.mime);
|
||||
let pn = asset_path("images", pa, poster_mime);
|
||||
self.images.insert(pn.clone(), (poster_mime, pa.data.as_slice()));
|
||||
if kind == "video" {
|
||||
attrs.push_str(&format!(" poster=\"{}\"", xml_attr(&img_src(&pn, poster_mime, &pa.data))));
|
||||
}
|
||||
}
|
||||
let markup = format!("<{kind} src=\"{}\"{attrs}>{}</{kind}>", xml_attr(&img_src(&name, mime, &a.data)), xml_text(&a.name));
|
||||
return Some((markup, true));
|
||||
}
|
||||
let name = asset_path("images", a, mime);
|
||||
self.images.insert(name.clone(), (mime, a.data.as_slice()));
|
||||
// Alt text from Object Export Options; without one the image counts as decorative
|
||||
// (`alt=""`): a file name read aloud helps nobody.
|
||||
if it.alt_text.trim().is_empty() {
|
||||
self.no_alt.push(a.name.clone());
|
||||
}
|
||||
Some((format!("<img src=\"{}\" alt=\"{}\"/>", xml_attr(&img_src(&name, mime, &a.data)), xml_attr(&it.alt_text)), false))
|
||||
}
|
||||
|
||||
/// 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(¬e.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(¬e.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).
|
||||
pub(crate) 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
|
||||
}
|
||||
|
||||
/// One table cell with its row and column spans, as `th` or `td`.
|
||||
fn table_cell(&mut self, text: &Story, cell_tag: &str, (rs, cs): (usize, usize), depth: usize, out: &mut String) {
|
||||
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(text, depth, "", out);
|
||||
let _ = writeln!(out, "</{cell_tag}>");
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.table_cell(&cell.text, cell_tag, (rs, cs), depth, out);
|
||||
}
|
||||
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, None).story(sid, None)
|
||||
}
|
||||
Reference in New Issue
Block a user