Skip to main content

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 & macOS)");
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        thirdparty::clean_untracked(&mupdf_dir)
89            .context("failed to clean untracked files from thirdparty/mupdf")?;
90    }
91
92    thirdparty::download_libraries(&root.join("thirdparty"), &["mupdf"])
93}
94
95/// Reads the MuPDF version string from the version header file.
96///
97/// Returns `None` if the file does not exist or the version cannot be parsed.
98fn read_mupdf_version(header: &Path) -> Option<String> {
99    let content = std::fs::read_to_string(header).ok()?;
100
101    // The header contains a line like: #define FZ_VERSION "1.27.0"
102    for line in content.lines() {
103        if line.contains("FZ_VERSION") && line.contains('"') {
104            let start = line.find('"')? + 1;
105            let end = line.rfind('"')?;
106            if start < end {
107                return Some(line[start..end].to_owned());
108            }
109        }
110    }
111
112    None
113}
114
115/// Returns `true` when the full native setup is complete.
116///
117/// Checks that the build marker, the compiled `libmupdf.a`, the C wrapper
118/// library `libmupdf_wrapper.a`, and both symlinks in
119/// `target/mupdf_wrapper/<platform>/` are all present.
120pub fn native_setup_done(root: &Path) -> bool {
121    let platform_dir = if cfg!(target_os = "macos") {
122        "Darwin"
123    } else {
124        "Linux"
125    };
126
127    let wrapper_dir = root.join(format!("target/mupdf_wrapper/{platform_dir}"));
128
129    native_mupdf_ready(root)
130        && wrapper_dir.join("libmupdf.a").exists()
131        && wrapper_dir.join("libmupdf_wrapper.a").exists()
132}
133
134/// Returns `true` when native MuPDF libraries are present and marked as built.
135fn native_mupdf_ready(root: &Path) -> bool {
136    let marker = root.join("thirdparty/mupdf").join(NATIVE_BUILT_MARKER);
137    if !marker.exists() {
138        return false;
139    }
140
141    let libmupdf = root.join("thirdparty/mupdf/build/release/libmupdf.a");
142
143    libmupdf.exists()
144}
145
146/// Writes the native build marker in the MuPDF source directory.
147fn write_native_build_marker(root: &Path) -> Result<()> {
148    let marker = root.join("thirdparty/mupdf").join(NATIVE_BUILT_MARKER);
149    std::fs::write(&marker, "").with_context(|| {
150        format!(
151            "failed to write native build marker at {}",
152            marker.display()
153        )
154    })
155}
156
157/// Builds the `mupdf_wrapper` C static library for the native platform.
158fn build_mupdf_wrapper_native_if_needed(root: &Path) -> Result<()> {
159    println!("Ensuring mupdf_wrapper is available…");
160    mupdf_wrapper::build_native_if_needed(root)
161}
162
163/// Compiles MuPDF using system libraries for the native platform.
164fn build_mupdf_native(root: &Path) -> Result<()> {
165    println!("Building MuPDF for native development…");
166
167    let mupdf_dir = root.join("thirdparty/mupdf");
168
169    // Remove git metadata that interferes with the MuPDF build system.
170    for entry in ["gitattributes", ".gitattributes"] {
171        let path = mupdf_dir.join(entry);
172        if path.exists() {
173            std::fs::remove_file(&path).ok();
174        }
175    }
176
177    cmd::run("make", &["clean"], &mupdf_dir, &[]).ok();
178    cmd::run("make", &["verbose=yes", "generate"], &mupdf_dir, &[])?;
179
180    let sys_cflags = collect_system_cflags()?;
181    let xcflags = format!(
182        "-DFZ_ENABLE_ICC=0 -DFZ_ENABLE_SPOT_RENDERING=0 \
183         -DFZ_ENABLE_ODT_OUTPUT=0 -DFZ_ENABLE_OCR_OUTPUT=0 {sys_cflags}"
184    );
185
186    cmd::run(
187        "make",
188        &[
189            "verbose=yes",
190            "mujs=no",
191            "tesseract=no",
192            "extract=no",
193            "archive=no",
194            "brotli=no",
195            "barcode=no",
196            "commercial=no",
197            "USE_SYSTEM_LIBS=yes",
198            &format!("XCFLAGS={xcflags}"),
199            "libs",
200        ],
201        &mupdf_dir,
202        &[],
203    )
204}
205
206/// Collects system library CFLAGS via `pkg-config` on macOS.
207///
208/// On Linux, MuPDF's build system detects system libraries automatically.
209/// On macOS it needs explicit CFLAGS gathered from pkg-config.
210fn collect_system_cflags() -> Result<String> {
211    if !cfg!(target_os = "macos") {
212        return Ok(String::new());
213    }
214
215    let libs = [
216        "freetype2",
217        "harfbuzz",
218        "libopenjp2",
219        "libjpeg",
220        "zlib",
221        "jbig2dec",
222        "gumbo",
223    ];
224
225    let mut flags = String::new();
226    for lib in libs {
227        if let Ok(f) = cmd::output("pkg-config", &["--cflags", lib], Path::new("."), &[]) {
228            if !f.is_empty() {
229                flags.push(' ');
230                flags.push_str(&f);
231            }
232        }
233    }
234
235    Ok(flags.trim().to_owned())
236}
237
238/// Creates symlinks in `target/mupdf_wrapper/<platform>/` pointing to the
239/// compiled MuPDF static libraries.
240fn link_mupdf_artifacts(root: &Path) -> Result<()> {
241    let platform_dir = if cfg!(target_os = "macos") {
242        "Darwin"
243    } else {
244        "Linux"
245    };
246
247    let target_dir = root.join(format!("target/mupdf_wrapper/{platform_dir}"));
248    std::fs::create_dir_all(&target_dir)
249        .context("failed to create target/mupdf_wrapper directory")?;
250
251    let release_dir = root.join("thirdparty/mupdf/build/release");
252
253    let libmupdf = release_dir.join("libmupdf.a");
254    if !libmupdf.exists() {
255        bail!("libmupdf.a not found after build — check MuPDF build output");
256    }
257
258    symlink_force(&libmupdf, &target_dir.join("libmupdf.a"))?;
259    println!("✓ Created libmupdf.a in target/mupdf_wrapper/{platform_dir}");
260
261    let libmupdf_third = release_dir.join("libmupdf-third.a");
262    if !libmupdf_third.exists() {
263        println!("Creating empty libmupdf-third.a (system libs used instead)…");
264        cmd::run("ar", &["cr", "libmupdf-third.a"], &release_dir, &[])?;
265    }
266
267    symlink_force(&libmupdf_third, &target_dir.join("libmupdf-third.a"))?;
268    println!("✓ Created libmupdf-third.a");
269
270    Ok(())
271}
272
273/// Creates a symlink at `link` pointing to `target`, removing any existing
274/// file or symlink at `link` first.
275fn symlink_force(target: &Path, link: &Path) -> Result<()> {
276    if link.exists() || link.symlink_metadata().is_ok() {
277        std::fs::remove_file(link)
278            .with_context(|| format!("failed to remove existing {}", link.display()))?;
279    }
280
281    #[cfg(unix)]
282    std::os::unix::fs::symlink(target, link)
283        .with_context(|| format!("failed to create symlink {}", link.display()))?;
284
285    #[cfg(not(unix))]
286    std::fs::copy(target, link)
287        .with_context(|| format!("failed to copy {} to {}", target.display(), link.display()))?;
288
289    Ok(())
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use std::fs;
296
297    #[test]
298    fn read_mupdf_version_parses_define() {
299        let tmp = tempfile::tempdir().unwrap();
300        let header = tmp.path().join("version.h");
301        fs::write(
302            &header,
303            r#"/* MuPDF version */
304#define FZ_VERSION "1.27.0"
305#define FZ_VERSION_MAJOR 1
306"#,
307        )
308        .unwrap();
309
310        let version = read_mupdf_version(&header);
311        assert_eq!(version.as_deref(), Some("1.27.0"));
312    }
313
314    #[test]
315    fn read_mupdf_version_returns_none_for_malformed_header() {
316        let tmp = tempfile::tempdir().unwrap();
317        let header = tmp.path().join("version.h");
318        fs::write(&header, "/* no version define here */\n").unwrap();
319
320        let version = read_mupdf_version(&header);
321        assert!(version.is_none());
322    }
323}