xtask_lib/tasks/
setup_native.rs

1//! `cargo xtask setup-native` — build MuPDF and the C wrapper for native dev.
2//!
3//! 1. Downloads MuPDF sources if the required version is not already present.
4//! 2. Builds the `mupdf_wrapper` C library.
5//! 3. Compiles MuPDF using system libraries.
6//! 4. Creates symlinks in `target/mupdf_wrapper/<platform>/` so the Rust
7//!    build script can find the static libraries.
8//!
9//! ## Required MuPDF version
10//!
11//! The version is pinned to [`REQUIRED_MUPDF_VERSION`].  If the sources
12//! already present on disk match this version the download is skipped.
13
14use std::path::Path;
15
16use anyhow::{Context, Result, bail};
17use clap::Args;
18
19use super::util::{cmd, mupdf_wrapper, thirdparty, workspace};
20
21/// The MuPDF source version that must be present for a successful build.
22pub const REQUIRED_MUPDF_VERSION: &str = "1.27.0";
23
24/// Marker file written after a successful native MuPDF build.
25const NATIVE_BUILT_MARKER: &str = ".built-native";
26
27/// Arguments for `cargo xtask setup-native`.
28#[derive(Debug, Args)]
29pub struct SetupNativeArgs {
30    /// Force a re-download of MuPDF sources even if the correct version is
31    /// already present.
32    #[arg(long)]
33    pub force: bool,
34}
35
36/// Builds MuPDF and the C wrapper for native (non-cross-compiled) development.
37///
38/// # Errors
39///
40/// Returns an error if any build step fails or if required tools (`make`,
41/// `pkg-config`, `ar`) are not available.
42pub fn run(args: SetupNativeArgs) -> Result<()> {
43    let root = workspace::root()?;
44
45    ensure_mupdf_sources(&root, args.force)?;
46    build_mupdf_wrapper_native_if_needed(&root)?;
47
48    if native_mupdf_ready(&root) {
49        println!("Native MuPDF build already present.");
50    } else {
51        build_mupdf_native(&root)?;
52        write_native_build_marker(&root)?;
53    }
54
55    link_mupdf_artifacts(&root)?;
56
57    println!("\nNative setup complete!");
58    println!("You can now run:");
59    println!("  cargo test          - Run tests");
60    println!("  cargo xtask build-kobo  - Build for Kobo (Linux only)");
61
62    Ok(())
63}
64
65/// Ensures MuPDF sources at the required version are present in
66/// `thirdparty/mupdf/`.
67///
68/// If the version header is missing or reports a different version the
69/// existing directory is removed and the sources are re-downloaded.
70pub fn ensure_mupdf_sources(root: &Path, force: bool) -> Result<()> {
71    let version_header = root.join("thirdparty/mupdf/include/mupdf/fitz/version.h");
72
73    let current_version = read_mupdf_version(&version_header);
74
75    if !force && current_version.as_deref() == Some(REQUIRED_MUPDF_VERSION) {
76        println!("MuPDF {REQUIRED_MUPDF_VERSION} already present.");
77        return Ok(());
78    }
79
80    if let Some(v) = &current_version {
81        println!("MuPDF version mismatch: have '{v}', need '{REQUIRED_MUPDF_VERSION}'");
82    }
83
84    println!("Downloading MuPDF {REQUIRED_MUPDF_VERSION} sources…");
85
86    let mupdf_dir = root.join("thirdparty/mupdf");
87    if mupdf_dir.exists() {
88        std::fs::remove_dir_all(&mupdf_dir).context("failed to remove stale thirdparty/mupdf")?;
89    }
90
91    thirdparty::download_libraries(&root.join("thirdparty"), &["mupdf"])
92}
93
94/// Reads the MuPDF version string from the version header file.
95///
96/// Returns `None` if the file does not exist or the version cannot be parsed.
97fn read_mupdf_version(header: &Path) -> Option<String> {
98    let content = std::fs::read_to_string(header).ok()?;
99
100    // The header contains a line like: #define FZ_VERSION "1.27.0"
101    for line in content.lines() {
102        if line.contains("FZ_VERSION") && line.contains('"') {
103            let start = line.find('"')? + 1;
104            let end = line.rfind('"')?;
105            if start < end {
106                return Some(line[start..end].to_owned());
107            }
108        }
109    }
110
111    None
112}
113
114/// Returns `true` when native MuPDF libraries are present and marked as built.
115fn native_mupdf_ready(root: &Path) -> bool {
116    let marker = root.join("thirdparty/mupdf").join(NATIVE_BUILT_MARKER);
117    if !marker.exists() {
118        return false;
119    }
120
121    let libmupdf = root.join("thirdparty/mupdf/build/release/libmupdf.a");
122
123    libmupdf.exists()
124}
125
126/// Writes the native build marker in the MuPDF source directory.
127fn write_native_build_marker(root: &Path) -> Result<()> {
128    let marker = root.join("thirdparty/mupdf").join(NATIVE_BUILT_MARKER);
129    std::fs::write(&marker, "").with_context(|| {
130        format!(
131            "failed to write native build marker at {}",
132            marker.display()
133        )
134    })
135}
136
137/// Builds the `mupdf_wrapper` C static library for the native platform.
138fn build_mupdf_wrapper_native_if_needed(root: &Path) -> Result<()> {
139    println!("Ensuring mupdf_wrapper is available…");
140    mupdf_wrapper::build_native_if_needed(root)
141}
142
143/// Compiles MuPDF using system libraries for the native platform.
144fn build_mupdf_native(root: &Path) -> Result<()> {
145    println!("Building MuPDF for native development…");
146
147    let mupdf_dir = root.join("thirdparty/mupdf");
148
149    // Remove git metadata that interferes with the MuPDF build system.
150    for entry in ["gitattributes", ".gitattributes"] {
151        let path = mupdf_dir.join(entry);
152        if path.exists() {
153            std::fs::remove_file(&path).ok();
154        }
155    }
156
157    cmd::run("make", &["clean"], &mupdf_dir, &[]).ok();
158    cmd::run("make", &["verbose=yes", "generate"], &mupdf_dir, &[])?;
159
160    let sys_cflags = collect_system_cflags()?;
161    let xcflags = format!(
162        "-DFZ_ENABLE_ICC=0 -DFZ_ENABLE_SPOT_RENDERING=0 \
163         -DFZ_ENABLE_ODT_OUTPUT=0 -DFZ_ENABLE_OCR_OUTPUT=0 {sys_cflags}"
164    );
165
166    cmd::run(
167        "make",
168        &[
169            "verbose=yes",
170            "mujs=no",
171            "tesseract=no",
172            "extract=no",
173            "archive=no",
174            "brotli=no",
175            "barcode=no",
176            "commercial=no",
177            "USE_SYSTEM_LIBS=yes",
178            &format!("XCFLAGS={xcflags}"),
179            "libs",
180        ],
181        &mupdf_dir,
182        &[],
183    )
184}
185
186/// Collects system library CFLAGS via `pkg-config` on macOS.
187///
188/// On Linux, MuPDF's build system detects system libraries automatically.
189/// On macOS it needs explicit CFLAGS gathered from pkg-config.
190fn collect_system_cflags() -> Result<String> {
191    if !cfg!(target_os = "macos") {
192        return Ok(String::new());
193    }
194
195    let libs = [
196        "freetype2",
197        "harfbuzz",
198        "libopenjp2",
199        "libjpeg",
200        "zlib",
201        "jbig2dec",
202        "gumbo",
203    ];
204
205    let mut flags = String::new();
206    for lib in libs {
207        if let Ok(f) = cmd::output("pkg-config", &["--cflags", lib], Path::new("."), &[]) {
208            if !f.is_empty() {
209                flags.push(' ');
210                flags.push_str(&f);
211            }
212        }
213    }
214
215    Ok(flags.trim().to_owned())
216}
217
218/// Creates symlinks in `target/mupdf_wrapper/<platform>/` pointing to the
219/// compiled MuPDF static libraries.
220fn link_mupdf_artifacts(root: &Path) -> Result<()> {
221    let platform_dir = if cfg!(target_os = "macos") {
222        "Darwin"
223    } else {
224        "Linux"
225    };
226
227    let target_dir = root.join(format!("target/mupdf_wrapper/{platform_dir}"));
228    std::fs::create_dir_all(&target_dir)
229        .context("failed to create target/mupdf_wrapper directory")?;
230
231    let release_dir = root.join("thirdparty/mupdf/build/release");
232
233    let libmupdf = release_dir.join("libmupdf.a");
234    if !libmupdf.exists() {
235        bail!("libmupdf.a not found after build — check MuPDF build output");
236    }
237
238    symlink_force(&libmupdf, &target_dir.join("libmupdf.a"))?;
239    println!("✓ Created libmupdf.a in target/mupdf_wrapper/{platform_dir}");
240
241    let libmupdf_third = release_dir.join("libmupdf-third.a");
242    if !libmupdf_third.exists() {
243        println!("Creating empty libmupdf-third.a (system libs used instead)…");
244        cmd::run("ar", &["cr", "libmupdf-third.a"], &release_dir, &[])?;
245    }
246
247    symlink_force(&libmupdf_third, &target_dir.join("libmupdf-third.a"))?;
248    println!("✓ Created libmupdf-third.a");
249
250    Ok(())
251}
252
253/// Creates a symlink at `link` pointing to `target`, removing any existing
254/// file or symlink at `link` first.
255fn symlink_force(target: &Path, link: &Path) -> Result<()> {
256    if link.exists() || link.symlink_metadata().is_ok() {
257        std::fs::remove_file(link)
258            .with_context(|| format!("failed to remove existing {}", link.display()))?;
259    }
260
261    #[cfg(unix)]
262    std::os::unix::fs::symlink(target, link)
263        .with_context(|| format!("failed to create symlink {}", link.display()))?;
264
265    #[cfg(not(unix))]
266    std::fs::copy(target, link)
267        .with_context(|| format!("failed to copy {} to {}", target.display(), link.display()))?;
268
269    Ok(())
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use std::fs;
276
277    #[test]
278    fn read_mupdf_version_parses_define() {
279        let tmp = tempfile::tempdir().unwrap();
280        let header = tmp.path().join("version.h");
281        fs::write(
282            &header,
283            r#"/* MuPDF version */
284#define FZ_VERSION "1.27.0"
285#define FZ_VERSION_MAJOR 1
286"#,
287        )
288        .unwrap();
289
290        let version = read_mupdf_version(&header);
291        assert_eq!(version.as_deref(), Some("1.27.0"));
292    }
293
294    #[test]
295    fn read_mupdf_version_returns_none_for_malformed_header() {
296        let tmp = tempfile::tempdir().unwrap();
297        let header = tmp.path().join("version.h");
298        fs::write(&header, "/* no version define here */\n").unwrap();
299
300        let version = read_mupdf_version(&header);
301        assert!(version.is_none());
302    }
303}