use super::*;
pub(super) fn build_external_resource_fragment(
href: &str,
data: &[u8],
ctx: &mut ExportContext,
) -> KfxFragment {
let resource_name = generate_resource_name(href, ctx);
let resource_name_symbol = ctx.symbols.get_or_intern(&resource_name);
let mut fields = Vec::new();
fields.push((
KfxSymbol::ResourceName as u64,
IonValue::Symbol(resource_name_symbol),
));
let location = format!("resource/{}", resource_name);
fields.push((KfxSymbol::Location as u64, IonValue::String(location)));
let format_symbol = detect_format_symbol(href, data);
fields.push((KfxSymbol::Format as u64, IonValue::Symbol(format_symbol)));
if let Some((width, height)) = crate::util::extract_image_dimensions(data) {
fields.push((KfxSymbol::ResourceWidth as u64, IonValue::Int(width as i64)));
fields.push((
KfxSymbol::ResourceHeight as u64,
IonValue::Int(height as i64),
));
}
if let Some(mime) = crate::util::detect_mime_type(href, data) {
fields.push((KfxSymbol::Mime as u64, IonValue::String(mime.to_string())));
}
let ion = IonValue::Struct(fields);
KfxFragment::new(KfxSymbol::ExternalResource, &resource_name, ion)
}
pub(super) fn build_resource_fragment(
href: &str,
data: Vec<u8>,
ctx: &mut ExportContext,
) -> KfxFragment {
let resource_name = generate_resource_name(href, ctx);
let raw_name = format!("resource/{}", resource_name);
ctx.symbols.get_or_intern(&raw_name);
KfxFragment::raw(KfxSymbol::Bcrawmedia as u64, &raw_name, data)
}
pub(super) fn build_font_data_fragment(
href: &str,
data: Vec<u8>,
ctx: &mut ExportContext,
) -> KfxFragment {
let resource_name = generate_resource_name(href, ctx);
let raw_name = format!("resource/{}", resource_name);
ctx.symbols.get_or_intern(&raw_name);
KfxFragment::raw(KfxSymbol::Bcrawfont as u64, &raw_name, data)
}
pub(super) fn build_font_fragments(book: &Book, ctx: &mut ExportContext) -> Vec<KfxFragment> {
use crate::style::{FontStyle, FontWeight};
let mut fragments = Vec::new();
let font_faces = book.font_faces();
for font_face in font_faces {
let resource_name = match ctx.resource_registry.get_name(&font_face.src) {
Some(name) => name.to_string(),
None => {
let filename = std::path::Path::new(&font_face.src)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&font_face.src);
let mut found = None;
for (href, _) in ctx.resource_registry.iter() {
if href.ends_with(filename) {
found = ctx.resource_registry.get_name(href).map(|s| s.to_string());
break;
}
}
match found {
Some(name) => name,
None => continue, }
}
};
let location = format!("resource/{}", resource_name);
let font_family = font_face.font_family.clone();
let weight_symbol = match font_face.font_weight {
FontWeight(w) if w >= 700 => KfxSymbol::Bold,
_ => KfxSymbol::Normal,
};
let style_symbol = match font_face.font_style {
FontStyle::Italic | FontStyle::Oblique => KfxSymbol::Italic,
FontStyle::Normal => KfxSymbol::Normal,
};
let ion = IonValue::Struct(vec![
(
KfxSymbol::FontFamily as u64,
IonValue::String(font_family.clone()),
),
(
KfxSymbol::FontStyle as u64,
IonValue::Symbol(style_symbol as u64),
),
(KfxSymbol::Location as u64, IonValue::String(location)),
(
KfxSymbol::FontWeight as u64,
IonValue::Symbol(weight_symbol as u64),
),
(
KfxSymbol::FontStretch as u64,
IonValue::Symbol(KfxSymbol::Normal as u64),
),
]);
fragments.push(KfxFragment::singleton(KfxSymbol::Font, ion));
}
fragments
}
pub(super) fn build_anchor_fragments(
ctx: &mut ExportContext,
used_anchors: &BTreeSet<u64>,
) -> Vec<KfxFragment> {
let mut fragments = Vec::new();
let is_used = |ctx: &ExportContext, symbol: &str| {
ctx.symbols
.get(symbol)
.is_some_and(|sym| used_anchors.contains(&sym))
};
let resolved_anchors = ctx.anchor_registry.drain_anchors();
for anchor in resolved_anchors {
if !is_used(ctx, &anchor.symbol) {
continue;
}
let anchor_symbol_id = ctx.symbols.get_or_intern(&anchor.symbol);
let mut pos_fields = Vec::new();
pos_fields.push((
KfxSymbol::Id as u64,
IonValue::Int(anchor.fragment_id as i64),
));
if anchor.offset > 0 {
pos_fields.push((
KfxSymbol::Offset as u64,
IonValue::Int(anchor.offset as i64),
));
}
let ion = IonValue::Struct(vec![
(
KfxSymbol::AnchorName as u64,
IonValue::Symbol(anchor_symbol_id),
),
(KfxSymbol::Position as u64, IonValue::Struct(pos_fields)),
]);
fragments.push(KfxFragment::new(KfxSymbol::Anchor, &anchor.symbol, ion));
}
let unresolved = ctx.anchor_registry.unresolved_symbols();
if !unresolved.is_empty() {
let fallback_id = ctx
.cover_fragment_id
.or_else(|| {
ctx.section_ids.first().and_then(|_| {
ctx.spine_section_chapters
.first()
.and_then(|&(_, ch)| ctx.chapter_fragments.get(&ch).copied())
})
})
.unwrap_or(crate::kfx::context::IdGenerator::FRAGMENT_MIN_ID);
for symbol in unresolved {
if !is_used(ctx, &symbol) {
continue;
}
let anchor_symbol_id = ctx.symbols.get_or_intern(&symbol);
let ion = IonValue::Struct(vec![
(
KfxSymbol::AnchorName as u64,
IonValue::Symbol(anchor_symbol_id),
),
(
KfxSymbol::Position as u64,
IonValue::Struct(vec![(
KfxSymbol::Id as u64,
IonValue::Int(fallback_id as i64),
)]),
),
]);
fragments.push(KfxFragment::new(KfxSymbol::Anchor, &symbol, ion));
}
}
let external_anchors = ctx.anchor_registry.drain_external_anchors();
for anchor in external_anchors {
if !is_used(ctx, &anchor.symbol) {
continue;
}
let anchor_symbol_id = ctx.symbols.get_or_intern(&anchor.symbol);
let ion = IonValue::Struct(vec![
(KfxSymbol::Uri as u64, IonValue::String(anchor.uri.clone())),
(
KfxSymbol::AnchorName as u64,
IonValue::Symbol(anchor_symbol_id),
),
]);
fragments.push(KfxFragment::new(KfxSymbol::Anchor, &anchor.symbol, ion));
}
fragments
}
pub(super) fn generate_resource_name(href: &str, ctx: &mut ExportContext) -> String {
ctx.resource_registry.get_or_create_name(href)
}
pub(super) fn build_resource_path_fragment() -> KfxFragment {
let ion = IonValue::Struct(vec![(KfxSymbol::Entries as u64, IonValue::List(vec![]))]);
KfxFragment::singleton(KfxSymbol::ResourcePath, ion)
}
pub(super) fn detect_format_symbol(href: &str, data: &[u8]) -> u64 {
let format = detect_media_format(href, data);
format_to_kfx_symbol(format)
}
pub(super) fn is_media_asset(book: &crate::model::Book, path: &str) -> bool {
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
match ext.to_lowercase().as_str() {
"jpg" | "jpeg" | "png" | "gif" | "svg" | "webp" | "ttf" | "otf" | "woff" | "woff2"
| "jfif" | "jpe" | "bmp" => true,
"css" | "xhtml" | "html" | "htm" | "xml" | "opf" | "ncx" | "txt" | "js" | "json" => false,
_ => book.load_asset(path).is_ok_and(|data| {
let format = detect_media_format(path, &data);
format.is_image() || format.is_font()
}),
}
}
#[cfg(test)]
mod resource_export_tests {
use super::*;
use crate::model::Book;
#[test]
fn test_kfx_export_includes_images() {
let book = Book::open("tests/fixtures/epictetus.epub").unwrap();
let data = build_kfx_container(&book).unwrap();
assert!(
data.len() > 400000,
"KFX should include image data, got {} bytes",
data.len()
);
}
#[test]
fn test_kfx_asset_roundtrip() {
let book = Book::open("tests/fixtures/epictetus.epub").unwrap();
let kfx_data = build_kfx_container(&book).unwrap();
let temp_path = std::env::temp_dir().join("test_roundtrip.kfx");
std::fs::write(&temp_path, &kfx_data).unwrap();
let reimported = Book::open(&temp_path).unwrap();
let assets: Vec<_> = reimported.list_assets().to_vec();
let total_size: usize = assets
.iter()
.filter_map(|a| reimported.load_asset(a).ok())
.map(|d| d.len())
.sum();
std::fs::remove_file(&temp_path).ok();
assert!(
total_size > 100000,
"Expected > 100KB of assets from KFX, got {} bytes",
total_size
);
}
}
#[cfg(test)]
mod anchor_resolution_tests {
use super::*;
use crate::model::Book;
#[test]
fn test_cross_file_anchor_resolution_flow() {
let book = Book::open("tests/fixtures/epictetus.epub").unwrap();
let resolved = book.resolve_links().unwrap();
assert!(!resolved.is_empty(), "Should have resolved some links");
let broken_count = resolved.broken_links().len();
eprintln!("Resolved {} links, {} broken", resolved.len(), broken_count);
}
#[test]
fn test_anchor_symbol_reuse() {
let book = Book::open("tests/fixtures/epictetus.epub").unwrap();
let mut ctx = ExportContext::new();
let spine_info: Vec<_> = book
.spine()
.iter()
.enumerate()
.map(|(idx, entry)| {
let section_name = format!("c{}", idx);
(entry.id, section_name)
})
.collect();
let resolved = book.resolve_links().unwrap();
register_link_targets(&book, &spine_info, &resolved, &mut ctx).unwrap();
for (source, target) in resolved.iter() {
if let AnchorTarget::Internal(gid) = target {
if let Ok(chapter) = book.load_chapter(source.chapter)
&& let Some(href) = chapter.semantics.href(source.node)
{
let href_symbol = ctx.anchor_registry.get_href_symbol(href);
let node_symbol = ctx.anchor_registry.get_symbol(*gid);
assert_eq!(
href_symbol, node_symbol,
"href '{}' and GlobalNodeId {:?} should have same symbol",
href, gid
);
return;
}
}
}
panic!("Should have found at least one internal link to verify");
}
#[test]
fn test_anchor_entities_created_in_full_export() {
let book = Book::open("tests/fixtures/epictetus.epub").unwrap();
let kfx_data = build_kfx_container(&book).unwrap();
use crate::kfx::container::{
parse_container_header, parse_container_info, parse_index_table,
};
let header = parse_container_header(&kfx_data).expect("Failed to parse header");
let ci_start = header.container_info_offset;
let ci_end = ci_start + header.container_info_length;
let container_info = parse_container_info(&kfx_data[ci_start..ci_end])
.expect("Failed to parse container info");
let (idx_offset, idx_len) = container_info.index.expect("No index table");
let index = parse_index_table(
&kfx_data[idx_offset..idx_offset + idx_len],
header.header_len,
);
let anchor_count = index.iter().filter(|e| e.type_id == 266).count();
assert!(
anchor_count >= 40,
"Expected at least 40 anchor entities for endnotes, got {}",
anchor_count
);
}
}