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 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
66fn 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
74fn load_custom_defaults(ctx: &PreprocessorContext) -> Option<Profile> {
77 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 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 let custom_defaults = load_custom_defaults(ctx);
190
191 let mut profiles: Vec<Profile> = Vec::new();
193 let default_p = cfg.default_profile();
194
195 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 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 let mut eff = QrConfig::inherit(&default_p, child);
214
215 if let Some(cd) = &custom_defaults {
217 eff = QrConfig::inherit(cd, &eff);
218 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 if let Some(dupe) = QrConfig::duplicate_marker_from(profiles.iter()) {
235 warn!("duplicate marker configured: {dupe}");
236 }
237
238 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 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 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 let is_localhost = profile.localhost_qr.unwrap_or(false);
283
284 let normal_rel = resolve_profile_path(&src_dir, profile.qr_path.as_deref(), marker);
286
287 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 let qr_rel_under_src = if is_localhost {
308 localhost_fixed_path(&src_dir)
310 } else {
311 normal_rel
312 };
313
314 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 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 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
369fn config_from_ctx(ctx: &PreprocessorContext) -> Option<QrConfig> {
371 ctx.config
372 .get::<QrConfig>("preprocessor.qr")
373 .ok()?
374}