mdbook_qr/
preprocessor.rs

1use anyhow::Result;
2use log::{debug, info, warn};
3use mdbook_preprocessor::book::{Book, BookItem};
4use mdbook_preprocessor::errors::{Error, Result as MdResult};
5use mdbook_preprocessor::{MDBOOK_VERSION, Preprocessor, PreprocessorContext};
6use semver::{Version, VersionReq};
7use toml::Value as TomlValue;
8use std::collections::HashMap;
9use std::io;
10
11use crate::config::{ColorCfg, FailureMode, FitConfig, Profile, QrConfig, ShapeFlags};
12use crate::html::inject_marker_relative;
13use crate::image::write_qr_png;
14use crate::util::{
15    derived_default_path, ensure_gitignore_for_localhost, localhost_fixed_path, pass_fit_dims,
16    resolve_profile_path,
17};
18
19pub struct QrPreprocessor;
20impl QrPreprocessor {
21    pub fn new() -> Self {
22        Self
23    }
24}
25
26impl Preprocessor for QrPreprocessor {
27    fn name(&self) -> &str {
28        "qr"
29    }
30    fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> MdResult<Book> {
31        run_impl(ctx, &mut book).map_err(Error::from)?;
32        Ok(book)
33    }
34    fn supports_renderer(&self, _renderer: &str) -> MdResult<bool> { Ok(true) }
35}
36
37pub fn run_preprocessor_once() -> Result<()> {
38    let pre = QrPreprocessor::new();
39    let (ctx, book) = mdbook_preprocessor::parse_input(io::stdin())?;
40
41    // mdBook 0.5: prefer a semver requirement check rather than raw string compare
42    if let (Ok(book_v), Ok(req)) = (
43        Version::parse(&ctx.mdbook_version),
44        VersionReq::parse(MDBOOK_VERSION),
45    ) {
46        if !req.matches(&book_v) {
47            warn!(
48                "The '{}' plugin was built against mdBook {}, called from {}",
49                pre.name(), MDBOOK_VERSION, ctx.mdbook_version
50            );
51        }
52    } else {
53        warn!(
54            "Unable to semver-parse mdBook versions (built={}, running={})",
55            MDBOOK_VERSION, ctx.mdbook_version
56        );
57    }
58
59    pre.run(&ctx, book)
60        .map(|processed| {
61            serde_json::to_writer(io::stdout(), &processed).expect("write preprocessor output");
62        })
63        .map_err(|e| anyhow::anyhow!(e))
64}
65
66/// Does the given marker appear in any chapter?
67fn marker_in_book(book: &Book, marker: &str) -> bool {
68    book.iter().any(|item| match item {
69        BookItem::Chapter(ch) => ch.content.contains(marker),
70        _ => false,
71    })
72}
73
74/// Build a `Profile` from the bare `[preprocessor.qr.custom]` table (no marker).
75/// Avoids `toml` type/version clashes by reading primitives only.
76fn load_custom_defaults(ctx: &PreprocessorContext) -> Option<Profile> {
77    // mdBook 0.5: typed Config::get<T>() -> Result<Option<T>, _>
78    let custom_val: TomlValue = ctx
79        .config
80        .get::<TomlValue>("preprocessor.qr.custom")
81        .ok()?
82        ?;
83    let custom = custom_val.as_table()?;
84
85    let mut p = Profile {
86        enable: None,
87        localhost_qr: None,
88        marker: None,
89        url: None,
90        qr_path: None,
91        fit: FitConfig::default(),
92        margin: None,
93        shape: ShapeFlags::default(),
94        background: None,
95        module: None,
96    };
97
98    if let Some(v) = custom.get("enable").and_then(|v| v.as_bool()) {
99        p.enable = Some(v);
100    }
101
102    if let Some(v) = custom.get("localhost-qr").and_then(|v| v.as_bool()) {
103        p.localhost_qr = Some(v);
104    }
105
106    if let Some(v) = custom.get("url").and_then(|v| v.as_str()) {
107        p.url = Some(v.to_string());
108    }
109    if let Some(v) = custom.get("qr-path").and_then(|v| v.as_str()) {
110        p.qr_path = Some(v.to_string());
111    }
112    if let Some(v) = custom.get("margin").and_then(|v| v.as_integer()) {
113        if v >= 0 {
114            p.margin = Some(v as u32);
115        }
116    }
117
118    if let Some(fit_tbl) = custom.get("fit").and_then(|v| v.as_table()) {
119        if let Some(w) = fit_tbl.get("width").and_then(|v| v.as_integer()) {
120            if w >= 0 {
121                p.fit.width = Some(w as u32);
122            }
123        }
124        if let Some(h) = fit_tbl.get("height").and_then(|v| v.as_integer()) {
125            if h >= 0 {
126                p.fit.height = Some(h as u32);
127            }
128        }
129    }
130
131    if let Some(shape_tbl) = custom.get("shape").and_then(|v| v.as_table()) {
132        p.shape.square = shape_tbl
133            .get("square")
134            .and_then(|v| v.as_bool())
135            .unwrap_or(false);
136        p.shape.circle = shape_tbl
137            .get("circle")
138            .and_then(|v| v.as_bool())
139            .unwrap_or(false);
140        p.shape.rounded_square = shape_tbl
141            .get("rounded_square")
142            .and_then(|v| v.as_bool())
143            .unwrap_or(false);
144        p.shape.vertical = shape_tbl
145            .get("vertical")
146            .and_then(|v| v.as_bool())
147            .unwrap_or(false);
148        p.shape.horizontal = shape_tbl
149            .get("horizontal")
150            .and_then(|v| v.as_bool())
151            .unwrap_or(false);
152        p.shape.diamond = shape_tbl
153            .get("diamond")
154            .and_then(|v| v.as_bool())
155            .unwrap_or(false);
156    }
157
158    if let Some(bg) = custom.get("background").and_then(|v| v.as_str()) {
159        p.background = Some(ColorCfg::Hex(bg.to_string()));
160    }
161    if let Some(fg) = custom.get("module").and_then(|v| v.as_str()) {
162        p.module = Some(ColorCfg::Hex(fg.to_string()));
163    }
164
165    Some(p)
166}
167
168fn run_impl(ctx: &PreprocessorContext, book: &mut Book) -> Result<()> {
169    let cfg: QrConfig = config_from_ctx(ctx).unwrap_or_default();
170    if !cfg.is_enabled() {
171        return Ok(());
172    }
173    let on_failure = cfg.on_failure.clone();
174    let src_dir = ctx.config.book.src.clone();
175
176    cfg.warn_invalid_customs();
177
178    // 1) Detect a *bare* [preprocessor.qr.custom] table (no named subtables)
179    let has_bare_custom = ctx
180        .config
181        .get::<TomlValue>("preprocessor.qr.custom")
182        .ok()
183        .and_then(|opt| opt)
184        .map(|v| v.is_table())
185        .unwrap_or(false)
186        && cfg.custom.is_empty();
187
188    // 2) Load defaults from bare custom (for inheritance only; never generates by itself)
189    let custom_defaults = load_custom_defaults(ctx);
190
191    // 3) Build profiles
192    let mut profiles: Vec<Profile> = Vec::new();
193    let default_p = cfg.default_profile();
194
195    // ── CHANGED: only include the default if there is NOT a bare custom table
196    if !has_bare_custom {
197        profiles.push(default_p.clone());
198    } else {
199        warn!(
200            "mdbook-qr: bare [preprocessor.qr.custom] present with no named subtables; \
201             suppressing default '{{QR_CODE}}' until a named custom (e.g., [preprocessor.qr.custom.flyer]) exists."
202        );
203    }
204
205    // Named customs (must have marker)
206    for (_name, child) in &cfg.custom {
207        if child.marker.is_none() {
208            warn!("mdbook-qr: custom entry missing `marker`; skipping.");
209            continue;
210        }
211
212        // default -> child
213        let mut eff = QrConfig::inherit(&default_p, child);
214
215        // overlay bare [preprocessor.qr.custom] defaults if present
216        if let Some(cd) = &custom_defaults {
217            eff = QrConfig::inherit(cd, &eff);
218            // child's explicit fields win back
219            eff.marker = child.marker.clone();
220            if child.qr_path.is_some() {
221                eff.qr_path = child.qr_path.clone();
222            }
223        }
224        profiles.push(eff);
225    }
226
227    for p in &profiles {
228        if let Some(m) = &p.marker {
229            info!("mdbook-qr: profile queued -> marker {}", m);
230        }
231    }
232
233    // Optional: warn on duplicate markers
234    if let Some(dupe) = QrConfig::duplicate_marker_from(profiles.iter()) {
235        warn!("duplicate marker configured: {dupe}");
236    }
237
238    // Track file-path collisions (warn only)
239    let mut path_to_marker: HashMap<std::path::PathBuf, String> = HashMap::new();
240
241    for profile in profiles.into_iter().filter(|p| p.is_enabled()) {
242        let marker = profile
243            .marker
244            .as_ref()
245            .expect("profiles here always have marker");
246
247        // Only generate if the marker is used
248        if !marker_in_book(book, marker) {
249            debug!(
250                "mdbook-qr: marker '{}' not found in any chapter; skipping",
251                marker
252            );
253            continue;
254        }
255
256        // Resolve URL (explicit -> localhost-qr -> env fallback)
257        let url = match crate::url::resolve_url(
258            profile.url.as_deref(),
259            profile.localhost_qr.unwrap_or(false),
260        ) {
261            Ok(u) => u,
262            Err(_) => match on_failure {
263                FailureMode::Continue => {
264                    warn!(
265                        "could not resolve URL for '{}'; set `preprocessor.qr.url` \
266                         or export GITHUB_REPOSITORY; skipping image.",
267                        marker
268                    );
269                    continue;
270                }
271                FailureMode::Bail => {
272                    anyhow::bail!(
273                        "mdbook-qr: could not resolve URL for '{}'; \
274                         set `preprocessor.qr.url` or export GITHUB_REPOSITORY.",
275                        marker
276                    );
277                }
278            },
279        };
280
281        // Decide mode up front
282        let is_localhost = profile.localhost_qr.unwrap_or(false);
283
284        //  Compute the normal path first (respects qr-path/marker)
285        let normal_rel = resolve_profile_path(&src_dir, profile.qr_path.as_deref(), marker);
286
287        //  Safety guard ONLY for non-localhost runs:
288        //    If about to write to the derived default for the *default marker*
289        //    and the file already exists AND no explicit qr-path was given, skip to avoid clobbering.
290        if !is_localhost {
291            let derived_default = derived_default_path(&src_dir, "{{QR_CODE}}");
292            if normal_rel == derived_default && profile.qr_path.is_none() {
293                let abs_candidate = ctx.root.join(&normal_rel);
294                if abs_candidate.exists() {
295                    warn!(
296                        "mdbook-qr: '{}' already exists; refusing to overwrite derived default. \
297                        Set an explicit `qr-path` for marker {} to proceed.",
298                        abs_candidate.display(),
299                        marker
300                    );
301                    continue;
302                }
303            }
304        }
305
306        // Pick the effective output path
307        let qr_rel_under_src = if is_localhost {
308            // {book.src}/mdbook-qr/qr_localhost.png
309            localhost_fixed_path(&src_dir)
310        } else {
311            normal_rel
312        };
313
314        // Warn on two markers mapping to same file
315        if let Some(prev) = path_to_marker.insert(qr_rel_under_src.clone(), marker.clone()) {
316            if prev != *marker {
317                warn!(
318                    "image path collision: '{}' and '{}' both map to '{}'. \
319                     The latter may overwrite the former.",
320                    prev,
321                    marker,
322                    qr_rel_under_src.display()
323                );
324            }
325        }
326
327        // Render + inject
328        let (fit_w, fit_h) = pass_fit_dims(&profile.fit);
329        let margin = profile.margin.unwrap_or(2);
330        let shape = profile.shape.to_shape();
331        let bg = profile.background_color();
332        let fg = profile.module_color();
333
334        let (_abs_out, content_hash) = write_qr_png(
335            &url,
336            &ctx.root,
337            &qr_rel_under_src,
338            fit_w,
339            fit_h,
340            margin,
341            Some(shape),
342            bg,
343            fg,
344        )?;
345
346        // If localhost-qr is active, ensure .gitignore excludes this pattern.
347        if profile.localhost_qr.unwrap_or(false) {
348            match ensure_gitignore_for_localhost(&ctx.root, &src_dir) {
349                Ok(true) => log::info!("mdbook-qr: added glob to .gitignore for qr_localhost.png"),
350                Ok(false) => {}
351                Err(e) => log::warn!("mdbook-qr: could not update .gitignore: {e}"),
352            }
353        }
354
355        inject_marker_relative(
356            book,
357            marker,
358            &src_dir,
359            &qr_rel_under_src,
360            fit_h,
361            fit_w,
362            Some(&content_hash),
363        )?;
364    }
365
366    Ok(())
367}
368
369/// Deserialize [preprocessor.qr] from the mdBook context.
370fn config_from_ctx(ctx: &PreprocessorContext) -> Option<QrConfig> {
371    ctx.config
372        .get::<QrConfig>("preprocessor.qr")
373        .ok()?
374}