Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ All conversions are edges in one directed graph. `graph::edges()` is the authori

`convert_file` finds the path, then runs each hop; multi-hop chains stage intermediates in a `tempfile::TempDir` (auto-cleaned) and write only the final artifact to `output_dir`, emitting overall `convert:progress` keyed to the original path.

**To add a format pairing, edit `graph.rs`:** add the `Edge`(s) and a `route_hop` arm (+ a `run_single_hop` branch if it's a new `Hop`). Quirks encoded here: HEIC is macOS-only (`sips`); JPEG/`tif` normalize via `normalize_ext` (so no jpg→jpg or pointless tif→tiff); PDF routing branches on `use_marker_pdf` and the Ollama LLM config; csv→toml is a semantic dead-end (`is_semantically_blocked`) even though csv→json→toml exists structurally, because a top-level array has no TOML form. **Document→PDF** uses a native tool when one fits: `typst` input compiles via `typst compile` (see `convert_typst_to_pdf`), and for md/txt/tex/image input pandoc is invoked with `--pdf-engine`, where `detect_pdf_engine()` prefers a LaTeX engine but falls back to `typst`. `.txt` input reads as Markdown (`ext_to_pandoc_input_format`) because pandoc has no `plain` reader.
**To add a format pairing, edit `graph.rs`:** add the `Edge`(s) and a `route_hop` arm (+ a `run_single_hop` branch if it's a new `Hop`). Quirks encoded here: HEIC is macOS-only (`sips`); JPEG/`tif` normalize via `normalize_ext` (so no jpg→jpg or pointless tif→tiff); PDF routing branches on `use_marker_pdf` and the Ollama LLM config; csv→toml is a semantic dead-end (`is_semantically_blocked`) even though csv→json→toml exists structurally, because a top-level array has no TOML form. **Document→PDF** uses a native tool when one fits: `typst` input compiles via `typst compile` (see `convert_typst_to_pdf`), and for md/txt/tex/image input pandoc is invoked with `--pdf-engine` via `document/pdf.rs` (`configure_pandoc_pdf`): it prefers a LaTeX engine but falls back to `typst`. Documents containing CJK text prefer typst, with an explicit CJK font fallback list, except `.tex` input, which prefers a XeLaTeX-family engine with `CJKmainfont`; the default fonts have no CJK glyphs. Pandoc is given the engine's *name*, never a full path, with engine dirs appended to its `PATH`. typst gets `-V mainfont`/`codefont` only when needed: when pandoc's typst template is the old one with an empty font list, or when the document has CJK text. Font variables set in the document's own YAML front matter are never overridden. See the comments in `pdf.rs` for the pandoc quirks this works around. `.txt` input reads as Markdown (`ext_to_pandoc_input_format`) because pandoc has no `plain` reader.

### External binary management — two strategies by platform

Expand Down
29 changes: 0 additions & 29 deletions swift-shifter/src/converter/document/binaries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -520,35 +520,6 @@ pub async fn ensure_typst(app: &tauri::AppHandle) -> Result<(), String> {
Ok(())
}

pub fn detect_pdf_engine() -> Option<&'static str> {
// LaTeX engines first (best fidelity for .tex), then typst as a capable,
// dependency-light fallback that also renders Markdown PDFs well.
const ENGINES: &[&str] = &[
"tectonic",
"xelatex",
"pdflatex",
"lualatex",
"wkhtmltopdf",
"typst",
];
for engine in ENGINES {
if which::which(engine).is_ok() {
return Some(engine);
}
#[cfg(target_os = "macos")]
for dir in BREW_PATHS {
if std::path::Path::new(dir).join(engine).exists() {
return Some(engine);
}
}
}
// typst may live in a managed/user dir that isn't on PATH.
if find_typst_binary().is_some() {
return Some("typst");
}
None
}

pub fn find_any_binary(names: &[&str]) -> Option<PathBuf> {
for name in names {
if let Ok(p) = which::which(name) {
Expand Down
66 changes: 31 additions & 35 deletions swift-shifter/src/converter/document/conversion.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::converter::document::binaries::*;
use crate::converter::document::llm::*;
use crate::converter::document::pdf;
use crate::converter::document::types::*;
use crate::converter::document::utils::*;
use std::path::Path;
Expand Down Expand Up @@ -398,10 +399,8 @@ pub async fn convert_document(
out.to_str().unwrap_or(""),
]);

if target_format == "pdf"
&& let Some(engine) = detect_pdf_engine()
{
cmd.args(["--pdf-engine", engine]);
if target_format == "pdf" {
pdf::configure_pandoc_pdf(&mut cmd, &pandoc, Some(Path::new(path))).await?;
}

cmd.arg(path);
Expand Down Expand Up @@ -511,18 +510,10 @@ pub async fn convert_image_to_pdf(
let pandoc = get_pandoc()?;

let input = Path::new(path);
let filename = input
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let image_dir = input
.parent()
.unwrap_or(Path::new("."))
.to_string_lossy()
.to_string();

let out = output_path(path, "pdf", output_dir)?;
// pandoc runs from the staging dir below, so the output path must not
// be relative to our own working directory.
let out = std::path::absolute(output_path(path, "pdf", output_dir)?)
.map_err(|e| format!("Failed to resolve output path: {e}"))?;

app.emit(
"convert:progress",
Expand All @@ -533,27 +524,34 @@ pub async fn convert_image_to_pdf(
)
.ok();

let tmp_md = std::env::temp_dir().join(format!("swift_shifter_{}.md", unique_tmp_suffix()));
std::fs::write(&tmp_md, format!("![]({})", filename))
.map_err(|e| format!("Failed to create temp file: {e}"))?;
// Stage a copy of the image next to a one-line markdown file and run
// pandoc from there. Older pandoc (e.g. 3.1 on Debian/Ubuntu) ignores
// --resource-path with typst, which resolves image paths against
// pandoc's working directory. The fixed name also avoids escaping
// spaces or parentheses from the user's file name in markdown.
let stage = tempfile::Builder::new()
.prefix("swift-shifter-image-pdf-")
.tempdir()
.map_err(|e| format!("Failed to create temp dir: {e}"))?;
let ext = input.extension().and_then(|e| e.to_str()).unwrap_or("png");
let staged_image = format!("image.{ext}");
std::fs::copy(input, stage.path().join(&staged_image))
.map_err(|e| format!("Failed to stage image: {e}"))?;
std::fs::write(
stage.path().join("image.md"),
format!("![]({staged_image})"),
)
.map_err(|e| format!("Failed to create temp file: {e}"))?;

let mut cmd = crate::process::async_command(&pandoc);
cmd.args([
"-f",
"markdown",
"-t",
"pdf",
"--resource-path",
&image_dir,
"-o",
out.to_str().unwrap_or(""),
]);
cmd.current_dir(stage.path());
cmd.args(["-f", "markdown", "-t", "pdf", "-o"]);
cmd.arg(&out);

if let Some(engine) = detect_pdf_engine() {
cmd.args(["--pdf-engine", engine]);
}
// The staged markdown has no text of its own to inspect.
pdf::configure_pandoc_pdf(&mut cmd, &pandoc, None).await?;

cmd.arg(tmp_md.to_str().unwrap_or(""));
cmd.arg("image.md");
cmd.stdout(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::piped());

Expand All @@ -575,8 +573,6 @@ pub async fn convert_image_to_pdf(
.await
.map_err(|e| format!("pandoc wait error: {e}"))?;

let _ = std::fs::remove_file(&tmp_md);

if !status.success() {
let msg = if stderr_out.trim().is_empty() {
format!("pandoc exited with code {}", status.code().unwrap_or(-1))
Expand Down
1 change: 1 addition & 0 deletions swift-shifter/src/converter/document/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod binaries;
pub mod conversion;
pub mod llm;
pub mod merge;
pub mod pdf;
pub mod types;
mod utils;
pub use merge::*;
Expand Down
Loading
Loading