use std::collections::HashMap;
use std::io::{self, Seek, Write};
use crate::book::{Book, LandmarkType};
use crate::export::Exporter;
use crate::import::ChapterId;
use crate::ir::{IRChapter, NodeId, Role};
use crate::kfx::auxiliary::build_auxiliary_data_fragment;
use crate::kfx::context::{ExportContext, LandmarkTarget};
use crate::kfx::cover::{
COVER_SECTION_NAME, build_cover_section, is_image_only_chapter, needs_standalone_cover,
normalize_cover_path,
};
use crate::kfx::fragment::KfxFragment;
use crate::kfx::ion::IonValue;
use crate::kfx::metadata::{
MetadataCategory, MetadataContext, build_category_entries, generate_book_id,
};
use crate::kfx::serialization::{
SerializedEntity, create_entity_data, generate_container_id, serialize_annotated_ion,
serialize_container,
};
use crate::kfx::symbols::KfxSymbol;
use crate::kfx::transforms::format_to_kfx_symbol;
use crate::util::detect_media_format;
#[derive(Debug, Clone, Default)]
pub struct KfxConfig {
}
pub struct KfxExporter {
#[allow(dead_code)]
config: KfxConfig,
}
impl KfxExporter {
pub fn new() -> Self {
Self {
config: KfxConfig::default(),
}
}
pub fn with_config(config: KfxConfig) -> Self {
Self { config }
}
}
impl Default for KfxExporter {
fn default() -> Self {
Self::new()
}
}
impl Exporter for KfxExporter {
fn export<W: Write + Seek>(&self, book: &mut Book, writer: &mut W) -> io::Result<()> {
let data = build_kfx_container(book)?;
writer.write_all(&data)?;
Ok(())
}
}
fn build_kfx_container(book: &mut Book) -> io::Result<Vec<u8>> {
let container_id = generate_container_id();
let mut ctx = ExportContext::new();
let asset_paths: Vec<_> = book.list_assets();
let cover_image = book.metadata().cover_image.clone();
let first_chapter_id = book.spine().first().map(|e| e.id);
let standalone_cover_path: Option<String> = match (cover_image, first_chapter_id) {
(Some(cover_img), Some(first_id)) => {
let normalized = normalize_cover_path(&cover_img, &asset_paths);
book.load_chapter(first_id).ok().and_then(|first_chapter| {
if needs_standalone_cover(&normalized, &first_chapter) {
Some(normalized)
} else {
None
}
})
}
_ => None,
};
let section_offset = if standalone_cover_path.is_some() {
1
} else {
0
};
let spine_info: Vec<_> = book
.spine()
.iter()
.enumerate()
.map(|(idx, entry)| {
let section_name = format!("c{}", idx + section_offset);
(entry.id, section_name)
})
.collect();
if standalone_cover_path.is_some() {
ctx.register_section(COVER_SECTION_NAME);
let cover_section_id = ctx.next_fragment_id();
ctx.cover_fragment_id = Some(cover_section_id);
ctx.landmark_fragments.insert(
LandmarkType::Cover,
LandmarkTarget {
fragment_id: cover_section_id,
offset: 0,
label: "cover-nav-unit".to_string(),
},
);
}
for (chapter_id, _) in &spine_info {
if let Ok(chapter) = book.load_chapter(*chapter_id) {
collect_needed_anchors_from_chapter(&chapter, chapter.root(), &mut ctx);
}
}
let mut source_to_chapter: HashMap<String, ChapterId> = HashMap::new();
for (chapter_id, section_name) in &spine_info {
let _section_id = ctx.register_section(section_name);
let source_path = book.source_id(*chapter_id).unwrap_or("").to_string();
if !source_path.is_empty() {
source_to_chapter.insert(source_path.clone(), *chapter_id);
}
if let Ok(chapter) = book.load_chapter(*chapter_id) {
survey_chapter(&chapter, *chapter_id, &source_path, &mut ctx);
}
}
resolve_landmarks_from_ir(book, &source_to_chapter, &mut ctx);
let has_cover = ctx.landmark_fragments.contains_key(&LandmarkType::Cover);
let has_srl = ctx
.landmark_fragments
.contains_key(&LandmarkType::StartReading);
if !has_cover || !has_srl {
for (chapter_id, _section_name) in &spine_info {
if let Ok(chapter) = book.load_chapter(*chapter_id) {
let is_cover = is_image_only_chapter(&chapter);
let fragment_id = ctx.chapter_fragments.get(chapter_id).copied();
if let Some(fid) = fragment_id {
if is_cover && !ctx.landmark_fragments.contains_key(&LandmarkType::Cover) {
ctx.landmark_fragments.insert(
LandmarkType::Cover,
LandmarkTarget {
fragment_id: fid,
offset: 0,
label: "cover-nav-unit".to_string(),
},
);
} else if !is_cover
&& !ctx
.landmark_fragments
.contains_key(&LandmarkType::StartReading)
{
ctx.landmark_fragments.insert(
LandmarkType::StartReading,
LandmarkTarget {
fragment_id: fid,
offset: 0,
label: book.metadata().title.clone(),
},
);
}
}
if ctx.landmark_fragments.contains_key(&LandmarkType::Cover)
&& ctx
.landmark_fragments
.contains_key(&LandmarkType::StartReading)
{
break;
}
}
}
}
ctx.nav_container_symbols.toc = ctx.symbols.get_or_intern("toc");
ctx.nav_container_symbols.headings = ctx.symbols.get_or_intern("headings");
ctx.nav_container_symbols.landmarks = ctx.symbols.get_or_intern("landmarks");
let asset_paths: Vec<_> = book.list_assets();
for asset_path in &asset_paths {
if is_media_asset(asset_path) {
let href = asset_path.to_string_lossy().to_string();
ctx.resource_registry.register(&href, &mut ctx.symbols);
let short_name = ctx.resource_registry.get_or_create_name(&href);
ctx.symbols.get_or_intern(&short_name);
}
}
let mut fragments = Vec::new();
fragments.push(build_content_features_fragment());
fragments.push(build_book_metadata_fragment(book, &container_id, &ctx));
fragments.push(build_metadata_fragment(&ctx));
let document_data_index = fragments.len();
let mut section_fragments = Vec::new();
let mut storyline_fragments = Vec::new();
let mut content_fragments = Vec::new();
if let Some(ref cover_path) = standalone_cover_path {
let section_id = ctx
.cover_fragment_id
.expect("cover_fragment_id should be set in Pass 1");
let cover_content_id = ctx.fragment_ids.peek();
ctx.cover_content_id = Some(cover_content_id);
let (section, storyline) = build_cover_section(cover_path, section_id, &mut ctx);
section_fragments.push(section);
storyline_fragments.push(storyline);
if let Some(target) = ctx.landmark_fragments.get_mut(&LandmarkType::Cover) {
target.fragment_id = cover_content_id;
}
}
for (chapter_id, section_name) in &spine_info {
if let Ok(chapter) = book.load_chapter(*chapter_id) {
let source_path = book.source_id(*chapter_id).unwrap_or("");
ctx.begin_chapter_export(*chapter_id, source_path);
let (section, storyline, content) =
build_chapter_entities_grouped(&chapter, *chapter_id, section_name, &mut ctx);
section_fragments.push(section);
storyline_fragments.push(storyline);
if let Some(c) = content {
content_fragments.push(c);
}
}
}
ctx.fix_landmark_content_ids(&source_to_chapter);
fragments.push(build_book_navigation_fragment_with_positions(book, &ctx));
fragments.extend(section_fragments);
fragments.extend(storyline_fragments);
fragments.extend(content_fragments);
let style_fragments = build_style_fragments(&mut ctx);
fragments.extend(style_fragments);
let (anchor_frags, anchor_ids_by_fragment) = build_anchor_fragments(&mut ctx);
fragments.extend(anchor_frags);
if standalone_cover_path.is_some() {
fragments.push(build_auxiliary_data_fragment(COVER_SECTION_NAME, &mut ctx));
}
for (_, section_name) in &spine_info {
fragments.push(build_auxiliary_data_fragment(section_name, &mut ctx));
}
for asset_path in &asset_paths {
if is_media_asset(asset_path)
&& let Ok(data) = book.load_asset(asset_path)
{
let href = asset_path.to_string_lossy().to_string();
fragments.push(build_external_resource_fragment(&href, &data, &mut ctx));
fragments.push(build_resource_fragment(&href, &data, &mut ctx));
}
}
fragments.push(build_position_map_fragment(&ctx, &anchor_ids_by_fragment));
fragments.push(build_position_id_map_fragment(&ctx));
fragments.push(build_location_map_fragment(&ctx));
fragments.push(build_resource_path_fragment());
fragments.push(build_container_entity_map_fragment(
&container_id,
&fragments,
&ctx,
));
fragments.insert(document_data_index, build_document_data_fragment(&ctx));
let local_syms = ctx.symbols.local_symbols();
let symtab_ion = build_symbol_table_ion(local_syms);
let format_caps_ion = build_format_capabilities_ion();
let entities = serialize_fragments(&fragments, ctx.symbols.local_symbols());
Ok(serialize_container(
&container_id,
&entities,
&symtab_ion,
&format_caps_ion,
))
}
fn survey_chapter(
chapter: &IRChapter,
chapter_id: ChapterId,
source_path: &str,
ctx: &mut ExportContext,
) {
let _fragment_id = ctx.begin_chapter_survey(chapter_id, source_path);
survey_node(chapter, chapter.root(), ctx);
ctx.end_chapter_survey();
}
fn survey_node(chapter: &IRChapter, node_id: NodeId, ctx: &mut ExportContext) {
let node = match chapter.node(node_id) {
Some(n) => n,
None => return,
};
if node.role == Role::Root {
for child in chapter.children(node_id) {
survey_node(chapter, child, ctx);
}
return;
}
ctx.record_position(node_id);
if let Some(anchor_id) = chapter.semantics.id(node_id) {
ctx.record_anchor(anchor_id, node_id);
}
if let Some(src) = chapter.semantics.src(node_id) {
ctx.resource_registry.register(src, &mut ctx.symbols);
}
if !node.text.is_empty() {
let text = chapter.text(node.text);
ctx.advance_text_offset(text.len());
}
for child in chapter.children(node_id) {
survey_node(chapter, child, ctx);
}
}
fn collect_needed_anchors_from_chapter(
chapter: &IRChapter,
node_id: NodeId,
ctx: &mut ExportContext,
) {
if chapter.node(node_id).is_none() {
return;
}
if let Some(href) = chapter.semantics.href(node_id) {
ctx.anchor_registry.register_link_target(href);
ctx.register_needed_anchor(href);
}
for child in chapter.children(node_id) {
collect_needed_anchors_from_chapter(chapter, child, ctx);
}
}
fn build_style_fragments(ctx: &mut ExportContext) -> Vec<KfxFragment> {
let style_pairs = ctx.style_registry.drain_to_ion();
style_pairs
.into_iter()
.map(|(name, ion)| KfxFragment::new(KfxSymbol::Style, &name, ion))
.collect()
}
fn build_metadata_fragment(ctx: &ExportContext) -> KfxFragment {
let sections: Vec<IonValue> = ctx
.section_ids
.iter()
.map(|&id| IonValue::Symbol(id))
.collect();
let reading_order = IonValue::Struct(vec![
(
KfxSymbol::ReadingOrderName as u64,
IonValue::Symbol(KfxSymbol::Default as u64),
),
(KfxSymbol::Sections as u64, IonValue::List(sections)),
]);
let reading_orders = IonValue::List(vec![reading_order]);
let metadata = IonValue::Struct(vec![(KfxSymbol::ReadingOrders as u64, reading_orders)]);
KfxFragment::singleton(KfxSymbol::Metadata, metadata)
}
fn build_book_metadata_fragment(
book: &Book,
container_id: &str,
ctx: &ExportContext,
) -> KfxFragment {
let meta = book.metadata();
let cover_resource_name = meta.cover_image.as_ref().and_then(|path| {
if let Some(name) = ctx.resource_registry.get_name(path) {
return Some(name);
}
let with_prefix = format!("epub/{}", path);
if let Some(name) = ctx.resource_registry.get_name(&with_prefix) {
return Some(name);
}
let filename = std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())?;
for (href, _) in ctx.resource_registry.iter() {
if href.ends_with(filename) {
return ctx.resource_registry.get_name(href);
}
}
None
});
let book_id = if !meta.identifier.is_empty() {
Some(generate_book_id(&meta.identifier))
} else {
None
};
let meta_ctx = MetadataContext {
version: Some(env!("CARGO_PKG_VERSION")),
cover_resource_name,
asset_id: Some(container_id),
book_id,
};
let categories = [
MetadataCategory::KindleEbook,
MetadataCategory::KindleTitle,
MetadataCategory::KindleAudit,
];
let categorised: Vec<IonValue> = categories
.iter()
.map(|&cat| {
let entries = build_category_entries(cat, meta, &meta_ctx);
let ion_entries: Vec<IonValue> = entries
.into_iter()
.map(|(k, v)| metadata_kv(k, &v))
.collect();
IonValue::Struct(vec![
(
KfxSymbol::Category as u64,
IonValue::String(cat.as_str().to_string()),
),
(KfxSymbol::Metadata as u64, IonValue::List(ion_entries)),
])
})
.collect();
let book_metadata = IonValue::Struct(vec![(
KfxSymbol::CategorisedMetadata as u64,
IonValue::List(categorised),
)]);
KfxFragment::singleton(KfxSymbol::BookMetadata, book_metadata)
}
fn metadata_kv(key: &str, value: &str) -> IonValue {
IonValue::Struct(vec![
(KfxSymbol::Key as u64, IonValue::String(key.to_string())),
(KfxSymbol::Value as u64, IonValue::String(value.to_string())),
])
}
fn build_content_features_fragment() -> KfxFragment {
let reflow_style = IonValue::Struct(vec![
(
KfxSymbol::Namespace as u64,
IonValue::String("com.amazon.yjconversion".to_string()),
),
(
KfxSymbol::Key as u64,
IonValue::String("reflow-style".to_string()),
),
(
KfxSymbol::VersionInfo as u64,
IonValue::Struct(vec![(
KfxSymbol::Version as u64,
IonValue::Struct(vec![
(KfxSymbol::MajorVersion as u64, IonValue::Int(6)),
(KfxSymbol::MinorVersion as u64, IonValue::Int(0)),
]),
)]),
),
]);
let canonical_format = IonValue::Struct(vec![
(
KfxSymbol::Namespace as u64,
IonValue::String("SDK.Marker".to_string()),
),
(
KfxSymbol::Key as u64,
IonValue::String("CanonicalFormat".to_string()),
),
(
KfxSymbol::VersionInfo as u64,
IonValue::Struct(vec![(
KfxSymbol::Version as u64,
IonValue::Struct(vec![
(KfxSymbol::MajorVersion as u64, IonValue::Int(1)),
(KfxSymbol::MinorVersion as u64, IonValue::Int(0)),
]),
)]),
),
]);
let yj_hdv = IonValue::Struct(vec![
(
KfxSymbol::Namespace as u64,
IonValue::String("com.amazon.yjconversion".to_string()),
),
(
KfxSymbol::Key as u64,
IonValue::String("yj_hdv".to_string()),
),
(
KfxSymbol::VersionInfo as u64,
IonValue::Struct(vec![(
KfxSymbol::Version as u64,
IonValue::Struct(vec![
(KfxSymbol::MajorVersion as u64, IonValue::Int(1)),
(KfxSymbol::MinorVersion as u64, IonValue::Int(0)),
]),
)]),
),
]);
let content_features = IonValue::Struct(vec![(
KfxSymbol::Features as u64,
IonValue::List(vec![reflow_style, canonical_format, yj_hdv]),
)]);
KfxFragment::singleton(KfxSymbol::ContentFeatures, content_features)
}
fn build_document_data_fragment(ctx: &ExportContext) -> KfxFragment {
let sections: Vec<IonValue> = ctx
.section_ids
.iter()
.map(|&id| IonValue::Symbol(id))
.collect();
let reading_order = IonValue::Struct(vec![
(
KfxSymbol::ReadingOrderName as u64,
IonValue::Symbol(KfxSymbol::Default as u64),
),
(KfxSymbol::Sections as u64, IonValue::List(sections)),
]);
let max_id = ctx.max_eid();
let document_data = IonValue::Struct(vec![
(
KfxSymbol::Direction as u64,
IonValue::Symbol(KfxSymbol::Ltr as u64),
),
(
KfxSymbol::ColumnCount as u64,
IonValue::Symbol(KfxSymbol::Auto as u64),
),
(
KfxSymbol::FontSize as u64,
IonValue::Struct(vec![
(KfxSymbol::Value as u64, IonValue::Decimal("1".to_string())),
(
KfxSymbol::Unit as u64,
IonValue::Symbol(KfxSymbol::Em as u64),
),
]),
),
(
KfxSymbol::WritingMode as u64,
IonValue::Symbol(KfxSymbol::HorizontalTb as u64),
),
(
KfxSymbol::Selection as u64,
IonValue::Symbol(KfxSymbol::Enabled as u64),
),
(KfxSymbol::MaxId as u64, IonValue::Int(max_id as i64)),
(
KfxSymbol::LineHeight as u64,
IonValue::Struct(vec![
(
KfxSymbol::Value as u64,
IonValue::Decimal("1.2".to_string()),
),
(
KfxSymbol::Unit as u64,
IonValue::Symbol(KfxSymbol::Em as u64),
),
]),
),
(
KfxSymbol::SpacingPercentBase as u64,
IonValue::Symbol(KfxSymbol::Width as u64),
),
(
KfxSymbol::ReadingOrders as u64,
IonValue::List(vec![reading_order]),
),
]);
KfxFragment::singleton(KfxSymbol::DocumentData, document_data)
}
fn build_book_navigation_fragment_with_positions(book: &Book, ctx: &ExportContext) -> KfxFragment {
let mut nav_containers = Vec::new();
let headings_entries = build_headings_entries(ctx);
let headings_container = IonValue::Struct(vec![
(
KfxSymbol::NavType as u64,
IonValue::Symbol(KfxSymbol::Headings as u64),
),
(
KfxSymbol::NavContainerName as u64,
IonValue::Symbol(ctx.nav_container_symbols.headings),
),
(KfxSymbol::Entries as u64, IonValue::List(headings_entries)),
]);
let annotated = IonValue::Annotated(
vec![KfxSymbol::NavContainer as u64],
Box::new(headings_container),
);
nav_containers.push(annotated);
if !book.toc().is_empty() {
let toc_entries = build_toc_entries_with_positions(book.toc(), ctx);
let toc_container = IonValue::Struct(vec![
(
KfxSymbol::NavType as u64,
IonValue::Symbol(KfxSymbol::Toc as u64),
),
(
KfxSymbol::NavContainerName as u64,
IonValue::Symbol(ctx.nav_container_symbols.toc),
),
(KfxSymbol::Entries as u64, IonValue::List(toc_entries)),
]);
let annotated = IonValue::Annotated(
vec![KfxSymbol::NavContainer as u64],
Box::new(toc_container),
);
nav_containers.push(annotated);
}
let landmarks_entries = build_landmarks_entries(book, ctx);
if !landmarks_entries.is_empty() {
let landmarks_container = IonValue::Struct(vec![
(
KfxSymbol::NavType as u64,
IonValue::Symbol(KfxSymbol::Landmarks as u64),
),
(
KfxSymbol::NavContainerName as u64,
IonValue::Symbol(ctx.nav_container_symbols.landmarks),
),
(KfxSymbol::Entries as u64, IonValue::List(landmarks_entries)),
]);
let annotated = IonValue::Annotated(
vec![KfxSymbol::NavContainer as u64],
Box::new(landmarks_container),
);
nav_containers.push(annotated);
}
let reading_order = IonValue::Struct(vec![
(
KfxSymbol::ReadingOrderName as u64,
IonValue::Symbol(KfxSymbol::Default as u64),
),
(
KfxSymbol::NavContainers as u64,
IonValue::List(nav_containers),
),
]);
let book_nav = IonValue::List(vec![reading_order]);
KfxFragment::singleton(KfxSymbol::BookNavigation, book_nav)
}
fn build_headings_entries(ctx: &ExportContext) -> Vec<IonValue> {
use std::collections::BTreeMap;
let mut by_level: BTreeMap<u8, Vec<&crate::kfx::context::HeadingPosition>> = BTreeMap::new();
for heading in &ctx.heading_positions {
by_level.entry(heading.level).or_default().push(heading);
}
fn level_to_symbol(level: u8) -> Option<KfxSymbol> {
match level {
2 => Some(KfxSymbol::H2),
3 => Some(KfxSymbol::H3),
4 => Some(KfxSymbol::H4),
5 => Some(KfxSymbol::H5),
6 => Some(KfxSymbol::H6),
_ => None, }
}
let mut entries = Vec::new();
for (level, headings) in by_level {
let Some(level_symbol) = level_to_symbol(level) else {
continue;
};
if headings.is_empty() {
continue;
}
let nested_entries: Vec<IonValue> = headings
.iter()
.map(|h| {
IonValue::Annotated(
vec![KfxSymbol::NavUnit as u64],
Box::new(IonValue::Struct(vec![
(
KfxSymbol::Representation as u64,
IonValue::Struct(vec![(
KfxSymbol::Label as u64,
IonValue::String("heading-nav-unit".to_string()),
)]),
),
(
KfxSymbol::TargetPosition as u64,
IonValue::Struct(vec![
(KfxSymbol::Id as u64, IonValue::Int(h.fragment_id as i64)),
(KfxSymbol::Offset as u64, IonValue::Int(h.offset as i64)),
]),
),
])),
)
})
.collect();
let first = headings[0];
let level_entry = IonValue::Annotated(
vec![KfxSymbol::NavUnit as u64],
Box::new(IonValue::Struct(vec![
(
KfxSymbol::LandmarkType as u64,
IonValue::Symbol(level_symbol as u64),
),
(
KfxSymbol::Representation as u64,
IonValue::Struct(vec![(
KfxSymbol::Label as u64,
IonValue::String("heading-nav-unit".to_string()),
)]),
),
(
KfxSymbol::TargetPosition as u64,
IonValue::Struct(vec![
(
KfxSymbol::Id as u64,
IonValue::Int(first.fragment_id as i64),
),
(KfxSymbol::Offset as u64, IonValue::Int(first.offset as i64)),
]),
),
(KfxSymbol::Entries as u64, IonValue::List(nested_entries)),
])),
);
entries.push(level_entry);
}
entries
}
fn build_landmarks_entries(_book: &Book, ctx: &ExportContext) -> Vec<IonValue> {
use crate::kfx::schema::schema;
let mut entries = Vec::new();
let mut landmarks: Vec<_> = ctx.landmark_fragments.iter().collect();
landmarks.sort_by_key(|(lt, _)| match lt {
LandmarkType::Cover => 0,
LandmarkType::StartReading => 1,
_ => 2,
});
for (landmark_type, target) in landmarks {
let Some(kfx_symbol) = schema().landmark_to_kfx(*landmark_type) else {
continue; };
let entry = IonValue::Annotated(
vec![KfxSymbol::NavUnit as u64],
Box::new(IonValue::Struct(vec![
(
KfxSymbol::LandmarkType as u64,
IonValue::Symbol(kfx_symbol as u64),
),
(
KfxSymbol::Representation as u64,
IonValue::Struct(vec![(
KfxSymbol::Label as u64,
IonValue::String(target.label.clone()),
)]),
),
(
KfxSymbol::TargetPosition as u64,
IonValue::Struct(vec![
(
KfxSymbol::Id as u64,
IonValue::Int(target.fragment_id as i64),
),
(
KfxSymbol::Offset as u64,
IonValue::Int(target.offset as i64),
),
]),
),
])),
);
entries.push(entry);
}
entries
}
fn build_toc_entries_with_positions(
entries: &[crate::book::TocEntry],
ctx: &ExportContext,
) -> Vec<IonValue> {
entries
.iter()
.map(|entry| {
let mut fields = Vec::new();
let representation = IonValue::Struct(vec![(
KfxSymbol::Label as u64,
IonValue::String(entry.title.clone()),
)]);
fields.push((KfxSymbol::Representation as u64, representation));
let (fragment_id, offset) = ctx
.anchor_registry
.get_anchor_position(&entry.href)
.unwrap_or_else(|| {
resolve_toc_position(&entry.href, ctx)
});
let target = IonValue::Struct(vec![
(KfxSymbol::Id as u64, IonValue::Int(fragment_id as i64)),
(KfxSymbol::Offset as u64, IonValue::Int(offset as i64)),
]);
fields.push((KfxSymbol::TargetPosition as u64, target));
if !entry.children.is_empty() {
let child_entries = build_toc_entries_with_positions(&entry.children, ctx);
fields.push((KfxSymbol::Entries as u64, IonValue::List(child_entries)));
}
let nav_unit = IonValue::Struct(fields);
IonValue::Annotated(vec![KfxSymbol::NavUnit as u64], Box::new(nav_unit))
})
.collect()
}
fn resolve_toc_position(href: &str, ctx: &ExportContext) -> (u64, usize) {
let base_path = if let Some(hash_pos) = href.find('#') {
&href[..hash_pos]
} else {
href
};
if let Some(&content_id) = ctx.first_content_ids.get(base_path) {
return (content_id, 0);
}
if let Some(&content_id) = ctx.first_content_ids.values().next() {
(content_id, 0)
} else {
(200, 0) }
}
fn build_chapter_entities_grouped(
chapter: &IRChapter,
chapter_id: ChapterId,
section_name: &str,
ctx: &mut ExportContext,
) -> (KfxFragment, KfxFragment, Option<KfxFragment>) {
use crate::kfx::storyline::{ir_to_tokens, tokens_to_ion};
let is_cover = ctx.cover_fragment_id.is_none() && is_image_only_chapter(chapter);
let story_name = format!("story_{}", section_name);
let content_name = format!("content_{}", section_name);
let section_name_symbol = ctx.symbols.get_or_intern(section_name);
let story_name_symbol = ctx.symbols.get_or_intern(&story_name);
let content_name_symbol = ctx.symbols.get_or_intern(&content_name);
ctx.begin_chapter(&content_name);
let section_id = ctx
.get_chapter_fragment(chapter_id)
.unwrap_or_else(|| ctx.next_fragment_id());
let (storyline_content_list, content_strings) = if is_cover {
let content_list = build_cover_storyline(chapter, ctx);
let text = ctx.drain_text();
(content_list, text)
} else {
let tokens = ir_to_tokens(chapter, ctx);
let content_list = tokens_to_ion(&tokens, ctx);
let text = ctx.drain_text();
(content_list, text)
};
let content_fragment = if !content_strings.is_empty() {
let content_ion = IonValue::Struct(vec![
(
KfxSymbol::Name as u64,
IonValue::Symbol(content_name_symbol),
),
(
KfxSymbol::ContentList as u64,
IonValue::List(content_strings.into_iter().map(IonValue::String).collect()),
),
]);
Some(KfxFragment::new(
KfxSymbol::Content,
&content_name,
content_ion,
))
} else {
None
};
let storyline_ion = IonValue::Struct(vec![
(
KfxSymbol::StoryName as u64,
IonValue::Symbol(story_name_symbol),
),
(KfxSymbol::ContentList as u64, storyline_content_list),
]);
let storyline_fragment = KfxFragment::new(KfxSymbol::Storyline, &story_name, storyline_ion);
let page_template = if is_cover {
IonValue::Struct(vec![
(KfxSymbol::Id as u64, IonValue::Int(section_id as i64)),
(
KfxSymbol::StoryName as u64,
IonValue::Symbol(story_name_symbol),
),
(
KfxSymbol::Type as u64,
IonValue::Symbol(KfxSymbol::Container as u64),
),
(KfxSymbol::FixedWidth as u64, IonValue::Int(1400)),
(KfxSymbol::FixedHeight as u64, IonValue::Int(2100)),
(
KfxSymbol::Layout as u64,
IonValue::Symbol(KfxSymbol::ScaleFit as u64),
),
(
KfxSymbol::Float as u64,
IonValue::Symbol(KfxSymbol::Center as u64),
),
])
} else {
IonValue::Struct(vec![
(KfxSymbol::Id as u64, IonValue::Int(section_id as i64)),
(
KfxSymbol::StoryName as u64,
IonValue::Symbol(story_name_symbol),
),
(
KfxSymbol::Type as u64,
IonValue::Symbol(KfxSymbol::Text as u64),
),
])
};
let section_ion = IonValue::Struct(vec![
(
KfxSymbol::SectionName as u64,
IonValue::Symbol(section_name_symbol),
),
(
KfxSymbol::PageTemplates as u64,
IonValue::List(vec![page_template]),
),
]);
let section_fragment =
KfxFragment::new_with_id(KfxSymbol::Section, section_id, section_name, section_ion);
(section_fragment, storyline_fragment, content_fragment)
}
fn build_cover_storyline(chapter: &IRChapter, ctx: &mut ExportContext) -> IonValue {
use crate::ir::Role;
for node_id in chapter.iter_dfs() {
let node = match chapter.node(node_id) {
Some(n) => n,
None => continue,
};
if node.role == Role::Image {
if let Some(src) = chapter.semantics.src(node_id) {
let resource_name = ctx.resource_registry.get_or_create_name(src);
let resource_name_symbol = ctx.symbols.get_or_intern(&resource_name);
let style_symbol = ctx.register_style_id(node.style, &chapter.styles);
let container_id = ctx.fragment_ids.next_id();
let image_struct = IonValue::Struct(vec![
(KfxSymbol::Id as u64, IonValue::Int(container_id as i64)),
(KfxSymbol::Style as u64, IonValue::Symbol(style_symbol)),
(
KfxSymbol::Type as u64,
IonValue::Symbol(KfxSymbol::Image as u64),
),
(
KfxSymbol::ResourceName as u64,
IonValue::Symbol(resource_name_symbol),
),
]);
return IonValue::List(vec![image_struct]);
}
}
}
IonValue::List(vec![])
}
#[allow(dead_code)]
fn build_chapter_entities(
chapter: &IRChapter,
chapter_id: ChapterId,
section_name: &str,
ctx: &mut ExportContext,
) -> Vec<KfxFragment> {
use crate::kfx::storyline::{ir_to_tokens, tokens_to_ion};
let mut fragments = Vec::new();
let story_name = format!("story_{}", section_name);
let content_name = format!("content_{}", section_name);
let section_name_symbol = ctx.symbols.get_or_intern(section_name);
let story_name_symbol = ctx.symbols.get_or_intern(&story_name);
let content_name_symbol = ctx.symbols.get_or_intern(&content_name);
ctx.begin_chapter(&content_name);
let section_id = ctx
.get_chapter_fragment(chapter_id)
.unwrap_or_else(|| ctx.next_fragment_id());
let tokens = ir_to_tokens(chapter, ctx);
let storyline_content_list = tokens_to_ion(&tokens, ctx);
let content_strings = ctx.drain_text();
if !content_strings.is_empty() {
let content_ion = IonValue::Struct(vec![
(
KfxSymbol::Name as u64,
IonValue::Symbol(content_name_symbol),
),
(
KfxSymbol::ContentList as u64,
IonValue::List(content_strings.into_iter().map(IonValue::String).collect()),
),
]);
fragments.push(KfxFragment::new(
KfxSymbol::Content,
&content_name,
content_ion,
));
}
let storyline_ion = IonValue::Struct(vec![
(
KfxSymbol::StoryName as u64,
IonValue::Symbol(story_name_symbol),
),
(KfxSymbol::ContentList as u64, storyline_content_list),
]);
fragments.push(KfxFragment::new(
KfxSymbol::Storyline,
&story_name,
storyline_ion,
));
let page_template = IonValue::Struct(vec![
(KfxSymbol::Id as u64, IonValue::Int(section_id as i64)),
(
KfxSymbol::StoryName as u64,
IonValue::Symbol(story_name_symbol),
),
(
KfxSymbol::Type as u64,
IonValue::Symbol(KfxSymbol::Text as u64),
),
]);
let section_ion = IonValue::Struct(vec![
(
KfxSymbol::SectionName as u64,
IonValue::Symbol(section_name_symbol),
),
(
KfxSymbol::PageTemplates as u64,
IonValue::List(vec![page_template]),
),
]);
fragments.push(KfxFragment::new_with_id(
KfxSymbol::Section,
section_id,
section_name,
section_ion,
));
fragments
}
fn build_symbol_table_ion(local_symbols: &[String]) -> Vec<u8> {
use crate::kfx::ion::IonWriter;
use crate::kfx::symbols::KFX_MAX_SYMBOL_ID;
let mut writer = IonWriter::new();
writer.write_bvm();
let import_entry = IonValue::Struct(vec![
(4, IonValue::String("YJ_symbols".to_string())), (5, IonValue::Int(10)), (8, IonValue::Int(KFX_MAX_SYMBOL_ID as i64)), ]);
let symbols_list: Vec<IonValue> = local_symbols
.iter()
.map(|s| IonValue::String(s.clone()))
.collect();
let symbol_table = IonValue::Struct(vec![
(6, IonValue::List(vec![import_entry])), (7, IonValue::List(symbols_list)), ]);
writer.write_annotated(&[3], &symbol_table);
writer.into_bytes()
}
fn build_format_capabilities_ion() -> Vec<u8> {
let caps = IonValue::Struct(vec![
(
KfxSymbol::Namespace as u64,
IonValue::String("yj".to_string()),
),
(KfxSymbol::MajorVersion as u64, IonValue::Int(1)),
(KfxSymbol::MinorVersion as u64, IonValue::Int(0)),
(KfxSymbol::Features as u64, IonValue::List(vec![])),
]);
serialize_annotated_ion(KfxSymbol::FormatCapabilities as u64, &caps)
}
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)
}
fn build_resource_fragment(href: &str, data: &[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.to_vec())
}
fn build_anchor_fragments(ctx: &mut ExportContext) -> (Vec<KfxFragment>, HashMap<u64, Vec<u64>>) {
let mut fragments = Vec::new();
let mut anchor_ids_by_fragment: HashMap<u64, Vec<u64>> = HashMap::new();
let resolved_anchors = ctx.anchor_registry.drain_anchors();
for anchor in resolved_anchors {
let anchor_symbol_id = ctx.symbols.get_or_intern(&anchor.symbol);
anchor_ids_by_fragment
.entry(anchor.section_id)
.or_default()
.push(anchor_symbol_id);
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 external_anchors = ctx.anchor_registry.drain_external_anchors();
for anchor in external_anchors {
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, anchor_ids_by_fragment)
}
fn generate_resource_name(href: &str, ctx: &mut ExportContext) -> String {
ctx.resource_registry.get_or_create_name(href)
}
fn build_position_map_fragment(
ctx: &ExportContext,
anchor_ids_by_fragment: &HashMap<u64, Vec<u64>>,
) -> KfxFragment {
let mut entries = Vec::new();
let section_offset = if let Some(cover_fid) = ctx.cover_fragment_id {
let mut contains_list = vec![IonValue::Int(cover_fid as i64)];
if let Some(content_id) = ctx.cover_content_id {
contains_list.push(IonValue::Int(content_id as i64));
}
let entry = IonValue::Struct(vec![
(KfxSymbol::Contains as u64, IonValue::List(contains_list)),
(
KfxSymbol::SectionName as u64,
IonValue::Symbol(ctx.section_ids[0]),
),
]);
entries.push(entry);
1 } else {
0
};
let mut chapter_entries: Vec<_> = ctx.chapter_fragments.iter().collect();
chapter_entries.sort_by_key(|(_, fid)| **fid);
for (idx, §ion_sym) in ctx.section_ids.iter().skip(section_offset).enumerate() {
if let Some(&(chapter_id, &fragment_id)) = chapter_entries.get(idx) {
let mut eid_list = vec![IonValue::Int(fragment_id as i64)];
if let Some(content_ids) = ctx.content_ids_by_chapter.get(chapter_id) {
for &content_id in content_ids {
eid_list.push(IonValue::Int(content_id as i64));
}
}
if let Some(anchor_ids) = anchor_ids_by_fragment.get(&fragment_id) {
for &anchor_id in anchor_ids {
eid_list.push(IonValue::Int(anchor_id as i64));
}
}
let entry = IonValue::Struct(vec![
(KfxSymbol::Contains as u64, IonValue::List(eid_list)),
(KfxSymbol::SectionName as u64, IonValue::Symbol(section_sym)),
]);
entries.push(entry);
}
}
let ion = IonValue::List(entries);
KfxFragment::singleton(KfxSymbol::PositionMap, ion)
}
fn build_position_id_map_fragment(ctx: &ExportContext) -> KfxFragment {
let mut entries = Vec::new();
let mut pid = 0i64;
let mut all_content_ids: Vec<u64> = Vec::new();
if let Some(cover_id) = ctx.cover_content_id {
all_content_ids.push(cover_id);
}
let mut chapter_entries: Vec<_> = ctx.chapter_fragments.iter().collect();
chapter_entries.sort_by_key(|(_, fid)| **fid);
for (chapter_id, _) in &chapter_entries {
if let Some(content_ids) = ctx.content_ids_by_chapter.get(chapter_id) {
all_content_ids.extend(content_ids.iter().copied());
}
}
all_content_ids.sort();
for eid in all_content_ids {
let entry = IonValue::Struct(vec![
(KfxSymbol::Pid as u64, IonValue::Int(pid)),
(KfxSymbol::Eid as u64, IonValue::Int(eid as i64)),
]);
entries.push(entry);
pid += 1;
}
let ion = IonValue::List(entries);
KfxFragment::singleton(KfxSymbol::PositionIdMap, ion)
}
fn build_location_map_fragment(ctx: &ExportContext) -> KfxFragment {
const CHARS_PER_LOCATION: usize = 110;
let mut location_entries = Vec::new();
let mut content_ranges: Vec<(u64, usize, usize)> = Vec::new();
let mut cumulative_offset: usize = 0;
let mut chapter_entries: Vec<_> = ctx.chapter_fragments.iter().collect();
chapter_entries.sort_by_key(|(_, fid)| **fid);
for (chapter_id, _) in &chapter_entries {
if let Some(content_ids) = ctx.content_ids_by_chapter.get(chapter_id) {
for &content_id in content_ids {
let text_len = ctx
.content_id_lengths
.get(&content_id)
.copied()
.unwrap_or(0);
if text_len > 0 {
let start = cumulative_offset;
let end = cumulative_offset + text_len;
content_ranges.push((content_id, start, end));
cumulative_offset = end;
}
}
}
}
let total_chars = cumulative_offset;
let mut location_char_pos: usize = 0;
while location_char_pos < total_chars {
let content_id = content_ranges
.iter()
.find(|(_, start, end)| location_char_pos >= *start && location_char_pos < *end)
.map(|(id, _, _)| *id)
.unwrap_or_else(|| {
content_ranges.last().map(|(id, _, _)| *id).unwrap_or(0)
});
let entry = IonValue::Struct(vec![
(KfxSymbol::Id as u64, IonValue::Int(content_id as i64)),
(KfxSymbol::Offset as u64, IonValue::Int(0)),
]);
location_entries.push(entry);
location_char_pos += CHARS_PER_LOCATION;
}
let ion = IonValue::List(vec![IonValue::Struct(vec![(
KfxSymbol::Locations as u64,
IonValue::List(location_entries),
)])]);
KfxFragment::singleton(KfxSymbol::LocationMap, ion)
}
fn build_resource_path_fragment() -> KfxFragment {
let ion = IonValue::Struct(vec![(KfxSymbol::Entries as u64, IonValue::List(vec![]))]);
KfxFragment::singleton(KfxSymbol::ResourcePath, ion)
}
fn build_container_entity_map_fragment(
container_id: &str,
fragments: &[KfxFragment],
ctx: &ExportContext,
) -> KfxFragment {
let mut entity_names: Vec<IonValue> = Vec::new();
for frag in fragments {
if frag.fid.starts_with('$') {
continue;
}
if frag.is_raw() {
continue;
}
if let Some(symbol_id) = ctx.symbols.get(&frag.fid) {
entity_names.push(IonValue::Symbol(symbol_id));
}
}
let container_entry = IonValue::Struct(vec![
(
KfxSymbol::Id as u64,
IonValue::String(container_id.to_string()),
),
(KfxSymbol::Contains as u64, IonValue::List(entity_names)),
]);
let ion = IonValue::Struct(vec![(
KfxSymbol::ContainerList as u64,
IonValue::List(vec![container_entry]),
)]);
KfxFragment::singleton(KfxSymbol::ContainerEntityMap, ion)
}
fn detect_format_symbol(href: &str, data: &[u8]) -> u64 {
let format = detect_media_format(href, data);
format_to_kfx_symbol(format)
}
fn is_media_asset(path: &std::path::Path) -> bool {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
matches!(
ext.to_lowercase().as_str(),
"jpg" | "jpeg" | "png" | "gif" | "svg" | "webp" | "ttf" | "otf" | "woff" | "woff2"
)
}
fn resolve_landmarks_from_ir(
book: &Book,
source_to_chapter: &HashMap<String, ChapterId>,
ctx: &mut ExportContext,
) {
for landmark in book.landmarks() {
let (href_path, anchor) = match landmark.href.split_once('#') {
Some((path, anchor)) => (path, Some(anchor)),
None => (landmark.href.as_str(), None),
};
let chapter_id = source_to_chapter.get(href_path).copied();
if let Some(cid) = chapter_id {
let target = if let Some(anchor_id) = anchor {
let full_href = format!("{}#{}", href_path, anchor_id);
if let Some(&(_, node_id)) = ctx.anchor_map.get(&full_href) {
ctx.position_map
.get(&(cid, node_id))
.map(|pos| LandmarkTarget {
fragment_id: pos.fragment_id,
offset: 0,
label: landmark.label.clone(),
})
} else {
ctx.chapter_fragments
.get(&cid)
.copied()
.map(|frag_id| LandmarkTarget {
fragment_id: frag_id,
offset: 0,
label: landmark.label.clone(),
})
}
} else {
ctx.chapter_fragments
.get(&cid)
.copied()
.map(|frag_id| LandmarkTarget {
fragment_id: frag_id,
offset: 0,
label: landmark.label.clone(),
})
};
if let Some(target) = target {
ctx.landmark_fragments
.entry(landmark.landmark_type)
.or_insert(target.clone());
if landmark.landmark_type == LandmarkType::BodyMatter {
ctx.landmark_fragments
.entry(LandmarkType::StartReading)
.or_insert(target);
}
}
}
}
}
fn serialize_fragments(
fragments: &[KfxFragment],
local_symbols: &[String],
) -> Vec<SerializedEntity> {
fragments
.iter()
.map(|frag| {
let id = if frag.is_singleton() {
KfxSymbol::Null as u32 } else {
local_symbols
.iter()
.position(|s| s == &frag.fid)
.map(|i| (crate::kfx::symbols::KFX_SYMBOL_TABLE_SIZE + i) as u32)
.unwrap_or(0)
};
let data = match &frag.data {
crate::kfx::fragment::FragmentData::Ion(value) => create_entity_data(value),
crate::kfx::fragment::FragmentData::Raw(bytes) => {
crate::kfx::serialization::create_raw_media_data(bytes)
}
};
SerializedEntity {
id,
entity_type: frag.ftype as u32,
data,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_symbol_table_ion() {
let symbols = vec!["section-1".to_string(), "section-2".to_string()];
let ion = build_symbol_table_ion(&symbols);
assert_eq!(&ion[..4], &[0xe0, 0x01, 0x00, 0xea]);
}
#[test]
fn test_build_format_capabilities_ion() {
let ion = build_format_capabilities_ion();
assert_eq!(&ion[..4], &[0xe0, 0x01, 0x00, 0xea]);
}
#[test]
fn test_metadata_fragment_contains_reading_orders() {
let mut ctx = ExportContext::new();
ctx.register_section("c0");
ctx.register_section("c1");
let frag = build_metadata_fragment(&ctx);
assert_eq!(frag.ftype, KfxSymbol::Metadata as u64);
assert!(frag.is_singleton());
if let crate::kfx::fragment::FragmentData::Ion(ion) = &frag.data {
if let IonValue::Struct(fields) = ion {
let has_reading_orders = fields
.iter()
.any(|(id, _)| *id == KfxSymbol::ReadingOrders as u64);
assert!(has_reading_orders, "metadata should contain reading_orders");
} else {
panic!("expected Struct");
}
} else {
panic!("expected Ion data");
}
}
#[test]
fn test_book_metadata_fragment_has_categorised_metadata() {
let book = Book::open("tests/fixtures/epictetus.epub").unwrap();
let ctx = ExportContext::new();
let container_id = generate_container_id();
let frag = build_book_metadata_fragment(&book, &container_id, &ctx);
assert_eq!(frag.ftype, KfxSymbol::BookMetadata as u64);
assert!(frag.is_singleton());
if let crate::kfx::fragment::FragmentData::Ion(ion) = &frag.data {
if let IonValue::Struct(fields) = ion {
let has_categorised = fields
.iter()
.any(|(id, _)| *id == KfxSymbol::CategorisedMetadata as u64);
assert!(
has_categorised,
"book_metadata should contain categorised_metadata"
);
let categorised = fields
.iter()
.find(|(id, _)| *id == KfxSymbol::CategorisedMetadata as u64)
.map(|(_, v)| v);
if let Some(IonValue::List(categories)) = categorised {
assert_eq!(categories.len(), 3, "should have 3 metadata categories");
} else {
panic!("categorised_metadata should be a list");
}
} else {
panic!("expected Struct");
}
} else {
panic!("expected Ion data");
}
}
#[test]
fn test_metadata_kv_helper() {
let kv = metadata_kv("test_key", "test_value");
if let IonValue::Struct(fields) = kv {
assert_eq!(fields.len(), 2);
let key_field = fields.iter().find(|(id, _)| *id == KfxSymbol::Key as u64);
let value_field = fields.iter().find(|(id, _)| *id == KfxSymbol::Value as u64);
assert!(key_field.is_some(), "should have key field");
assert!(value_field.is_some(), "should have value field");
if let Some((_, IonValue::String(k))) = key_field {
assert_eq!(k, "test_key");
}
if let Some((_, IonValue::String(v))) = value_field {
assert_eq!(v, "test_value");
}
} else {
panic!("expected Struct");
}
}
#[test]
fn test_book_navigation_structure() {
let mut 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);
let source_path = book.source_id(entry.id).unwrap_or("").to_string();
(entry.id, section_name, source_path)
})
.collect();
for (chapter_id, section_name, source_path) in &spine_info {
ctx.register_section(section_name);
if let Ok(chapter) = book.load_chapter(*chapter_id) {
survey_chapter(&chapter, *chapter_id, source_path, &mut ctx);
}
}
let frag = build_book_navigation_fragment_with_positions(&book, &ctx);
assert_eq!(frag.ftype, KfxSymbol::BookNavigation as u64);
if let crate::kfx::fragment::FragmentData::Ion(ion) = &frag.data {
if let IonValue::List(reading_orders) = ion {
assert_eq!(reading_orders.len(), 1, "should have one reading order");
if let IonValue::Struct(fields) = &reading_orders[0] {
let has_reading_order_name = fields
.iter()
.any(|(id, _)| *id == KfxSymbol::ReadingOrderName as u64);
let has_nav_containers = fields
.iter()
.any(|(id, _)| *id == KfxSymbol::NavContainers as u64);
assert!(has_reading_order_name, "should have reading_order_name");
assert!(has_nav_containers, "should have nav_containers");
} else {
panic!("reading order should be a struct");
}
} else {
panic!("book_navigation should be a list");
}
} else {
panic!("expected Ion data");
}
}
#[test]
fn test_content_features_fragment() {
let frag = build_content_features_fragment();
assert_eq!(frag.ftype, KfxSymbol::ContentFeatures as u64);
assert!(frag.is_singleton());
if let crate::kfx::fragment::FragmentData::Ion(ion) = &frag.data {
if let IonValue::Struct(fields) = ion {
let features = fields
.iter()
.find(|(id, _)| *id == KfxSymbol::Features as u64);
assert!(
features.is_some(),
"content_features should contain features"
);
if let Some((_, IonValue::List(items))) = features {
assert_eq!(items.len(), 3, "should have 3 feature entries");
} else {
panic!("features should be a list");
}
} else {
panic!("expected Struct");
}
} else {
panic!("expected Ion data");
}
}
#[test]
fn test_document_data_fragment() {
let mut ctx = ExportContext::new();
ctx.register_section("c0");
ctx.register_section("c1");
ctx.next_fragment_id();
ctx.next_fragment_id();
let frag = build_document_data_fragment(&ctx);
assert_eq!(frag.ftype, KfxSymbol::DocumentData as u64);
assert!(frag.is_singleton());
if let crate::kfx::fragment::FragmentData::Ion(ion) = &frag.data {
if let IonValue::Struct(fields) = ion {
let field_ids: Vec<u64> = fields.iter().map(|(id, _)| *id).collect();
assert!(
field_ids.contains(&(KfxSymbol::Direction as u64)),
"should have direction"
);
assert!(
field_ids.contains(&(KfxSymbol::ColumnCount as u64)),
"should have column_count"
);
assert!(
field_ids.contains(&(KfxSymbol::FontSize as u64)),
"should have font_size"
);
assert!(
field_ids.contains(&(KfxSymbol::WritingMode as u64)),
"should have writing_mode"
);
assert!(
field_ids.contains(&(KfxSymbol::Selection as u64)),
"should have selection"
);
assert!(
field_ids.contains(&(KfxSymbol::MaxId as u64)),
"should have max_id"
);
assert!(
field_ids.contains(&(KfxSymbol::LineHeight as u64)),
"should have line_height"
);
assert!(
field_ids.contains(&(KfxSymbol::ReadingOrders as u64)),
"should have reading_orders"
);
} else {
panic!("expected Struct");
}
} else {
panic!("expected Ion data");
}
}
#[test]
fn test_document_data_max_id_reflects_all_fragment_ids() {
let mut ctx = ExportContext::new();
ctx.register_section("c0");
for _ in 0..100 {
ctx.next_fragment_id();
}
let frag = build_document_data_fragment(&ctx);
if let crate::kfx::fragment::FragmentData::Ion(IonValue::Struct(fields)) = &frag.data {
let max_id_field = fields.iter().find(|(id, _)| *id == KfxSymbol::MaxId as u64);
if let Some((_, IonValue::Int(max_id))) = max_id_field {
assert!(
*max_id >= 100,
"max_id ({}) should reflect all generated fragment IDs",
max_id
);
} else {
panic!("max_id should be an integer");
}
} else {
panic!("expected Ion struct data");
}
}
#[test]
fn test_singleton_uses_null_symbol() {
let frag = build_content_features_fragment();
let local_symbols: Vec<String> = vec![];
let entities = serialize_fragments(&[frag], &local_symbols);
assert_eq!(entities[0].id, KfxSymbol::Null as u32);
}
#[test]
fn test_build_headings_entries_empty() {
let ctx = ExportContext::new();
let entries = build_headings_entries(&ctx);
assert!(
entries.is_empty(),
"No headings should produce empty entries"
);
}
#[test]
fn test_build_headings_entries_single_level() {
use crate::kfx::context::HeadingPosition;
let mut ctx = ExportContext::new();
ctx.heading_positions.push(HeadingPosition {
level: 2,
fragment_id: 100,
offset: 0,
});
ctx.heading_positions.push(HeadingPosition {
level: 2,
fragment_id: 100,
offset: 50,
});
ctx.heading_positions.push(HeadingPosition {
level: 2,
fragment_id: 101,
offset: 0,
});
let entries = build_headings_entries(&ctx);
assert_eq!(entries.len(), 1, "Should have one level group for h2");
if let IonValue::Annotated(annotations, inner) = &entries[0] {
assert_eq!(annotations[0], KfxSymbol::NavUnit as u64);
if let IonValue::Struct(fields) = inner.as_ref() {
let landmark = fields
.iter()
.find(|(id, _)| *id == KfxSymbol::LandmarkType as u64);
assert!(landmark.is_some(), "Should have landmark_type");
if let Some((_, IonValue::Symbol(sym))) = landmark {
assert_eq!(*sym, KfxSymbol::H2 as u64);
}
let nested = fields
.iter()
.find(|(id, _)| *id == KfxSymbol::Entries as u64);
assert!(nested.is_some(), "Should have nested entries");
if let Some((_, IonValue::List(list))) = nested {
assert_eq!(list.len(), 3, "Should have 3 nested h2 entries");
}
}
} else {
panic!("Expected annotated nav_unit");
}
}
#[test]
fn test_build_headings_entries_multiple_levels() {
use crate::kfx::context::HeadingPosition;
let mut ctx = ExportContext::new();
ctx.heading_positions.push(HeadingPosition {
level: 2,
fragment_id: 100,
offset: 0,
});
ctx.heading_positions.push(HeadingPosition {
level: 3,
fragment_id: 100,
offset: 20,
});
ctx.heading_positions.push(HeadingPosition {
level: 4,
fragment_id: 101,
offset: 0,
});
ctx.heading_positions.push(HeadingPosition {
level: 3,
fragment_id: 101,
offset: 30,
});
let entries = build_headings_entries(&ctx);
assert_eq!(entries.len(), 3, "Should have three level groups");
let levels: Vec<u64> = entries
.iter()
.filter_map(|e| {
if let IonValue::Annotated(_, inner) = e {
if let IonValue::Struct(fields) = inner.as_ref() {
fields
.iter()
.find(|(id, _)| *id == KfxSymbol::LandmarkType as u64)
.and_then(|(_, v)| {
if let IonValue::Symbol(sym) = v {
Some(*sym)
} else {
None
}
})
} else {
None
}
} else {
None
}
})
.collect();
assert_eq!(
levels,
vec![
KfxSymbol::H2 as u64,
KfxSymbol::H3 as u64,
KfxSymbol::H4 as u64
]
);
}
#[test]
fn test_build_headings_entries_ignores_h1() {
use crate::kfx::context::HeadingPosition;
let mut ctx = ExportContext::new();
ctx.heading_positions.push(HeadingPosition {
level: 1,
fragment_id: 100,
offset: 0,
});
let entries = build_headings_entries(&ctx);
assert!(entries.is_empty(), "h1 should be ignored");
}
#[test]
fn test_build_headings_entries_target_position() {
use crate::kfx::context::HeadingPosition;
let mut ctx = ExportContext::new();
ctx.heading_positions.push(HeadingPosition {
level: 2,
fragment_id: 12345,
offset: 99,
});
let entries = build_headings_entries(&ctx);
assert_eq!(entries.len(), 1);
if let IonValue::Annotated(_, inner) = &entries[0]
&& let IonValue::Struct(fields) = inner.as_ref()
{
let target = fields
.iter()
.find(|(id, _)| *id == KfxSymbol::TargetPosition as u64);
if let Some((_, IonValue::Struct(pos_fields))) = target {
let id_field = pos_fields
.iter()
.find(|(id, _)| *id == KfxSymbol::Id as u64);
let offset_field = pos_fields
.iter()
.find(|(id, _)| *id == KfxSymbol::Offset as u64);
if let Some((_, IonValue::Int(id))) = id_field {
assert_eq!(*id, 12345);
} else {
panic!("Expected Int id");
}
if let Some((_, IonValue::Int(offset))) = offset_field {
assert_eq!(*offset, 99);
} else {
panic!("Expected Int offset");
}
}
}
}
#[test]
fn test_position_id_map_includes_all_content_ids() {
use crate::ChapterId;
let mut ctx = ExportContext::new();
ctx.register_section("c0");
ctx.register_section("c1");
let chapter1 = ChapterId(1);
let chapter2 = ChapterId(2);
ctx.content_ids_by_chapter
.entry(chapter1)
.or_default()
.extend(vec![100, 101, 102]);
ctx.content_ids_by_chapter
.entry(chapter2)
.or_default()
.extend(vec![200, 201]);
ctx.chapter_fragments.insert(chapter1, 90);
ctx.chapter_fragments.insert(chapter2, 95);
let frag = build_position_id_map_fragment(&ctx);
if let crate::kfx::fragment::FragmentData::Ion(IonValue::List(entries)) = &frag.data {
assert_eq!(
entries.len(),
5,
"position_id_map should have one entry per content ID"
);
let eids: Vec<i64> = entries
.iter()
.filter_map(|entry| {
if let IonValue::Struct(fields) = entry {
fields
.iter()
.find(|(id, _)| *id == KfxSymbol::Eid as u64)
.and_then(|(_, v)| {
if let IonValue::Int(eid) = v {
Some(*eid)
} else {
None
}
})
} else {
None
}
})
.collect();
assert!(eids.contains(&100), "should contain content ID 100");
assert!(eids.contains(&101), "should contain content ID 101");
assert!(eids.contains(&102), "should contain content ID 102");
assert!(eids.contains(&200), "should contain content ID 200");
assert!(eids.contains(&201), "should contain content ID 201");
} else {
panic!("expected List data");
}
}
}
#[cfg(test)]
#[allow(clippy::vec_init_then_push, clippy::needless_range_loop)]
mod entity_structure_tests {
use super::*;
use crate::book::Book;
use crate::kfx::fragment::FragmentData;
#[test]
fn test_entity_order_matches_reference() {
let mut book = Book::open("tests/fixtures/epictetus.epub").unwrap();
let container_id = generate_container_id();
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();
for (chapter_id, section_name) in &spine_info {
ctx.register_section(section_name);
let source_path = book.source_id(*chapter_id).unwrap_or("").to_string();
if let Ok(chapter) = book.load_chapter(*chapter_id) {
survey_chapter(&chapter, *chapter_id, &source_path, &mut ctx);
}
}
let mut fragments = Vec::new();
fragments.push(build_content_features_fragment());
fragments.push(build_book_metadata_fragment(&book, &container_id, &ctx));
fragments.push(build_metadata_fragment(&ctx));
fragments.push(build_document_data_fragment(&ctx));
fragments.push(build_book_navigation_fragment_with_positions(&book, &ctx));
let mut section_fragments = Vec::new();
let mut storyline_fragments = Vec::new();
let mut content_fragments = Vec::new();
for (chapter_id, section_name) in &spine_info {
if let Ok(chapter) = book.load_chapter(*chapter_id) {
let (section, storyline, content) =
build_chapter_entities_grouped(&chapter, *chapter_id, section_name, &mut ctx);
section_fragments.push(section);
storyline_fragments.push(storyline);
if let Some(c) = content {
content_fragments.push(c);
}
}
}
fragments.extend(section_fragments);
fragments.extend(storyline_fragments);
fragments.extend(content_fragments);
let types: Vec<u64> = fragments.iter().map(|f| f.ftype).collect();
assert_eq!(types[0], KfxSymbol::ContentFeatures as u64);
assert_eq!(types[1], KfxSymbol::BookMetadata as u64);
assert_eq!(types[2], KfxSymbol::Metadata as u64);
assert_eq!(types[3], KfxSymbol::DocumentData as u64);
assert_eq!(types[4], KfxSymbol::BookNavigation as u64);
let after_header = &types[5..];
let section_count = after_header
.iter()
.take_while(|&&t| t == KfxSymbol::Section as u64)
.count();
assert!(section_count > 0, "should have sections after header");
let after_sections = &after_header[section_count..];
let storyline_count = after_sections
.iter()
.take_while(|&&t| t == KfxSymbol::Storyline as u64)
.count();
assert!(storyline_count > 0, "should have storylines after sections");
let after_storylines = &after_sections[storyline_count..];
let content_count = after_storylines
.iter()
.take_while(|&&t| t == KfxSymbol::Content as u64)
.count();
for t in after_storylines.iter().take(content_count) {
assert_eq!(
*t,
KfxSymbol::Content as u64,
"content should follow storylines"
);
}
for i in 1..section_count {
assert_eq!(
after_header[i],
KfxSymbol::Section as u64,
"sections should be grouped"
);
}
for i in 1..storyline_count {
assert_eq!(
after_sections[i],
KfxSymbol::Storyline as u64,
"storylines should be grouped"
);
}
}
#[test]
fn test_chapter_entities_grouped_returns_correct_types() {
let mut book = Book::open("tests/fixtures/epictetus.epub").unwrap();
let mut ctx = ExportContext::new();
let spine_entry = book.spine().first().unwrap();
let chapter_id = spine_entry.id;
let section_name = "c0";
ctx.register_section(section_name);
let source_path = book.source_id(chapter_id).unwrap_or("").to_string();
if let Ok(chapter) = book.load_chapter(chapter_id) {
survey_chapter(&chapter, chapter_id, &source_path, &mut ctx);
}
let chapter = book.load_chapter(chapter_id).unwrap();
let (section, storyline, content) =
build_chapter_entities_grouped(&chapter, chapter_id, section_name, &mut ctx);
assert_eq!(section.ftype, KfxSymbol::Section as u64);
assert_eq!(storyline.ftype, KfxSymbol::Storyline as u64);
if let FragmentData::Ion(IonValue::Struct(fields)) = §ion.data {
let has_section_name = fields
.iter()
.any(|(id, _)| *id == KfxSymbol::SectionName as u64);
let has_page_templates = fields
.iter()
.any(|(id, _)| *id == KfxSymbol::PageTemplates as u64);
assert!(has_section_name, "section should have section_name");
assert!(has_page_templates, "section should have page_templates");
}
if let FragmentData::Ion(IonValue::Struct(fields)) = &storyline.data {
let has_story_name = fields
.iter()
.any(|(id, _)| *id == KfxSymbol::StoryName as u64);
let has_content_list = fields
.iter()
.any(|(id, _)| *id == KfxSymbol::ContentList as u64);
assert!(has_story_name, "storyline should have story_name");
assert!(has_content_list, "storyline should have content_list");
}
if let Some(content_frag) = content {
assert_eq!(content_frag.ftype, KfxSymbol::Content as u64);
if let FragmentData::Ion(IonValue::Struct(fields)) = &content_frag.data {
let has_name = fields.iter().any(|(id, _)| *id == KfxSymbol::Name as u64);
let has_content_list = fields
.iter()
.any(|(id, _)| *id == KfxSymbol::ContentList as u64);
assert!(has_name, "content should have name");
assert!(has_content_list, "content should have content_list");
}
}
}
}
#[cfg(test)]
mod section_type_tests {
use super::*;
use crate::book::Book;
use crate::kfx::cover::{needs_standalone_cover, normalize_cover_path};
use crate::kfx::fragment::FragmentData;
#[test]
fn test_titlepage_section_has_text_type_when_standalone_cover_exists() {
let mut book = Book::open("tests/fixtures/epictetus.epub").unwrap();
let mut ctx = ExportContext::new();
let asset_paths: Vec<_> = book.list_assets();
let cover_image = book
.metadata()
.cover_image
.clone()
.expect("should have cover");
let normalized = normalize_cover_path(&cover_image, &asset_paths);
let first_chapter_id = book.spine().first().expect("should have spine").id;
let first_chapter = book.load_chapter(first_chapter_id).unwrap();
assert!(
needs_standalone_cover(&normalized, &first_chapter),
"test requires a book with different cover and titlepage images"
);
ctx.register_section("c0");
ctx.register_section("c1");
ctx.cover_fragment_id = Some(ctx.next_fragment_id());
let source_path = book.source_id(first_chapter_id).unwrap_or("").to_string();
let first_chapter = book.load_chapter(first_chapter_id).unwrap();
survey_chapter(&first_chapter, first_chapter_id, &source_path, &mut ctx);
let first_chapter = book.load_chapter(first_chapter_id).unwrap();
let (section, _, _) =
build_chapter_entities_grouped(&first_chapter, first_chapter_id, "c1", &mut ctx);
if let FragmentData::Ion(IonValue::Struct(fields)) = §ion.data {
let page_templates = fields
.iter()
.find(|(id, _)| *id == KfxSymbol::PageTemplates as u64)
.expect("section should have page_templates");
if let (_, IonValue::List(templates)) = page_templates {
let template = &templates[0];
if let IonValue::Struct(template_fields) = template {
let type_field = template_fields
.iter()
.find(|(id, _)| *id == KfxSymbol::Type as u64)
.expect("page_template should have type");
if let (_, IonValue::Symbol(type_sym)) = type_field {
assert_eq!(
*type_sym,
KfxSymbol::Text as u64,
"titlepage (c1) should have type: text when standalone cover exists, \
but got type: container"
);
} else {
panic!("type should be a symbol");
}
}
}
} else {
panic!("section should have Ion struct data");
}
}
}
#[cfg(test)]
mod resource_export_tests {
use super::*;
use crate::book::Book;
#[test]
fn test_kfx_export_includes_images() {
let mut book = Book::open("tests/fixtures/epictetus.epub").unwrap();
let data = build_kfx_container(&mut book).unwrap();
assert!(
data.len() > 400000,
"KFX should include image data, got {} bytes",
data.len()
);
}
#[test]
fn test_kfx_asset_roundtrip() {
let mut book = Book::open("tests/fixtures/epictetus.epub").unwrap();
let kfx_data = build_kfx_container(&mut book).unwrap();
let temp_path = std::env::temp_dir().join("test_roundtrip.kfx");
std::fs::write(&temp_path, &kfx_data).unwrap();
let mut reimported = Book::open(&temp_path).unwrap();
let assets = reimported.list_assets();
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::book::Book;
#[test]
fn test_cross_file_anchor_resolution_flow() {
let mut 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 enchiridion_id = spine_info
.iter()
.find(|(id, _)| {
book.source_id(*id)
.map(|p| p.contains("enchiridion"))
.unwrap_or(false)
})
.map(|(id, _)| *id);
let endnotes_id = spine_info
.iter()
.find(|(id, _)| {
book.source_id(*id)
.map(|p| p.contains("endnotes"))
.unwrap_or(false)
})
.map(|(id, _)| *id);
assert!(enchiridion_id.is_some(), "Should find enchiridion chapter");
assert!(endnotes_id.is_some(), "Should find endnotes chapter");
let enchiridion_id = enchiridion_id.unwrap();
let endnotes_id = endnotes_id.unwrap();
let endnotes_path = book.source_id(endnotes_id).unwrap().to_string();
if let Ok(chapter) = book.load_chapter(enchiridion_id) {
collect_needed_anchors_from_chapter(&chapter, chapter.root(), &mut ctx);
}
assert!(
ctx.needed_anchor_count() > 0,
"Should have registered some needed anchors"
);
if let Ok(chapter) = book.load_chapter(endnotes_id) {
ctx.register_section("c_endnotes");
survey_chapter(&chapter, endnotes_id, &endnotes_path, &mut ctx);
}
ctx.begin_chapter_export(endnotes_id, &endnotes_path);
assert_eq!(
ctx.get_current_chapter_path(),
Some(endnotes_path.as_str()),
"current_chapter_path should be set to endnotes path"
);
let sample_key = ctx.build_anchor_key("note-1");
assert!(
ctx.has_needed_anchor(&sample_key),
"Anchor key '{}' should be in needed_anchors",
sample_key
);
}
#[test]
fn test_anchor_symbol_reuse() {
let mut 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 endnotes_id = spine_info
.iter()
.find(|(id, _)| {
book.source_id(*id)
.map(|p| p.contains("endnotes"))
.unwrap_or(false)
})
.map(|(id, _)| *id)
.expect("Should find endnotes chapter");
let endnotes_path = book.source_id(endnotes_id).unwrap().to_string();
for (chapter_id, _) in &spine_info {
if let Ok(chapter) = book.load_chapter(*chapter_id) {
collect_needed_anchors_from_chapter(&chapter, chapter.root(), &mut ctx);
}
}
let expected_key = format!("{}#note-1", endnotes_path);
let link_symbol = ctx.anchor_registry.get_symbol(&expected_key);
assert!(
link_symbol.is_some(),
"Link to '{}' should be registered in anchor_registry",
expected_key
);
let link_symbol = link_symbol.unwrap().to_string();
ctx.begin_chapter_export(endnotes_id, &endnotes_path);
let content_id = 12345u64;
ctx.create_anchor_if_needed("note-1", content_id, 0);
let anchors = ctx.anchor_registry.drain_anchors();
let note_anchor = anchors.iter().find(|a| a.anchor_name.ends_with("note-1"));
assert!(
note_anchor.is_some(),
"Should have created anchor for note-1. Keys checked: {}",
ctx.build_anchor_key("note-1")
);
let note_anchor = note_anchor.unwrap();
assert_eq!(
note_anchor.symbol, link_symbol,
"Anchor symbol '{}' should match link symbol '{}' for consistent link_to/anchor_name",
note_anchor.symbol, link_symbol
);
}
#[test]
fn test_anchor_entities_created_in_full_export() {
let mut book = Book::open("tests/fixtures/epictetus.epub").unwrap();
let kfx_data = build_kfx_container(&mut 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
);
}
}