inkhaven 3.0.8

Inkhaven — TUI literary work editor for Typst books
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
//! EPUB import P3 — orchestrator.
//!
//! Parses the package, then materialises one user **Book** → a
//! **Chapter** per spine document → one **Paragraph** holding that
//! chapter's converted prose, mirroring `crate::scrivener::import`'s
//! `Store::create_node` + `io_atomic::write` + `update_paragraph_content`
//! flow. Manifest images are extracted to a sidecar folder; their
//! in-prose references become comments so nothing breaks compilation.
//!
//! Per-item failures are collected in the report — the import never
//! aborts on a single bad chapter.

use std::path::Path;

use anyhow::{Context, Result};
use uuid::Uuid;

use super::package::EpubArchive;
use super::xhtml::xhtml_to_typst;
use crate::config::Config;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::NodeKind;
use crate::store::{InsertPosition, Store};

#[derive(Debug, Clone, Default)]
pub struct EpubImportOpts {
    /// Override the title of the created book. None → the EPUB's
    /// `dc:title`, falling back to "Imported EPUB".
    pub book_name: Option<String>,
    /// Report what would be created without writing anything.
    pub dry_run: bool,
}

#[derive(Debug, Default)]
pub struct EpubImportReport {
    pub book_title: String,
    /// The EPUB's `dc:creator`, surfaced so the author can set book metadata
    /// (inkhaven has no per-book author field to store it in yet).
    pub author: Option<String>,
    pub chapters_created: usize,
    pub paragraphs_created: usize,
    /// Images referenced in a chapter and imported as `NodeKind::Image` nodes
    /// under that chapter (so they render in the assembled book).
    pub images_imported: usize,
    /// Manifest images NOT referenced by any chapter (cover art, orphans),
    /// written to a sidecar folder for the author to place by hand.
    pub images_extracted: usize,
    pub errors: Vec<String>,
}

/// Import `epub_path` into `store`. Creates a Book → Chapters →
/// Paragraphs and extracts images to `<project>/<book-slug>-images/`.
pub fn import_epub(
    epub_path: &Path,
    store: &Store,
    cfg: &Config,
    opts: &EpubImportOpts,
) -> Result<EpubImportReport> {
    let bytes = std::fs::read(epub_path)
        .with_context(|| format!("read {}", epub_path.display()))?;
    let mut archive = EpubArchive::open(bytes)?;
    let pkg = archive.package()?;

    let mut report = EpubImportReport::default();
    let book_title = opts
        .book_name
        .clone()
        .filter(|s| !s.trim().is_empty())
        .or_else(|| (!pkg.title.trim().is_empty()).then(|| pkg.title.clone()))
        .unwrap_or_else(|| "Imported EPUB".to_string());
    report.book_title = book_title.clone();
    report.author = pkg.author.clone().filter(|a| !a.trim().is_empty());

    let image_items: Vec<&super::package::ManifestItem> = pkg
        .manifest
        .values()
        .filter(|m| m.media_type.starts_with("image/"))
        .collect();

    if opts.dry_run {
        report.chapters_created = pkg.spine.len();
        report.paragraphs_created = pkg.spine.len();
        report.images_extracted = image_items.len();
        return Ok(report);
    }

    // 1. The book.
    let book_id = create_node(store, cfg, NodeKind::Book, &book_title, None)?;

    // Manifest hrefs imported as image nodes — skipped by the sidecar step below.
    let mut imported_hrefs: std::collections::HashSet<String> = std::collections::HashSet::new();

    // 2. Each spine document → a Chapter + one Paragraph of prose, and its
    //    referenced images → `NodeKind::Image` nodes under that chapter.
    // Bound the import: a hostile EPUB can list a huge spine of itemrefs that
    // each re-decompress a 64 MiB entry, burning CPU + memory. Per-entry size is
    // already capped by the archive reader; these cap the aggregate.
    const MAX_SPINE_DOCS: usize = 10_000;
    const MAX_TOTAL_XHTML_BYTES: usize = 512 * 1024 * 1024; // across the whole import
    if pkg.spine.len() > MAX_SPINE_DOCS {
        report.errors.push(format!(
            "EPUB has {} spine documents — importing only the first {}",
            pkg.spine.len(),
            MAX_SPINE_DOCS
        ));
    }
    let mut total_xhtml_bytes: usize = 0;
    for (i, href) in pkg.spine.iter().take(MAX_SPINE_DOCS).enumerate() {
        let xhtml = match archive.read(href) {
            Some(b) => {
                total_xhtml_bytes = total_xhtml_bytes.saturating_add(b.len());
                if total_xhtml_bytes > MAX_TOTAL_XHTML_BYTES {
                    report.errors.push(format!(
                        "stopped: EPUB spine decompressed past {} MiB — refusing the rest",
                        MAX_TOTAL_XHTML_BYTES / (1024 * 1024)
                    ));
                    break;
                }
                String::from_utf8_lossy(&b).into_owned()
            }
            None => {
                report.errors.push(format!("spine document `{href}` missing from the zip"));
                continue;
            }
        };
        // Resolve the chapter's `<img>` references (relative to this document)
        // to manifest images before the refs are neutralised out of the prose.
        let chapter_images: Vec<(String, Option<String>)> = extract_img_srcs(&xhtml)
            .into_iter()
            .map(|(src, alt)| (resolve_href(href, &src), alt))
            .filter(|(res, _)| pkg.manifest.values().any(|m| m.href == *res))
            .collect();

        let body = neutralize_image_refs(&xhtml_to_typst(&xhtml));
        let chapter_title =
            first_heading(&body).unwrap_or_else(|| format!("Chapter {}", i + 1));

        let chapter_id = match create_node(
            store,
            cfg,
            NodeKind::Chapter,
            &chapter_title,
            Some(book_id),
        ) {
            Ok(id) => {
                report.chapters_created += 1;
                id
            }
            Err(e) => {
                report.errors.push(format!("chapter `{chapter_title}`: {e:#}"));
                continue;
            }
        };

        match create_paragraph(store, cfg, chapter_id, &chapter_title, &body) {
            Ok(()) => report.paragraphs_created += 1,
            Err(e) => report
                .errors
                .push(format!("paragraph in `{chapter_title}`: {e:#}")),
        }

        // Import each referenced image as an Image node under the chapter. The
        // hierarchy is reloaded per node (via `create_image`) so ordering stays
        // correct across successive inserts.
        for (res, alt) in chapter_images {
            let Some(bytes) = archive.read(&res) else { continue };
            let fname = res.rsplit('/').next().unwrap_or("image");
            let (stem, ext) = match fname.rsplit_once('.') {
                Some((s, e)) if !e.is_empty() => (s, e.to_ascii_lowercase()),
                _ => (fname, "png".to_string()),
            };
            let title = alt.filter(|a| !a.trim().is_empty()).unwrap_or_else(|| stem.to_string());
            match create_image(store, cfg, chapter_id, &title, &ext, &bytes) {
                Ok(()) => {
                    report.images_imported += 1;
                    imported_hrefs.insert(res);
                }
                Err(e) => report.errors.push(format!("image `{fname}`: {e:#}")),
            }
        }
    }

    // 3. Any manifest images NOT referenced by a chapter (cover art, orphans)
    //    go to a sidecar folder for the author to place by hand.
    let orphans: Vec<&super::package::ManifestItem> =
        image_items.iter().copied().filter(|m| !imported_hrefs.contains(&m.href)).collect();
    if !orphans.is_empty() {
        let dir = store
            .project_root()
            .join(format!("{}-images", slug::slugify(&book_title)));
        let _ = std::fs::create_dir_all(&dir);
        for item in orphans {
            if let Some(bytes) = archive.read(&item.href) {
                // Take only the final path component, splitting on BOTH separators
                // — a hostile OPF `href` can use `\` to slip past a `/`-only split
                // on Windows (`..\..\evil.png`) — and refuse traversal, so
                // `dir.join(name)` can never escape the sidecar folder.
                let base = item.href.rsplit(['/', '\\']).next().unwrap_or("image");
                let name = if base.is_empty() || base == ".." || base == "." {
                    "image"
                } else {
                    base
                };
                if crate::io_atomic::write(&dir.join(name), &bytes).is_ok() {
                    report.images_extracted += 1;
                }
            }
        }
    }

    Ok(report)
}

/// Create an `Image` node under `parent_id`, writing `bytes` as its content.
/// Reloads the hierarchy so `create_image_node`'s ordering is correct across
/// successive inserts under the same parent.
fn create_image(
    store: &Store,
    cfg: &Config,
    parent_id: Uuid,
    title: &str,
    ext: &str,
    bytes: &[u8],
) -> Result<()> {
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow::anyhow!("hierarchy: {e}"))?;
    let parent = hierarchy
        .get(parent_id)
        .cloned()
        .ok_or_else(|| anyhow::anyhow!("parent {parent_id} missing"))?;
    store
        .create_image_node(cfg, &hierarchy, title, ext, bytes, Some(&parent), InsertPosition::End)
        .map_err(|e| anyhow::anyhow!("create image `{title}`: {e}"))?;
    Ok(())
}

/// Extract `(src, alt)` from every `<img>` tag in an XHTML document.
fn extract_img_srcs(xhtml: &str) -> Vec<(String, Option<String>)> {
    let lower = xhtml.to_ascii_lowercase();
    let mut out = Vec::new();
    let mut i = 0;
    while let Some(rel) = lower[i..].find("<img") {
        let start = i + rel;
        let end = xhtml[start..].find('>').map(|e| start + e).unwrap_or(xhtml.len());
        let tag = &xhtml[start..end];
        if let Some(src) = attr_value(tag, "src") {
            out.push((src, attr_value(tag, "alt")));
        }
        i = end.max(start + 4);
    }
    out
}

/// Value of `name="…"` / `name='…'` in a tag slice (case-insensitive name).
fn attr_value(tag: &str, name: &str) -> Option<String> {
    let lower = tag.to_ascii_lowercase();
    let key = format!("{name}=");
    let after = lower.find(&key)? + key.len();
    let bytes = tag.as_bytes();
    let quote = *bytes.get(after)? as char;
    if quote != '"' && quote != '\'' {
        return None;
    }
    let rest = &tag[after + 1..];
    let end = rest.find(quote)?;
    Some(rest[..end].to_string())
}

/// Resolve a relative image `src` against the referencing document's `base`
/// href (both OPF-root-relative), collapsing `.` / `..` segments.
fn resolve_href(base: &str, src: &str) -> String {
    let src = src.split(['#', '?']).next().unwrap_or(src);
    if let Some(abs) = src.strip_prefix('/') {
        return abs.to_string();
    }
    let mut parts: Vec<&str> = match base.rfind('/') {
        Some(i) => base[..i].split('/').filter(|s| !s.is_empty()).collect(),
        None => Vec::new(),
    };
    for seg in src.split('/') {
        match seg {
            "" | "." => {}
            ".." => {
                parts.pop();
            }
            s => parts.push(s),
        }
    }
    parts.join("/")
}

/// Create a Book/Chapter branch node and return its id.
fn create_node(
    store: &Store,
    cfg: &Config,
    kind: NodeKind,
    title: &str,
    parent_id: Option<Uuid>,
) -> Result<Uuid> {
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow::anyhow!("hierarchy: {e}"))?;
    let parent = parent_id.and_then(|id| hierarchy.get(id).cloned());
    let node = store
        .create_node(cfg, &hierarchy, kind, title, parent.as_ref(), None, InsertPosition::End)
        .map_err(|e| anyhow::anyhow!("create {kind:?} `{title}`: {e}"))?;
    Ok(node.id)
}

/// Create a Paragraph node under `parent_id` and write `body` to it
/// (on-disk file + store blob), matching the Scrivener importer.
fn create_paragraph(
    store: &Store,
    cfg: &Config,
    parent_id: Uuid,
    title: &str,
    body: &str,
) -> Result<()> {
    let hierarchy = Hierarchy::load(store).map_err(|e| anyhow::anyhow!("hierarchy: {e}"))?;
    let parent = hierarchy
        .get(parent_id)
        .cloned()
        .ok_or_else(|| anyhow::anyhow!("parent {parent_id} missing"))?;
    let mut node = store
        .create_node(cfg, &hierarchy, NodeKind::Paragraph, title, Some(&parent), None, InsertPosition::End)
        .map_err(|e| anyhow::anyhow!("create paragraph: {e}"))?;
    if body.is_empty() {
        return Ok(());
    }
    if let Some(rel) = node.file.as_ref() {
        let abs = store.project_root().join(rel);
        crate::io_atomic::write(&abs, body.as_bytes())
            .map_err(|e| anyhow::anyhow!("write {}: {e}", abs.display()))?;
    }
    store
        .update_paragraph_content(&mut node, body.as_bytes())
        .map_err(|e| anyhow::anyhow!("store update: {e}"))?;
    Ok(())
}

/// The text of the first `= heading` line, if any.
fn first_heading(body: &str) -> Option<String> {
    for line in body.lines() {
        let t = line.trim_start();
        if let Some(rest) = t.strip_prefix("= ") {
            let h = rest.trim();
            if !h.is_empty() {
                return Some(h.to_string());
            }
        }
    }
    None
}

/// Turn the `#image("href")` markers the XHTML converter emits into
/// typst comments referencing the extracted basename, so an imported
/// chapter compiles cleanly while still telling the author where the
/// image was.
fn neutralize_image_refs(body: &str) -> String {
    const OPEN: &str = "#image(\"";
    let mut out = String::with_capacity(body.len());
    let mut rest = body;
    while let Some(pos) = rest.find(OPEN) {
        out.push_str(&rest[..pos]);
        let after = &rest[pos + OPEN.len()..];
        match after.find("\")") {
            Some(end) => {
                let href = &after[..end];
                let base = href.rsplit('/').next().unwrap_or(href);
                out.push_str(&format!("// [imported image: {base}]"));
                rest = &after[end + 2..];
            }
            None => {
                out.push_str(&rest[pos..]); // malformed — leave verbatim
                return out;
            }
        }
    }
    out.push_str(rest);
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn first_heading_extracts_chapter_title() {
        assert_eq!(first_heading("= Chapter One\n\nbody"), Some("Chapter One".into()));
        assert_eq!(first_heading("no heading here"), None);
        // `==` subheadings don't count as the chapter title.
        assert_eq!(first_heading("== Sub\n\nbody"), None);
    }

    #[test]
    fn neutralize_rewrites_image_refs_to_comments() {
        let out = neutralize_image_refs("see #image(\"img/x.png\") here");
        assert_eq!(out, "see // [imported image: x.png] here");
        // Unterminated marker is left verbatim, no panic.
        let bad = neutralize_image_refs("oops #image(\"x");
        assert!(bad.contains("#image(\"x"));
    }

    #[test]
    fn extract_img_srcs_reads_src_and_alt() {
        let x = r#"<p>hi</p><img src="../images/fig1.png" alt="Figure 1"/><img src='cover.jpg'>"#;
        let got = extract_img_srcs(x);
        assert_eq!(got.len(), 2);
        assert_eq!(got[0], ("../images/fig1.png".into(), Some("Figure 1".into())));
        assert_eq!(got[1], ("cover.jpg".into(), None));
    }

    #[test]
    fn resolve_href_collapses_dot_segments() {
        // src relative to the chapter document's directory.
        assert_eq!(resolve_href("OEBPS/text/ch1.xhtml", "../images/f.png"), "OEBPS/images/f.png");
        assert_eq!(resolve_href("OEBPS/ch1.xhtml", "img/f.png"), "OEBPS/img/f.png");
        // Absolute (root-relative) and fragment/query stripping.
        assert_eq!(resolve_href("a/b.xhtml", "/x/y.png"), "x/y.png");
        assert_eq!(resolve_href("a/b.xhtml", "./f.png#frag"), "a/f.png");
    }
}