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 [`thirdparty::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};
17use clap::Args;
18
19use super::util::thirdparty::MUPDF_VERSION;
20use super::util::{cmd, mupdf_wrapper, thirdparty, workspace};
21
22/// Marker file written after a successful native MuPDF build.
23const NATIVE_BUILT_MARKER: &str = ".built-native";
24
25/// Arguments for `cargo xtask setup-native`.
26#[derive(Debug, Args)]
27pub struct SetupNativeArgs {
28    /// Force a re-download of MuPDF sources even if the correct version is
29    /// already present.
30    #[arg(long)]
31    pub force: bool,
32}
33
34/// Builds MuPDF and the C wrapper for native (non-cross-compiled) development.
35///
36/// # Errors
37///
38/// Returns an error if any build step fails or if required tools (`make`,
39/// `pkg-config`, `ar`) are not available.
40pub fn run(args: SetupNativeArgs) -> Result<()> {
41    let root = workspace::root()?;
42
43    ensure_native_artifacts(&root, args.force)?;
44
45    println!("\nNative setup complete!");
46    println!("You can now run:");
47    println!("  cargo test          - Run tests");
48    println!("  cargo xtask build-kobo  - Build for Kobo (Linux & macOS)");
49
50    Ok(())
51}
52
53pub fn ensure_native_artifacts(root: &Path, force: bool) -> Result<()> {
54    thirdparty::download_libraries(&root.join("thirdparty"), &["libwebp"])?;
55    build_libwebp_native(root)?;
56
57    let mupdf_patched = ensure_mupdf_sources_with_webp_patches(root, force)?;
58    if mupdf_patched {
59        remove_native_wrapper_artifact(root)?;
60    }
61
62    let rebuild_mupdf = mupdf_patched || !native_mupdf_ready(root);
63    if rebuild_mupdf {
64        build_mupdf_native(root)?;
65        write_native_build_marker(root)?;
66    } else {
67        println!("Native MuPDF build already present.");
68    }
69
70    build_mupdf_wrapper_native_if_needed(root)?;
71
72    link_mupdf_artifacts(root)?;
73
74    Ok(())
75}
76
77/// Builds libwebp from source for native development.
78///
79/// # Why we combine static archives manually
80///
81/// libwebp's build system creates separate `.a` files in sub-directories
82/// (`dec/`, `dsp/`, `enc/`, `utils/`) but does **not** assemble them into a
83/// single `src/.libs/libwebp.a` when building static-only.  We extract the
84/// individual object files from each sub-archive and repack them into one
85/// unified `libwebp.a` so that downstream `build.rs` scripts can simply link
86/// with `-lwebp`.
87pub fn build_libwebp_native(root: &Path) -> Result<()> {
88    let libwebp_dir = root.join("thirdparty/libwebp");
89    if libwebp_dir.join("src/.libs/libwebp.a").exists() {
90        println!("libwebp already built for native.");
91        return Ok(());
92    }
93
94    println!("Building libwebp for native development…");
95
96    if !libwebp_dir.join("configure").exists() {
97        cmd::run("sh", &["autogen.sh"], &libwebp_dir, &[("NOCONFIGURE", "1")])
98            .context("failed to run autogen.sh for libwebp")?;
99    }
100
101    cmd::run(
102        "./configure",
103        &[
104            "--disable-shared",
105            "--enable-static",
106            "--disable-libwebpmux",
107            "--enable-libwebpdecoder",
108            "--enable-libwebpdemux",
109            "--disable-webp-tools",
110            "--with-pic",
111        ],
112        &libwebp_dir,
113        &[],
114    )
115    .context("failed to configure libwebp")?;
116
117    cmd::run("make", &["-j4"], &libwebp_dir, &[]).context("failed to build libwebp")?;
118
119    combine_libwebp_static_archives(&libwebp_dir)?;
120
121    println!("✓ libwebp built successfully");
122    Ok(())
123}
124
125/// Extracts sub-archives from a static libwebp build and repacks them into
126/// a single `src/.libs/libwebp.a`.
127///
128/// See [`build_libwebp_native`] for why this is necessary.
129fn combine_libwebp_static_archives(libwebp_dir: &Path) -> Result<()> {
130    let libs_dir = libwebp_dir.join("src/.libs");
131    std::fs::create_dir_all(&libs_dir).context("failed to create src/.libs for libwebp")?;
132
133    let sublibs = [
134        "dec/.libs/libwebpdecode.a",
135        "dsp/.libs/libwebpdsp.a",
136        "enc/.libs/libwebpencode.a",
137        "utils/.libs/libwebputils.a",
138    ];
139
140    let mut objects = vec![];
141    for sublib in &sublibs {
142        let path = libwebp_dir.join("src").join(sublib);
143        let extract_dir = libs_dir.join(format!("extract_{}", sublib.replace('/', "_")));
144        std::fs::create_dir_all(&extract_dir)?;
145        cmd::run("ar", &["x", path.to_str().unwrap()], &extract_dir, &[])
146            .with_context(|| format!("failed to extract objects from {}", path.display()))?;
147        for entry in std::fs::read_dir(&extract_dir)? {
148            objects.push(entry?.path());
149        }
150    }
151
152    let libwebp_a = libs_dir.join("libwebp.a");
153    let mut ar_args: Vec<&str> = vec!["rcs", libwebp_a.to_str().unwrap()];
154    for obj in &objects {
155        ar_args.push(obj.to_str().unwrap());
156    }
157    cmd::run("ar", &ar_args, &libwebp_dir, &[]).context("failed to create combined libwebp.a")?;
158
159    Ok(())
160}
161
162/// Ensures MuPDF sources at the required version are present in
163/// `thirdparty/mupdf/` and that Cadmus' WebP patch series is applied.
164///
165/// If the version header is missing or reports a different version the
166/// existing directory is removed and the sources are re-downloaded.
167pub fn ensure_mupdf_sources(root: &Path, force: bool) -> Result<()> {
168    ensure_mupdf_sources_with_webp_patches(root, force).map(|_| ())
169}
170
171fn ensure_mupdf_sources_with_webp_patches(root: &Path, force: bool) -> Result<bool> {
172    let mupdf_dir = root.join("thirdparty/mupdf");
173    let version_header = mupdf_dir.join("include/mupdf/fitz/version.h");
174    let current_version = read_mupdf_version(&version_header);
175
176    if force || current_version.as_deref() != Some(MUPDF_VERSION) {
177        if let Some(v) = &current_version {
178            println!("MuPDF version mismatch: have '{v}', need '{MUPDF_VERSION}'");
179        }
180
181        println!("Downloading MuPDF {MUPDF_VERSION} sources…");
182
183        if mupdf_dir.exists() {
184            thirdparty::clean_untracked(&mupdf_dir)
185                .context("failed to clean untracked files from thirdparty/mupdf")?;
186        }
187
188        thirdparty::download_libraries(&root.join("thirdparty"), &["mupdf"])?;
189    } else {
190        println!("MuPDF {MUPDF_VERSION} already present.");
191    }
192
193    apply_mupdf_webp_patches_if_needed(&mupdf_dir)
194}
195
196fn apply_mupdf_webp_patches_if_needed(mupdf_dir: &Path) -> Result<bool> {
197    let patched = thirdparty::apply_mupdf_webp_patches_if_needed(mupdf_dir)?;
198    if patched {
199        remove_native_build_marker(mupdf_dir);
200    }
201
202    Ok(patched)
203}
204
205fn remove_native_build_marker(mupdf_dir: &Path) {
206    let marker = mupdf_dir.join(NATIVE_BUILT_MARKER);
207    if marker.exists() {
208        std::fs::remove_file(&marker).ok();
209    }
210}
211
212/// Reads the MuPDF version string from the version header file.
213///
214/// Returns `None` if the file does not exist or the version cannot be parsed.
215fn read_mupdf_version(header: &Path) -> Option<String> {
216    let content = std::fs::read_to_string(header).ok()?;
217
218    // The header contains a line like: #define FZ_VERSION "1.27.0"
219    for line in content.lines() {
220        if line.contains("FZ_VERSION") && line.contains('"') {
221            let start = line.find('"')? + 1;
222            let end = line.rfind('"')?;
223            if start < end {
224                return Some(line[start..end].to_owned());
225            }
226        }
227    }
228
229    None
230}
231
232/// Returns `true` when the full native setup is complete.
233///
234/// Checks that the build marker, the compiled `libmupdf.a`, the C wrapper
235/// library `libmupdf_wrapper.a`, and both symlinks in
236/// `target/mupdf_wrapper/<platform>/` are all present.
237pub fn native_setup_done(root: &Path) -> bool {
238    let platform_dir = if cfg!(target_os = "macos") {
239        "Darwin"
240    } else {
241        "Linux"
242    };
243
244    let wrapper_dir = root.join(format!("target/mupdf_wrapper/{platform_dir}"));
245
246    native_mupdf_ready(root)
247        && wrapper_dir.join("libmupdf.a").exists()
248        && wrapper_dir.join("libmupdf_wrapper.a").exists()
249}
250
251/// Returns `true` when native MuPDF libraries are present and marked as built.
252fn native_mupdf_ready(root: &Path) -> bool {
253    let marker = root.join("thirdparty/mupdf").join(NATIVE_BUILT_MARKER);
254    if !marker.exists() {
255        return false;
256    }
257
258    let libmupdf = root.join("thirdparty/mupdf/build/release/libmupdf.a");
259
260    libmupdf.exists()
261}
262
263/// Writes the native build marker in the MuPDF source directory.
264fn write_native_build_marker(root: &Path) -> Result<()> {
265    let marker = root.join("thirdparty/mupdf").join(NATIVE_BUILT_MARKER);
266    std::fs::write(&marker, "").with_context(|| {
267        format!(
268            "failed to write native build marker at {}",
269            marker.display()
270        )
271    })
272}
273
274/// Builds the `mupdf_wrapper` C static library for the native platform.
275fn build_mupdf_wrapper_native_if_needed(root: &Path) -> Result<()> {
276    println!("Ensuring mupdf_wrapper is available…");
277    mupdf_wrapper::build_native_if_needed(root)
278}
279
280fn remove_native_wrapper_artifact(root: &Path) -> Result<()> {
281    let platform_dir = if cfg!(target_os = "macos") {
282        "Darwin"
283    } else {
284        "Linux"
285    };
286    let lib = root.join(format!(
287        "target/mupdf_wrapper/{platform_dir}/libmupdf_wrapper.a"
288    ));
289
290    if lib.exists() {
291        std::fs::remove_file(&lib)
292            .with_context(|| format!("failed to remove stale {}", lib.display()))?;
293    }
294
295    Ok(())
296}
297
298/// Compiles MuPDF using system libraries for the native platform.
299fn build_mupdf_native(root: &Path) -> Result<()> {
300    println!("Building MuPDF for native development…");
301
302    let mupdf_dir = root.join("thirdparty/mupdf");
303
304    // Remove git metadata that interferes with the MuPDF build system.
305    for entry in ["gitattributes", ".gitattributes"] {
306        let path = mupdf_dir.join(entry);
307        if path.exists() {
308            std::fs::remove_file(&path).ok();
309        }
310    }
311
312    cmd::run("make", &["clean"], &mupdf_dir, &[]).ok();
313    cmd::run("make", &["verbose=yes", "generate"], &mupdf_dir, &[])?;
314
315    let sys_cflags = collect_system_cflags()?;
316    let xcflags = format!(
317        "-DFZ_ENABLE_ICC=0 -DFZ_ENABLE_SPOT_RENDERING=0 \
318         -DFZ_ENABLE_ODT_OUTPUT=0 -DFZ_ENABLE_OCR_OUTPUT=0 \
319         -DHAVE_WEBP=1 -I{root}/thirdparty/libwebp/src {sys_cflags}",
320        root = root.display()
321    );
322
323    // Linker flags for libwebp static libraries
324    let xlibs = format!(
325        "-L{root}/thirdparty/libwebp/src/.libs -lwebp \
326         -L{root}/thirdparty/libwebp/src/demux/.libs -lwebpdemux",
327        root = root.display()
328    );
329
330    cmd::run(
331        "make",
332        &[
333            "verbose=yes",
334            "mujs=no",
335            "tesseract=no",
336            "extract=no",
337            "archive=no",
338            "brotli=no",
339            "barcode=no",
340            "commercial=no",
341            "USE_SYSTEM_LIBS=yes",
342            &format!("XCFLAGS={xcflags}"),
343            &format!("XLIBS={xlibs}"),
344            "libs",
345        ],
346        &mupdf_dir,
347        &[],
348    )
349}
350
351/// Collects system library CFLAGS via `pkg-config` on macOS.
352///
353/// On Linux, MuPDF's build system detects system libraries automatically.
354/// On macOS it needs explicit CFLAGS gathered from pkg-config.
355fn collect_system_cflags() -> Result<String> {
356    if !cfg!(target_os = "macos") {
357        return Ok(String::new());
358    }
359
360    let libs = [
361        "freetype2",
362        "harfbuzz",
363        "libopenjp2",
364        "libjpeg",
365        "libwebp",
366        "zlib",
367        "jbig2dec",
368        "gumbo",
369    ];
370
371    let mut flags = String::new();
372    for lib in libs {
373        if let Ok(f) = cmd::output("pkg-config", &["--cflags", lib], Path::new("."), &[]) {
374            if !f.is_empty() {
375                flags.push(' ');
376                flags.push_str(&f);
377            }
378        }
379    }
380
381    Ok(flags.trim().to_owned())
382}
383
384/// Creates symlinks in `target/mupdf_wrapper/<platform>/` pointing to the
385/// compiled MuPDF static libraries.
386fn link_mupdf_artifacts(root: &Path) -> Result<()> {
387    let platform_dir = if cfg!(target_os = "macos") {
388        "Darwin"
389    } else {
390        "Linux"
391    };
392
393    let target_dir = root.join(format!("target/mupdf_wrapper/{platform_dir}"));
394    std::fs::create_dir_all(&target_dir)
395        .context("failed to create target/mupdf_wrapper directory")?;
396
397    let release_dir = root.join("thirdparty/mupdf/build/release");
398
399    let libmupdf = release_dir.join("libmupdf.a");
400    if !libmupdf.exists() {
401        anyhow::bail!("libmupdf.a not found after build -- check MuPDF build output");
402    }
403
404    symlink_force(&libmupdf, &target_dir.join("libmupdf.a"))?;
405    println!("✓ Created libmupdf.a in target/mupdf_wrapper/{platform_dir}");
406
407    let libmupdf_third = release_dir.join("libmupdf-third.a");
408    if !libmupdf_third.exists() {
409        println!("Creating empty libmupdf-third.a (system libs used instead)…");
410        cmd::run("ar", &["cr", "libmupdf-third.a"], &release_dir, &[])?;
411    }
412
413    symlink_force(&libmupdf_third, &target_dir.join("libmupdf-third.a"))?;
414    println!("✓ Created libmupdf-third.a");
415
416    Ok(())
417}
418
419/// Creates a symlink at `link` pointing to `target`, removing any existing
420/// file or symlink at `link` first.
421fn symlink_force(target: &Path, link: &Path) -> Result<()> {
422    if link.exists() || link.symlink_metadata().is_ok() {
423        std::fs::remove_file(link)
424            .with_context(|| format!("failed to remove existing {}", link.display()))?;
425    }
426
427    #[cfg(unix)]
428    std::os::unix::fs::symlink(target, link)
429        .with_context(|| format!("failed to create symlink {}", link.display()))?;
430
431    #[cfg(not(unix))]
432    std::fs::copy(target, link)
433        .with_context(|| format!("failed to copy {} to {}", target.display(), link.display()))?;
434
435    Ok(())
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use std::fs;
442
443    #[test]
444    fn read_mupdf_version_parses_define() {
445        let tmp = tempfile::tempdir().unwrap();
446        let header = tmp.path().join("version.h");
447        fs::write(
448            &header,
449            r#"/* MuPDF version */
450#define FZ_VERSION "1.27.0"
451#define FZ_VERSION_MAJOR 1
452"#,
453        )
454        .unwrap();
455
456        let version = read_mupdf_version(&header);
457        assert_eq!(version.as_deref(), Some("1.27.0"));
458    }
459
460    #[test]
461    fn read_mupdf_version_returns_none_for_malformed_header() {
462        let tmp = tempfile::tempdir().unwrap();
463        let header = tmp.path().join("version.h");
464        fs::write(&header, "/* no version define here */\n").unwrap();
465
466        let version = read_mupdf_version(&header);
467        assert!(version.is_none());
468    }
469}