unaltraweb 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (444) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +20 -0
  3. data/Makefile +412 -0
  4. data/README.md +226 -0
  5. data/_config.yml +842 -0
  6. data/_data/i18n/ca.yml +278 -0
  7. data/_data/i18n/en.yml +278 -0
  8. data/_data/i18n/es.yml +278 -0
  9. data/_includes/audio.liquid +30 -0
  10. data/_includes/bib_search.liquid +5 -0
  11. data/_includes/blog-list.liquid +66 -0
  12. data/_includes/book-citation.liquid +33 -0
  13. data/_includes/citation.liquid +26 -0
  14. data/_includes/custom_head.liquid +3 -0
  15. data/_includes/custom_scripts.liquid +5 -0
  16. data/_includes/cv/list.liquid +5 -0
  17. data/_includes/cv/list_groups.liquid +51 -0
  18. data/_includes/cv/map.liquid +29 -0
  19. data/_includes/cv/nested_list.liquid +16 -0
  20. data/_includes/cv/time_table.liquid +118 -0
  21. data/_includes/cv-download-card.liquid +26 -0
  22. data/_includes/developer-mode-switcher.liquid +24 -0
  23. data/_includes/disqus.liquid +13 -0
  24. data/_includes/distill_scripts.liquid +298 -0
  25. data/_includes/documentation-download-detail.liquid +76 -0
  26. data/_includes/documentation-downloads.liquid +71 -0
  27. data/_includes/documentation-profile-attributes.liquid +4 -0
  28. data/_includes/documentation-section-toc.liquid +9 -0
  29. data/_includes/documentation-sidebar.liquid +112 -0
  30. data/_includes/figure.liquid +87 -0
  31. data/_includes/footer.liquid +66 -0
  32. data/_includes/giscus.liquid +25 -0
  33. data/_includes/head.liquid +220 -0
  34. data/_includes/header.liquid +544 -0
  35. data/_includes/latest_posts.liquid +61 -0
  36. data/_includes/localized-date.liquid +5 -0
  37. data/_includes/manual-bibliography.liquid +2 -0
  38. data/_includes/manual-featured-readings.liquid +12 -0
  39. data/_includes/manual-other-readings.liquid +12 -0
  40. data/_includes/manual-page-toc.liquid +11 -0
  41. data/_includes/manual-sidebar.liquid +58 -0
  42. data/_includes/manual-teachers.liquid +74 -0
  43. data/_includes/metadata.liquid +278 -0
  44. data/_includes/news.liquid +48 -0
  45. data/_includes/newsletter.liquid +68 -0
  46. data/_includes/page-hero.liquid +96 -0
  47. data/_includes/page-link.liquid +8 -0
  48. data/_includes/pagination.liquid +22 -0
  49. data/_includes/post-reading-time.liquid +29 -0
  50. data/_includes/previous-next.liquid +37 -0
  51. data/_includes/profile-card.liquid +49 -0
  52. data/_includes/profile-feature-matrix.liquid +75 -0
  53. data/_includes/profile-highlights.liquid +122 -0
  54. data/_includes/project-grid.liquid +26 -0
  55. data/_includes/project-resource-link.liquid +45 -0
  56. data/_includes/project-resources.liquid +21 -0
  57. data/_includes/projects.liquid +38 -0
  58. data/_includes/projects_horizontal.liquid +37 -0
  59. data/_includes/publications-metrics-summary.liquid +69 -0
  60. data/_includes/publications-summary.liquid +127 -0
  61. data/_includes/reading-biblio-controls.liquid +65 -0
  62. data/_includes/reading-cover-card.liquid +93 -0
  63. data/_includes/reading-rating.liquid +11 -0
  64. data/_includes/recommended-readings.liquid +30 -0
  65. data/_includes/related_posts.liquid +39 -0
  66. data/_includes/repository/repo.liquid +48 -0
  67. data/_includes/repository/repo_trophies.liquid +44 -0
  68. data/_includes/repository/repo_user.liquid +35 -0
  69. data/_includes/resume/awards.liquid +19 -0
  70. data/_includes/resume/basics.liquid +28 -0
  71. data/_includes/resume/certificates.liquid +35 -0
  72. data/_includes/resume/education.liquid +54 -0
  73. data/_includes/resume/interests.liquid +34 -0
  74. data/_includes/resume/languages.liquid +31 -0
  75. data/_includes/resume/projects.liquid +32 -0
  76. data/_includes/resume/publications.liquid +28 -0
  77. data/_includes/resume/references.liquid +31 -0
  78. data/_includes/resume/skills.liquid +33 -0
  79. data/_includes/resume/volunteer.liquid +52 -0
  80. data/_includes/resume/work.liquid +52 -0
  81. data/_includes/scripts.liquid +364 -0
  82. data/_includes/search-data.js.liquid +341 -0
  83. data/_includes/selected_papers.liquid +3 -0
  84. data/_includes/t.liquid +31 -0
  85. data/_includes/team-grid.liquid +172 -0
  86. data/_includes/video.liquid +97 -0
  87. data/_layouts/about.liquid +93 -0
  88. data/_layouts/archive.liquid +45 -0
  89. data/_layouts/bib.liquid +677 -0
  90. data/_layouts/book-review.liquid +225 -0
  91. data/_layouts/book-shelf.liquid +66 -0
  92. data/_layouts/cv.liquid +127 -0
  93. data/_layouts/default.liquid +70 -0
  94. data/_layouts/distill.liquid +132 -0
  95. data/_layouts/documentation-download-detail.liquid +5 -0
  96. data/_layouts/documentation-home.liquid +100 -0
  97. data/_layouts/documentation-page.liquid +45 -0
  98. data/_layouts/manual-bib.liquid +73 -0
  99. data/_layouts/manual-chapter.liquid +99 -0
  100. data/_layouts/manual-featured-bib.liquid +100 -0
  101. data/_layouts/manual-home.liquid +120 -0
  102. data/_layouts/none.liquid +1 -0
  103. data/_layouts/outputs.liquid +29 -0
  104. data/_layouts/page.liquid +52 -0
  105. data/_layouts/post.liquid +95 -0
  106. data/_layouts/profile.liquid +36 -0
  107. data/_layouts/profiles.liquid +36 -0
  108. data/_layouts/project.liquid +7 -0
  109. data/_layouts/repositories.liquid +58 -0
  110. data/_layouts/theses.liquid +126 -0
  111. data/_plugins/bibliography_profiles.rb +164 -0
  112. data/_plugins/callouts.rb +104 -0
  113. data/_plugins/code_blocks.rb +277 -0
  114. data/_plugins/computation_figure_images.rb +266 -0
  115. data/_plugins/computation_sources.rb +42 -0
  116. data/_plugins/content_search_index.rb +210 -0
  117. data/_plugins/details.rb +23 -0
  118. data/_plugins/external-posts.rb +124 -0
  119. data/_plugins/figure_captions.rb +800 -0
  120. data/_plugins/file-exists.rb +22 -0
  121. data/_plugins/google-scholar-citations.rb +86 -0
  122. data/_plugins/hide-custom-bibtex.rb +18 -0
  123. data/_plugins/inspirehep-citations.rb +57 -0
  124. data/_plugins/localized_visual_sources.rb +145 -0
  125. data/_plugins/manual_release_metadata.rb +47 -0
  126. data/_plugins/mermaid_mmd_images.rb +233 -0
  127. data/_plugins/profile-pages.rb +28 -0
  128. data/_plugins/remove-accents.rb +32 -0
  129. data/_plugins/reproducible_build_time.rb +55 -0
  130. data/_plugins/search-data.rb +83 -0
  131. data/_plugins/theme-cache-bust.rb +56 -0
  132. data/_plugins/vega_visual_images.rb +290 -0
  133. data/_plugins/web_capture_images.rb +154 -0
  134. data/_sass/_base.scss +2133 -0
  135. data/_sass/_blog.scss +67 -0
  136. data/_sass/_callouts.scss +100 -0
  137. data/_sass/_custom.scss +437 -0
  138. data/_sass/_cv.scss +274 -0
  139. data/_sass/_developer.scss +97 -0
  140. data/_sass/_distill.scss +185 -0
  141. data/_sass/_documentation.scss +2220 -0
  142. data/_sass/_layout.scss +218 -0
  143. data/_sass/_manual.scss +2691 -0
  144. data/_sass/_profile.scss +323 -0
  145. data/_sass/_projects.scss +163 -0
  146. data/_sass/_reading.scss +465 -0
  147. data/_sass/_site-custom.scss +3 -0
  148. data/_sass/_tabs.scss +48 -0
  149. data/_sass/_team.scss +230 -0
  150. data/_sass/_themes.scss +254 -0
  151. data/_sass/_typograms.scss +132 -0
  152. data/_sass/_variables.scss +52 -0
  153. data/_sass/font-awesome/_animated.scss +152 -0
  154. data/_sass/font-awesome/_bordered-pulled.scss +20 -0
  155. data/_sass/font-awesome/_core.scss +49 -0
  156. data/_sass/font-awesome/_fixed-width.scss +7 -0
  157. data/_sass/font-awesome/_functions.scss +57 -0
  158. data/_sass/font-awesome/_icons.scss +13 -0
  159. data/_sass/font-awesome/_list.scss +18 -0
  160. data/_sass/font-awesome/_mixins.scss +65 -0
  161. data/_sass/font-awesome/_rotated-flipped.scss +31 -0
  162. data/_sass/font-awesome/_screen-reader.scss +14 -0
  163. data/_sass/font-awesome/_shims.scss +1578 -0
  164. data/_sass/font-awesome/_sizing.scss +16 -0
  165. data/_sass/font-awesome/_stacked.scss +32 -0
  166. data/_sass/font-awesome/_variables.scss +5044 -0
  167. data/_sass/font-awesome/brands.scss +30 -0
  168. data/_sass/font-awesome/fontawesome.scss +21 -0
  169. data/_sass/font-awesome/regular.scss +27 -0
  170. data/_sass/font-awesome/solid.scss +27 -0
  171. data/_sass/font-awesome/v4-shims.scss +11 -0
  172. data/_sass/tabler-icons/tabler-icons-filled.scss +1646 -0
  173. data/_sass/tabler-icons/tabler-icons-outline.scss +9859 -0
  174. data/_sass/tabler-icons/tabler-icons.scss +11463 -0
  175. data/_scripts/cronitor-analytics-setup.js +9 -0
  176. data/_scripts/giscus-setup.js +48 -0
  177. data/_scripts/google-analytics-setup.js +9 -0
  178. data/_scripts/open-panel-analytics-setup.js +14 -0
  179. data/_scripts/photoswipe-setup.js +11 -0
  180. data/assets/audio/epicaly-short-113909.mp3 +0 -0
  181. data/assets/bibliography/2018-12-22-distill.bib +7 -0
  182. data/assets/css/academicons.min.css +1 -0
  183. data/assets/css/bootstrap-toc.min.css +4 -0
  184. data/assets/css/bootstrap.min.css +7 -0
  185. data/assets/css/bootstrap.min.css.map +1 -0
  186. data/assets/css/jekyll-pygments-themes-github.css +209 -0
  187. data/assets/css/jekyll-pygments-themes-native.css +232 -0
  188. data/assets/css/jupyter-grade3.css +3758 -0
  189. data/assets/css/jupyter-monokai.css +3758 -0
  190. data/assets/css/jupyter.css +383 -0
  191. data/assets/css/main.scss +36 -0
  192. data/assets/css/mdb.min.css +48 -0
  193. data/assets/css/mdb.min.css.map +1 -0
  194. data/assets/css/scholar-icons.css +386 -0
  195. data/assets/fonts/academicons.ttf +0 -0
  196. data/assets/fonts/academicons.woff +0 -0
  197. data/assets/fonts/scholar-icons.ttf +0 -0
  198. data/assets/fonts/scholar-icons.woff +0 -0
  199. data/assets/fonts/tabler-icons-filled.ttf +0 -0
  200. data/assets/fonts/tabler-icons-filled.woff +0 -0
  201. data/assets/fonts/tabler-icons-filled.woff2 +0 -0
  202. data/assets/fonts/tabler-icons-outline.ttf +0 -0
  203. data/assets/fonts/tabler-icons-outline.woff +0 -0
  204. data/assets/fonts/tabler-icons-outline.woff2 +0 -0
  205. data/assets/fonts/tabler-icons.ttf +0 -0
  206. data/assets/fonts/tabler-icons.woff +0 -0
  207. data/assets/fonts/tabler-icons.woff2 +0 -0
  208. data/assets/html/relativity.html +15 -0
  209. data/assets/img/1.jpg +0 -0
  210. data/assets/img/10.jpg +0 -0
  211. data/assets/img/11.jpg +0 -0
  212. data/assets/img/12.jpg +0 -0
  213. data/assets/img/2.jpg +0 -0
  214. data/assets/img/3.jpg +0 -0
  215. data/assets/img/4.jpg +0 -0
  216. data/assets/img/5.jpg +0 -0
  217. data/assets/img/6.jpg +0 -0
  218. data/assets/img/7.jpg +0 -0
  219. data/assets/img/8.jpg +0 -0
  220. data/assets/img/9.jpg +0 -0
  221. data/assets/img/book_covers/the_godfather.jpg +0 -0
  222. data/assets/img/brand/dosquartsdedocs-logo-cafe.svg +31 -0
  223. data/assets/img/brand/dosquartsdedocs-logo-footer-cafe.svg +31 -0
  224. data/assets/img/brand/dosquartsdedocs-logo-white.svg +31 -0
  225. data/assets/img/brand/dosquartsdedocs-logo.svg +31 -0
  226. data/assets/img/prof_pic.jpg +0 -0
  227. data/assets/img/prof_pic_color.png +0 -0
  228. data/assets/img/profile-placeholder.svg +8 -0
  229. data/assets/img/publication_preview/brownian-motion.gif +0 -0
  230. data/assets/img/publication_preview/goodchild1992-gis-cover.jpg +0 -0
  231. data/assets/img/publication_preview/gutierrez2020-jtrg-preview.jpg +0 -0
  232. data/assets/img/publication_preview/harvey1989-postmodernity-cover.jpg +0 -0
  233. data/assets/img/publication_preview/tobler1970-economic-geography-cover.jpg +0 -0
  234. data/assets/img/publication_preview/tuan1977-space-place-cover.jpg +0 -0
  235. data/assets/img/publication_preview/wave-mechanics.gif +0 -0
  236. data/assets/img/publication_preview/zaragozi2025-rooftop-pv-preview.jpg +0 -0
  237. data/assets/img/rhino.png +0 -0
  238. data/assets/img/team/aaron-gutierrez.png +0 -0
  239. data/assets/img/team/alba-font.png +0 -0
  240. data/assets/img/team/alba-viana.png +0 -0
  241. data/assets/img/team/alejandro-gonzalez.png +0 -0
  242. data/assets/img/team/ana-pastor.png +0 -0
  243. data/assets/img/team/antoni-domenech.png +0 -0
  244. data/assets/img/team/benito-zaragozi.png +0 -0
  245. data/assets/img/team/cinta-sanz.png +0 -0
  246. data/assets/img/team/daniel-miravet.png +0 -0
  247. data/assets/img/team/eugenia-altamirano.png +0 -0
  248. data/assets/img/team/fiammetta-brandajs.png +0 -0
  249. data/assets/img/team/francesc-gonzalez.png +0 -0
  250. data/assets/img/team/isabel-paulino.png +0 -0
  251. data/assets/img/team/joan-borras.png +0 -0
  252. data/assets/img/team/julie-wilson.png +0 -0
  253. data/assets/img/team/liliana-sole.png +0 -0
  254. data/assets/img/team/maria-casado.png +0 -0
  255. data/assets/img/team/maria-medina.png +0 -0
  256. data/assets/img/team/marta-nello.png +0 -0
  257. data/assets/img/team/mogrify +1 -0
  258. data/assets/img/team/oscar-saladie.png +0 -0
  259. data/assets/img/team/paolo-russo.png +0 -0
  260. data/assets/img/team/raquel-santos.png +0 -0
  261. data/assets/img/team/salvador-anton.png +0 -0
  262. data/assets/img/team/sunsi-huertas.png +0 -0
  263. data/assets/img/team/user-placeholder.png +0 -0
  264. data/assets/img/team/wilbert-den-hoed.png +0 -0
  265. data/assets/img/team/xavier-delclos.png +0 -0
  266. data/assets/img/template_error.png +0 -0
  267. data/assets/js/bibsearch.js +70 -0
  268. data/assets/js/bootstrap-toc.min.js +5 -0
  269. data/assets/js/bootstrap.bundle.min.js +7 -0
  270. data/assets/js/bootstrap.bundle.min.js.map +1 -0
  271. data/assets/js/callouts.js +115 -0
  272. data/assets/js/chartjs-setup.js +14 -0
  273. data/assets/js/common.js +117 -0
  274. data/assets/js/content-search-match.js +159 -0
  275. data/assets/js/content-search.js +441 -0
  276. data/assets/js/copy_code.js +60 -0
  277. data/assets/js/custom.js +6 -0
  278. data/assets/js/developer-mode.js +103 -0
  279. data/assets/js/diff2html-setup.js +20 -0
  280. data/assets/js/distillpub/overrides.js +27 -0
  281. data/assets/js/distillpub/template.v2.js +9616 -0
  282. data/assets/js/distillpub/template.v2.js.map +1 -0
  283. data/assets/js/distillpub/transforms.v2.js +14557 -0
  284. data/assets/js/distillpub/transforms.v2.js.map +1 -0
  285. data/assets/js/documentation.js +435 -0
  286. data/assets/js/echarts-setup.js +29 -0
  287. data/assets/js/highlight-search-term.js +110 -0
  288. data/assets/js/jupyter_new_tab.js +18 -0
  289. data/assets/js/leaflet-setup.js +22 -0
  290. data/assets/js/manual.js +308 -0
  291. data/assets/js/masonry.js +12 -0
  292. data/assets/js/mathjax-setup.js +46 -0
  293. data/assets/js/mermaid-setup.js +37 -0
  294. data/assets/js/newsletter.js +105 -0
  295. data/assets/js/no_defer.js +24 -0
  296. data/assets/js/plotly-setup.js +52 -0
  297. data/assets/js/profile-nav.js +92 -0
  298. data/assets/js/progress-bar.js +73 -0
  299. data/assets/js/pseudocode-setup.js +18 -0
  300. data/assets/js/search/base-styles.min.js +286 -0
  301. data/assets/js/search/command-score.min.js +7 -0
  302. data/assets/js/search/hotkeys-js/hotkeys.esm.min.js +18 -0
  303. data/assets/js/search/lit/decorators.min.js +7 -0
  304. data/assets/js/search/lit/directives/class-map.min.js +7 -0
  305. data/assets/js/search/lit/directives/join.min.js +7 -0
  306. data/assets/js/search/lit/directives/live.min.js +7 -0
  307. data/assets/js/search/lit/directives/ref.min.js +7 -0
  308. data/assets/js/search/lit/directives/repeat.min.js +7 -0
  309. data/assets/js/search/lit/directives/unsafe-html.min.js +7 -0
  310. data/assets/js/search/lit/index.min.js +7 -0
  311. data/assets/js/search/lit-element/lit-element.min.js +12 -0
  312. data/assets/js/search/lit-html/directives/class-map.min.js +22 -0
  313. data/assets/js/search/lit-html/directives/join.min.js +12 -0
  314. data/assets/js/search/lit-html/directives/live.min.js +28 -0
  315. data/assets/js/search/lit-html/directives/ref.min.js +32 -0
  316. data/assets/js/search/lit-html/directives/repeat.min.js +27 -0
  317. data/assets/js/search/lit-html/directives/unsafe-html.min.js +22 -0
  318. data/assets/js/search/lit-html/is-server.min.js +12 -0
  319. data/assets/js/search/lit-html/lit-html.min.js +12 -0
  320. data/assets/js/search/ninja-action.min.js +101 -0
  321. data/assets/js/search/ninja-footer.min.js +62 -0
  322. data/assets/js/search/ninja-header.min.js +78 -0
  323. data/assets/js/search/ninja-keys.min.js +39 -0
  324. data/assets/js/search/reactive-element/decorators/custom-element.min.js +12 -0
  325. data/assets/js/search/reactive-element/decorators/event-options.min.js +12 -0
  326. data/assets/js/search/reactive-element/decorators/property.min.js +22 -0
  327. data/assets/js/search/reactive-element/decorators/query-all.min.js +17 -0
  328. data/assets/js/search/reactive-element/decorators/query-assigned-elements.min.js +17 -0
  329. data/assets/js/search/reactive-element/decorators/query-assigned-nodes.min.js +17 -0
  330. data/assets/js/search/reactive-element/decorators/query-async.min.js +17 -0
  331. data/assets/js/search/reactive-element/decorators/query.min.js +17 -0
  332. data/assets/js/search/reactive-element/decorators/state.min.js +27 -0
  333. data/assets/js/search/reactive-element/reactive-element.min.js +17 -0
  334. data/assets/js/search-setup.js +17 -0
  335. data/assets/js/shortcut-key.js +11 -0
  336. data/assets/js/tabs.min.js +5 -0
  337. data/assets/js/theme.js +314 -0
  338. data/assets/js/tooltips-setup.js +3 -0
  339. data/assets/js/typograms.js +1341 -0
  340. data/assets/js/vanilla-back-to-top.min.js +1 -0
  341. data/assets/js/vega-setup.js +24 -0
  342. data/assets/js/venobox-setup.js +5 -0
  343. data/assets/js/wechat.js +14 -0
  344. data/assets/js/zoom.js +15 -0
  345. data/assets/json/resume.json +98 -0
  346. data/assets/json/table_data.json +128 -0
  347. data/assets/jupyter/blog.ipynb +48 -0
  348. data/assets/pdf/example_pdf.pdf +0 -0
  349. data/assets/plotly/demo.html +72 -0
  350. data/assets/video/pexels-engin-akyurt-6069112-960x540-30fps.mp4 +0 -0
  351. data/assets/webfonts/fa-brands-400.ttf +0 -0
  352. data/assets/webfonts/fa-brands-400.woff2 +0 -0
  353. data/assets/webfonts/fa-regular-400.ttf +0 -0
  354. data/assets/webfonts/fa-regular-400.woff2 +0 -0
  355. data/assets/webfonts/fa-solid-900.ttf +0 -0
  356. data/assets/webfonts/fa-solid-900.woff2 +0 -0
  357. data/assets/webfonts/fa-v4compatibility.ttf +0 -0
  358. data/assets/webfonts/fa-v4compatibility.woff2 +0 -0
  359. data/docs/Gemfile +5 -0
  360. data/docs/_config.yml +83 -0
  361. data/docs/_documentation/en/01-quick-start.md +115 -0
  362. data/docs/_documentation/en/02-tools.md +146 -0
  363. data/docs/_documentation/en/03-usage.md +107 -0
  364. data/docs/_documentation/en/04-jekyll-basics.md +51 -0
  365. data/docs/_documentation/en/05-pages-collections.md +68 -0
  366. data/docs/_documentation/en/06-github-web-editing.md +92 -0
  367. data/docs/_documentation/en/10-profiles.md +125 -0
  368. data/docs/_documentation/en/11-unaltreselfie.md +39 -0
  369. data/docs/_documentation/en/12-unaltreprojecte.md +38 -0
  370. data/docs/_documentation/en/13-unaltremanual.md +237 -0
  371. data/docs/_documentation/en/14-unaltredocs.md +92 -0
  372. data/docs/_documentation/en/15-profile-checklist.md +39 -0
  373. data/docs/_documentation/en/20-syntax.md +353 -0
  374. data/docs/_documentation/en/21-themes.md +103 -0
  375. data/docs/_documentation/en/22-feature-reference.md +56 -0
  376. data/docs/_documentation/en/23-front-matter.md +51 -0
  377. data/docs/_documentation/en/24-data-files.md +28 -0
  378. data/docs/_documentation/en/30-customization.md +448 -0
  379. data/docs/_documentation/en/31-template.md +55 -0
  380. data/docs/_documentation/en/32-development.md +108 -0
  381. data/docs/_documentation/en/33-reusable-workflows.md +136 -0
  382. data/docs/_documentation/en/40-distribution.md +164 -0
  383. data/docs/_documentation/en/41-bibliometrics.md +137 -0
  384. data/docs/_documentation/en/42-docker-image.md +185 -0
  385. data/docs/_documentation/en/50-faq.md +33 -0
  386. data/docs/_pages/ca/index.md +35 -0
  387. data/docs/_pages/en/index.md +35 -0
  388. data/docs/_pages/es/index.md +35 -0
  389. data/docs/agents/action-prompts/00-start-site-session.txt +10 -0
  390. data/docs/agents/action-prompts/05-create-new-web.txt +5 -0
  391. data/docs/agents/action-prompts/10-content-update.txt +9 -0
  392. data/docs/agents/action-prompts/15-edit-default-content.txt +8 -0
  393. data/docs/agents/action-prompts/20-manual-teaching-materials.txt +18 -0
  394. data/docs/agents/action-prompts/22-manual-style-audit.txt +14 -0
  395. data/docs/agents/action-prompts/23-manual-structure-audit.txt +17 -0
  396. data/docs/agents/action-prompts/25-translation-prepublish.txt +8 -0
  397. data/docs/agents/action-prompts/30-project-site-update.txt +7 -0
  398. data/docs/agents/action-prompts/40-documentation-update.txt +7 -0
  399. data/docs/agents/action-prompts/50-bibliography-entry.txt +7 -0
  400. data/docs/agents/action-prompts/60-bibliometrics-refresh.txt +7 -0
  401. data/docs/agents/action-prompts/70-build-and-review.txt +12 -0
  402. data/docs/agents/manual-authoring-components.md +255 -0
  403. data/docs/agents/mcp-contract.md +162 -0
  404. data/docs/agents/quarto-computation-adoption.md +102 -0
  405. data/docs/assets/img/unaltraweb-docs-hero.svg +36 -0
  406. data/lib/unaltraweb/version.rb +8 -0
  407. data/lib/unaltraweb.rb +31 -0
  408. data/requirements.txt +5 -0
  409. data/scripts/biblio/fetch_scimago_csv.sh +126 -0
  410. data/scripts/biblio/metrics_common.py +154 -0
  411. data/scripts/biblio/metrics_merge_meta.py +73 -0
  412. data/scripts/biblio/metrics_update.py +826 -0
  413. data/scripts/computations/python/Dockerfile +37 -0
  414. data/scripts/computations/r/Dockerfile +31 -0
  415. data/scripts/computations/render.py +1267 -0
  416. data/scripts/cv/render_pdf_preview.sh +36 -0
  417. data/scripts/deploy/publish_branch.sh +80 -0
  418. data/scripts/generate_logo_variants.py +47 -0
  419. data/scripts/manual/Dockerfile +31 -0
  420. data/scripts/manual/build_pdf.py +1780 -0
  421. data/scripts/manual/filters/bibliography.lua +164 -0
  422. data/scripts/manual/filters/code-blocks.lua +102 -0
  423. data/scripts/manual/filters/figure-captions.lua +35 -0
  424. data/scripts/manual/publish_release.sh +281 -0
  425. data/scripts/manual/templates/manual.tex +494 -0
  426. data/scripts/manual/verify_release_assets.py +650 -0
  427. data/scripts/test_gem_build.py +128 -0
  428. data/scripts/test_reproducible_jekyll_build.py +191 -0
  429. data/scripts/test_wheel_install.py +282 -0
  430. data/scripts/unaltraweb-docker-mount.sh +36 -0
  431. data/scripts/unaltraweb-mcp-bootstrap.sh +105 -0
  432. data/scripts/unaltraweb-mcp-cleanup.sh +73 -0
  433. data/scripts/unaltraweb-mcp-project-id.sh +34 -0
  434. data/scripts/validate_distribution.py +563 -0
  435. data/scripts/validate_workflows.py +1285 -0
  436. data/scripts/web_captures/Dockerfile +31 -0
  437. data/scripts/web_captures/capture.mjs +157 -0
  438. data/scripts/web_captures/package-lock.json +56 -0
  439. data/scripts/web_captures/package.json +9 -0
  440. data/scripts/web_captures/render.py +867 -0
  441. data/src/unaltraweb_mcp/component-contract.json +153 -0
  442. data/src/unaltraweb_mcp/component-contract.schema.json +87 -0
  443. data/src/unaltraweb_mcp/docker_mount.py +19 -0
  444. metadata +861 -0
@@ -0,0 +1,1780 @@
1
+ #!/usr/bin/env python3
2
+ """Build and locally publish unaltremanual PDFs from Jekyll sources."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import datetime as dt
8
+ import hashlib
9
+ import html
10
+ import json
11
+ import os
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ import unicodedata
18
+ import xml.etree.ElementTree as ET
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ try:
23
+ import yaml
24
+ except ImportError as exc: # pragma: no cover - supplied by the builder image
25
+ raise SystemExit("PyYAML is required to build an unaltremanual PDF.") from exc
26
+
27
+
28
+ SCRIPT_ROOT = Path(__file__).resolve().parent
29
+ DEFAULT_DOCKERFILE = SCRIPT_ROOT / "Dockerfile"
30
+ DEFAULT_TEMPLATE = SCRIPT_ROOT / "templates" / "manual.tex"
31
+ DEFAULT_BIBLIOGRAPHY_FILTER = SCRIPT_ROOT / "filters" / "bibliography.lua"
32
+ DEFAULT_CODE_BLOCK_FILTER = SCRIPT_ROOT / "filters" / "code-blocks.lua"
33
+ DEFAULT_FIGURE_FILTER = SCRIPT_ROOT / "filters" / "figure-captions.lua"
34
+ BIB_ENTRY_HEADER_RE = re.compile(r"(?im)^\s*@(?:[A-Za-z]+)\s*[({]\s*([^,\s]+)\s*,")
35
+ BIB_CUSTOM_URL_RE = re.compile(r'(?im)^\s*(?:manual_url|website)\s*=\s*(?:\{([^}\r\n]+)\}|"([^"\r\n]+)")\s*,?')
36
+ FRONT_MATTER_RE = re.compile(r"\A---[ \t]*\r?\n(.*?)^---[ \t]*(?:\r?\n|\Z)", re.MULTILINE | re.DOTALL)
37
+ CITE_RE = re.compile(r"{%\s*cite\s+([^%]+?)\s*%}")
38
+ INCLUDE_RE = re.compile(r"{%\s*include\s+([^%]+?)\s*%}")
39
+ LIQUID_RE = re.compile(r"({[{%].*?[}%]})", re.DOTALL)
40
+ INLINE_CODE_RE = re.compile(r"<code>(.*?)</code>", re.IGNORECASE | re.DOTALL)
41
+ FENCED_CODE_BLOCK_RE = re.compile(r"^[ \t]*(`{3,}|~{3,})[^\n]*\n.*?^[ \t]*\1[ \t]*$", re.MULTILINE | re.DOTALL)
42
+ MARKDOWN_INLINE_CODE_RE = re.compile(r"(`+)[^\n]*?\1")
43
+ DISPLAY_MATH_BLOCK_RE = re.compile(
44
+ r"^[ \t]*\$\$[ \t]*\r?\n(?P<body>.*?)^[ \t]*\$\$[ \t]*$",
45
+ re.MULTILINE | re.DOTALL,
46
+ )
47
+ BASEURL_RE = re.compile(r"\{\{\s*site\.baseurl\s*\}\}")
48
+ IMAGE_RE = re.compile(
49
+ r"!\[(?P<alt>[^\]]*)\]\((?P<path>\S+?)(?:\s+(?:\"(?P<double_title>[^\"]*)\"|'(?P<single_title>[^']*)'))?\)"
50
+ r"(?P<attrs>\{:[^}\n]*\})?"
51
+ )
52
+ TABLE_DIV_RE = re.compile(r'^::: table\s+["\'](.+?)["\']\s*\n(.*?)^:::\s*$', re.MULTILINE | re.DOTALL)
53
+ LISTING_DIV_RE = re.compile(
54
+ r'^:::\s*listing\s+"(?P<caption>[^"]+)"\s*\n(?P<body>.*?)^:::\s*$',
55
+ re.MULTILINE | re.DOTALL,
56
+ )
57
+ PIPE_TABLE_SEPARATOR_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?(?:\s*\|\s*:?-{3,}:?)+\s*\|?\s*$")
58
+ TABLE_GUARD_AFTER_HEADING_RE = re.compile(
59
+ r"^(?P<heading>#{2,6}\s+[^\n]+)\n\n```\{=latex\}\n"
60
+ r"(?P<guard>\\(?:clearpage|Needspace\{(?P<baselines>\d+)\\baselineskip\}))\n```",
61
+ re.MULTILINE,
62
+ )
63
+ SUBFIGURES_DIV_RE = re.compile(
64
+ r'^:::\s*subfigures(?:\s+(?P<layout>[^\s"]+))?(?:\s+"(?P<caption>[^"]*)")?\s*\n'
65
+ r'(?P<body>.*?)^:::\s*$',
66
+ re.MULTILINE | re.DOTALL,
67
+ )
68
+ CALLOUT_BLOCK_RE = re.compile(r"^(?P<block>(?P<marks>>{2,})[^\n]*(?:\n(?P=marks)[^\n]*)*)", re.MULTILINE)
69
+ FIGURE_DIMENSION_ATTR_RE = re.compile(
70
+ r"(?:\A|\s)(?P<name>data-figure-(?:width|height)(?:-(?:web|pdf))?)\s*=\s*"
71
+ r"(?:\"(?P<double>[^\"]*)\"|'(?P<single>[^']*)'|(?P<bare>[^\s}]+))"
72
+ )
73
+ LANGUAGE_NAMES = {"ca": "catalan", "es": "spanish", "en": "english"}
74
+ LANGUAGE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*\Z")
75
+ RELEASE_SELECTOR_RE = re.compile(r"(?:latest|v[0-9]{4}\.(?:0[1-9]|1[0-2])(?:\.[1-9][0-9]*)?)\Z")
76
+ COMPUTATION_SUFFIXES = {".qmd", ".rmd", ".r", ".py", ".ipynb"}
77
+ DIAGRAM_SUFFIXES = {".mmd", ".mermaid", ".puml", ".plantuml", ".uml"}
78
+ GENERATED_MEDIA_SUFFIXES = {".gif", ".jpeg", ".jpg", ".pdf", ".png", ".svg", ".webp"}
79
+ VEGA_SOURCE_SUFFIXES = (".vl.json", ".vg.json")
80
+ VEGA_OUTPUT_SUFFIXES = {".svg", ".png", ".pdf"}
81
+ VEGA_MANIFEST_NAME = ".vegavisuals.yml"
82
+ VEGA_SAFE_NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*\Z")
83
+ REMOTE_PATH_RE = re.compile(r"^(?:[a-z][a-z0-9+.-]*:)?//", re.IGNORECASE)
84
+ VISUAL_LOCALIZATION_SUFFIXES = tuple(sorted({
85
+ ".capture.edited.svg", ".capture.yaml", ".capture.yml", ".capture.svg",
86
+ ".mermaid.edited.svg", ".mermaid.svg", ".plantuml.edited.svg", ".plantuml.svg",
87
+ ".mmd.edited.svg", ".mmd.svg", ".puml.edited.svg", ".puml.svg", ".uml.edited.svg", ".uml.svg",
88
+ ".vl.json", ".vg.json", ".edited.svg",
89
+ ".qmd", ".rmd", ".ipynb", ".mermaid", ".plantuml", ".mmd", ".puml", ".uml", ".py", ".r",
90
+ ".jpeg", ".tiff", ".webp", ".gif", ".jpg", ".png", ".svg", ".pdf",
91
+ }, key=len, reverse=True))
92
+ REPRODUCIBLE_BUILD_EPOCH = "0"
93
+ CALLOUT_STYLES = {
94
+ 2: ("ManualCalloutInfo", "info"),
95
+ 3: ("ManualCalloutExample", "example"),
96
+ 4: ("ManualCalloutWarning", "warning"),
97
+ 5: ("ManualCalloutObjectives", "objectives"),
98
+ 6: ("ManualCalloutDanger", "danger"),
99
+ }
100
+ CALLOUT_LABELS = {
101
+ "ca": {
102
+ "info": "NOTA",
103
+ "example": "EXEMPLE",
104
+ "warning": "ADVERTÈNCIA",
105
+ "objectives": "OBJECTIUS D'APRENENTATGE",
106
+ "danger": "ATENCIÓ",
107
+ },
108
+ "es": {
109
+ "info": "NOTA",
110
+ "example": "EJEMPLO",
111
+ "warning": "ADVERTENCIA",
112
+ "objectives": "OBJETIVOS DE APRENDIZAJE",
113
+ "danger": "ATENCIÓN",
114
+ },
115
+ "en": {
116
+ "info": "NOTE",
117
+ "example": "EXAMPLE",
118
+ "warning": "WARNING",
119
+ "objectives": "LEARNING OBJECTIVES",
120
+ "danger": "CAUTION",
121
+ },
122
+ }
123
+ CAPTURE_SVG_ALLOWED_ELEMENTS = {
124
+ "svg", "g", "defs", "metadata", "title", "desc", "image", "rect", "path", "text", "tspan",
125
+ "marker", "polygon", "polyline", "line", "circle", "ellipse", "clippath", "mask", "lineargradient",
126
+ "radialgradient", "stop", "pattern", "use", "namedview", "grid", "page",
127
+ }
128
+ CAPTURE_SVG_ALLOWED_ATTRIBUTES = {
129
+ "id", "class", "version", "baseprofile", "x", "y", "x1", "y1", "x2", "y2", "cx", "cy", "r", "rx", "ry",
130
+ "width", "height", "viewbox", "preserveaspectratio", "d", "points", "transform", "fill", "stroke", "stroke-width",
131
+ "stroke-linecap", "stroke-linejoin", "stroke-miterlimit", "stroke-dasharray", "stroke-dashoffset", "opacity", "fill-opacity",
132
+ "stroke-opacity", "fill-rule", "clip-rule", "clip-path", "mask", "font-family", "font-size", "font-style", "font-weight",
133
+ "text-anchor", "dominant-baseline", "marker-start", "marker-mid", "marker-end", "markerwidth", "markerheight", "refx", "refy",
134
+ "orient", "offset", "stop-color", "stop-opacity", "patternunits", "patterncontentunits", "gradientunits", "gradienttransform",
135
+ "spreadmethod", "href", "style", "data-selector", "groupmode", "label", "space", "role", "aria-label", "pagecolor",
136
+ "bordercolor", "borderopacity", "objecttolerance", "gridtolerance", "guidetolerance", "showgrid", "showguides", "zoom",
137
+ "current-layer", "document-units", "pagecheckerboard", "deskcolor", "units", "originx", "originy", "spacingx", "spacingy",
138
+ }
139
+ CAPTURE_SVG_ALLOWED_STYLE_PROPERTIES = {
140
+ "fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "stroke-miterlimit", "stroke-dasharray",
141
+ "stroke-dashoffset", "opacity", "fill-opacity", "stroke-opacity", "fill-rule", "clip-rule", "clip-path", "mask",
142
+ "font-family", "font-size", "font-style", "font-weight", "text-anchor", "dominant-baseline", "marker-start", "marker-mid",
143
+ "marker-end", "stop-color", "stop-opacity", "display", "visibility",
144
+ }
145
+ METADATA_LABELS = {
146
+ "ca": {
147
+ "title": "Crèdits editorials",
148
+ "series": "Col·lecció",
149
+ "publisher": "Editorial",
150
+ "edition": "Edició",
151
+ "publication_date": "Data de publicació",
152
+ "release_channel": "Canal de publicació",
153
+ "release_version": "Versió publicada",
154
+ "subject": "Assignatura",
155
+ "teaching_guides": "Guies docents",
156
+ "academic_year": "Curs acadèmic",
157
+ "department": "Departament",
158
+ "faculty": "Facultat",
159
+ "institution": "Institució",
160
+ "location": "Localització",
161
+ "revision_date": "Data de revisió",
162
+ "instructors": "Autoria",
163
+ "identifier": "Identificador",
164
+ "license": "Llicència",
165
+ "source": "Edició web",
166
+ "rights": "Drets",
167
+ "references": "Referències",
168
+ "listing": "Codi",
169
+ "list_of_figures": "Índex de figures",
170
+ "list_of_tables": "Índex de taules",
171
+ "list_of_listings": "Índex de codis",
172
+ },
173
+ "es": {
174
+ "title": "Créditos editoriales",
175
+ "series": "Colección",
176
+ "publisher": "Editorial",
177
+ "edition": "Edición",
178
+ "publication_date": "Fecha de publicación",
179
+ "release_channel": "Canal de publicación",
180
+ "release_version": "Versión publicada",
181
+ "subject": "Asignatura",
182
+ "teaching_guides": "Guías docentes",
183
+ "academic_year": "Curso académico",
184
+ "department": "Departamento",
185
+ "faculty": "Facultad",
186
+ "institution": "Institución",
187
+ "location": "Localización",
188
+ "revision_date": "Fecha de revisión",
189
+ "instructors": "Autoría",
190
+ "identifier": "Identificador",
191
+ "license": "Licencia",
192
+ "source": "Edición web",
193
+ "rights": "Derechos",
194
+ "references": "Referencias",
195
+ "listing": "Código",
196
+ "list_of_figures": "Índice de figuras",
197
+ "list_of_tables": "Índice de tablas",
198
+ "list_of_listings": "Índice de códigos",
199
+ },
200
+ "en": {
201
+ "title": "Editorial credits",
202
+ "series": "Series",
203
+ "publisher": "Publisher",
204
+ "edition": "Edition",
205
+ "publication_date": "Publication date",
206
+ "release_channel": "Publication channel",
207
+ "release_version": "Published version",
208
+ "subject": "Course",
209
+ "teaching_guides": "Teaching guides",
210
+ "academic_year": "Academic year",
211
+ "department": "Department",
212
+ "faculty": "Faculty",
213
+ "institution": "Institution",
214
+ "location": "Location",
215
+ "revision_date": "Revision date",
216
+ "instructors": "Authors",
217
+ "identifier": "Identifier",
218
+ "license": "License",
219
+ "source": "Web edition",
220
+ "rights": "Rights",
221
+ "references": "References",
222
+ "listing": "Code example",
223
+ "list_of_figures": "List of figures",
224
+ "list_of_tables": "List of tables",
225
+ "list_of_listings": "List of code examples",
226
+ },
227
+ }
228
+
229
+
230
+ class ManualPdfError(RuntimeError):
231
+ pass
232
+
233
+
234
+ def release_metadata() -> dict[str, str]:
235
+ selector = os.environ.get("UNALTRAWEB_MANUAL_RELEASE_SELECTOR", "latest").strip()
236
+ if not RELEASE_SELECTOR_RE.fullmatch(selector):
237
+ raise ManualPdfError(
238
+ "UNALTRAWEB_MANUAL_RELEASE_SELECTOR must be exactly latest, vYYYY.MM, or vYYYY.MM.N."
239
+ )
240
+ return {
241
+ "release-selector": selector,
242
+ "release-channel": "latest" if selector == "latest" else "stable",
243
+ }
244
+
245
+
246
+ class UniqueKeySafeLoader(yaml.SafeLoader):
247
+ def construct_mapping(self, node: yaml.nodes.MappingNode, deep: bool = False) -> dict[Any, Any]:
248
+ self.flatten_mapping(node)
249
+ mapping: dict[Any, Any] = {}
250
+ for key_node, value_node in node.value:
251
+ key = self.construct_object(key_node, deep=deep)
252
+ try:
253
+ duplicate = key in mapping
254
+ except TypeError as exc:
255
+ raise yaml.constructor.ConstructorError(
256
+ "while constructing a mapping",
257
+ node.start_mark,
258
+ "found an unhashable mapping key",
259
+ key_node.start_mark,
260
+ ) from exc
261
+ if duplicate:
262
+ raise yaml.constructor.ConstructorError(
263
+ "while constructing a mapping",
264
+ node.start_mark,
265
+ f"found duplicate key: {key}",
266
+ key_node.start_mark,
267
+ )
268
+ mapping[key] = self.construct_object(value_node, deep=deep)
269
+ return mapping
270
+
271
+
272
+ def read_yaml(path: Path) -> dict[str, Any]:
273
+ if not path.is_file():
274
+ raise ManualPdfError(f"Missing required YAML file: {path}")
275
+ parsed = yaml.safe_load(path.read_text(encoding="utf-8"))
276
+ return parsed if isinstance(parsed, dict) else {}
277
+
278
+
279
+ def read_source(path: Path) -> tuple[dict[str, Any], str]:
280
+ text = path.read_text(encoding="utf-8")
281
+ match = FRONT_MATTER_RE.match(text)
282
+ if not match:
283
+ return {}, text
284
+ parsed = yaml.safe_load(match.group(1))
285
+ return (parsed if isinstance(parsed, dict) else {}), text[match.end():]
286
+
287
+
288
+ def nested(config: dict[str, Any], *keys: str) -> dict[str, Any]:
289
+ value: Any = config
290
+ for key in keys:
291
+ if not isinstance(value, dict):
292
+ return {}
293
+ value = value.get(key)
294
+ return value if isinstance(value, dict) else {}
295
+
296
+
297
+ def safe_relative(project: Path, raw: str, *, label: str, must_exist: bool = False) -> Path:
298
+ value = str(raw or "").strip()
299
+ if not value:
300
+ raise ManualPdfError(f"No {label} path is configured.")
301
+ relative = Path(value)
302
+ if relative.is_absolute() or ".." in relative.parts:
303
+ raise ManualPdfError(f"{label} must be a project-relative path: {raw}")
304
+ resolved = (project / relative).resolve()
305
+ try:
306
+ resolved.relative_to(project)
307
+ except ValueError as exc:
308
+ raise ManualPdfError(f"{label} escapes the project: {raw}") from exc
309
+ if must_exist and not resolved.exists():
310
+ raise ManualPdfError(f"Missing {label}: {relative}")
311
+ return resolved
312
+
313
+
314
+ def validate_language(value: str) -> str:
315
+ language = value.strip()
316
+ if not language or not LANGUAGE_RE.fullmatch(language):
317
+ raise ManualPdfError(f"Invalid PDF language: {value!r}")
318
+ return language
319
+
320
+
321
+ def normalize_callout_language(value: Any) -> str:
322
+ normalized = str(value or "").lower().split("-", 1)[0]
323
+ return normalized or "en"
324
+
325
+
326
+ def language_data(project: Path, config: dict[str, Any], lang: str) -> tuple[dict[str, Any], Path | None]:
327
+ data_root = safe_relative(project, str(config.get("data_dir") or "_data"), label="Jekyll data directory")
328
+ if not LANGUAGE_RE.fullmatch(lang):
329
+ return {}, None
330
+ for suffix in (".yml", ".yaml"):
331
+ path = data_root / "i18n" / f"{lang}{suffix}"
332
+ if not path.is_file():
333
+ continue
334
+ parsed = yaml.safe_load(path.read_text(encoding="utf-8"))
335
+ return (parsed if isinstance(parsed, dict) else {}), path
336
+ return {}, None
337
+
338
+
339
+ def resolve_callout_labels(project: Path, config: dict[str, Any], language: str) -> tuple[dict[str, str], list[Path]]:
340
+ normalized_language = normalize_callout_language(language)
341
+ default_language = normalize_callout_language(config.get("default_lang") or config.get("lang") or "en")
342
+ localized_data, localized_path = language_data(project, config, normalized_language)
343
+ if default_language == normalized_language:
344
+ default_data, default_path = localized_data, localized_path
345
+ else:
346
+ default_data, default_path = language_data(project, config, default_language)
347
+
348
+ localized_callouts = localized_data.get("callouts")
349
+ default_callouts = default_data.get("callouts")
350
+ if isinstance(localized_callouts, dict):
351
+ configured = localized_callouts
352
+ elif isinstance(default_callouts, dict):
353
+ configured = default_callouts
354
+ else:
355
+ configured = {}
356
+
357
+ defaults = CALLOUT_LABELS.get(normalized_language) or CALLOUT_LABELS.get(default_language) or CALLOUT_LABELS["en"]
358
+ labels = dict(defaults)
359
+ labels.update({str(key): "" if value is None else str(value) for key, value in configured.items()})
360
+ dependencies = list(dict.fromkeys(path for path in (localized_path, default_path) if path is not None))
361
+ return labels, dependencies
362
+
363
+
364
+ def language_list(config: dict[str, Any], pdf: dict[str, Any], requested: str = "") -> list[str]:
365
+ configured = pdf.get("languages")
366
+ values: list[str] = []
367
+ if isinstance(configured, list):
368
+ values = [validate_language(str(item)) for item in configured if str(item).strip()]
369
+ if not values:
370
+ values = [validate_language(str(config.get("default_lang") or config.get("lang") or "en"))]
371
+ if requested.strip():
372
+ language = validate_language(requested)
373
+ if language not in values:
374
+ raise ManualPdfError(f"Language '{language}' is not enabled in unaltraweb.manual.pdf.languages.")
375
+ return [language]
376
+ return values
377
+
378
+
379
+ def render_path(template: str, lang: str) -> str:
380
+ try:
381
+ return template.format(lang=lang)
382
+ except (KeyError, ValueError) as exc:
383
+ raise ManualPdfError(f"Invalid language path template: {template}") from exc
384
+
385
+
386
+ def artifact_paths(project: Path, config: dict[str, Any], lang: str) -> dict[str, Path]:
387
+ lang = validate_language(lang)
388
+ manual = nested(config, "unaltraweb", "manual")
389
+ pdf = manual.get("pdf") if isinstance(manual.get("pdf"), dict) else {}
390
+ build_dir_raw = str(pdf.get("build_dir") or "tmp/manual-pdf")
391
+ build_root_raw = str(Path(build_dir_raw) / lang)
392
+ build_root = safe_relative(project, build_root_raw, label="language PDF build directory")
393
+ public_pdf_raw = render_path(str(pdf.get("output") or "assets/pdf/manual-{lang}.pdf"), lang)
394
+ public_cover_raw = render_path(str(pdf.get("cover_output") or "assets/img/manual-cover-{lang}.png"), lang)
395
+ if Path(public_pdf_raw).suffix.lower() != ".pdf":
396
+ raise ManualPdfError("Published PDF path must use the .pdf extension.")
397
+ if Path(public_cover_raw).suffix.lower() != ".png":
398
+ raise ManualPdfError("Published cover path must use the .png extension.")
399
+ public_pdf = safe_relative(project, public_pdf_raw, label="published PDF")
400
+ public_cover = safe_relative(project, public_cover_raw, label="published cover")
401
+ if public_pdf == public_cover:
402
+ raise ManualPdfError("Published PDF and cover paths must be different.")
403
+ if Path(public_pdf_raw).name == Path(public_cover_raw).name:
404
+ raise ManualPdfError("Published PDF and cover filenames must be different.")
405
+ return {
406
+ "build_dir": build_root,
407
+ "pdf": build_root / Path(public_pdf_raw).name,
408
+ "cover": build_root / Path(public_cover_raw).name,
409
+ "source": build_root / "manual.md",
410
+ "metadata": build_root / "metadata.yml",
411
+ "bibliography": build_root / "bibliography.bib",
412
+ "manifest": build_root / "manifest.json",
413
+ "public_pdf": public_pdf,
414
+ "public_cover": public_cover,
415
+ }
416
+
417
+
418
+ def artifact_plan(project: Path, config: dict[str, Any], languages: list[str]) -> list[tuple[str, dict[str, Path]]]:
419
+ plan = [(language, artifact_paths(project, config, language)) for language in languages]
420
+ destinations: dict[Path, tuple[str, str]] = {}
421
+ labels = {
422
+ "pdf": "generated PDF",
423
+ "cover": "generated cover",
424
+ "public_pdf": "published PDF",
425
+ "public_cover": "published cover",
426
+ }
427
+ for language, paths in plan:
428
+ for key, label in labels.items():
429
+ destination = paths[key]
430
+ previous = destinations.get(destination)
431
+ if previous is not None:
432
+ previous_language, previous_label = previous
433
+ relative = destination.relative_to(project).as_posix()
434
+ raise ManualPdfError(
435
+ f"Manual PDF destination collision: {previous_label} for '{previous_language}' and "
436
+ f"{label} for '{language}' both resolve to {relative}."
437
+ )
438
+ destinations[destination] = (language, label)
439
+ return plan
440
+
441
+
442
+ def manual_sources(project: Path, config: dict[str, Any], lang: str) -> tuple[Path | None, list[tuple[Path, dict[str, Any], str]], str]:
443
+ manual = nested(config, "unaltraweb", "manual")
444
+ collection = str(manual.get("collection") or "chapters").strip()
445
+ collection_root = safe_relative(project, f"_{collection}", label="manual collection", must_exist=True)
446
+ default_lang = str(config.get("default_lang") or config.get("lang") or lang)
447
+
448
+ pages: list[tuple[Path, dict[str, Any], str]] = []
449
+ for path in sorted(collection_root.rglob("*")):
450
+ if path.suffix.lower() not in {".md", ".markdown"} or not path.is_file():
451
+ continue
452
+ front, body = read_source(path)
453
+ if front.get("pdf") is False:
454
+ continue
455
+ pages.append((path, front, body))
456
+
457
+ selected = [item for item in pages if str(item[1].get("lang") or default_lang) == lang]
458
+ if not selected:
459
+ raise ManualPdfError(f"No manual chapters found for language '{lang}'.")
460
+
461
+ def weight(item: tuple[Path, dict[str, Any], str]) -> tuple[float, str]:
462
+ raw = item[1].get("weight", 0)
463
+ try:
464
+ numeric = float(raw)
465
+ except (TypeError, ValueError):
466
+ numeric = 0
467
+ return numeric, str(item[0])
468
+
469
+ selected.sort(key=weight)
470
+ numbered = [item for item in selected if item[1].get("manual_numbered") is not False]
471
+ references = [item for item in selected if item[1].get("manual_numbered") is False]
472
+
473
+ home: Path | None = None
474
+ home_candidates: list[Path] = []
475
+ pages_root = project / "_pages"
476
+ if pages_root.is_dir():
477
+ for path in sorted(pages_root.rglob("*")):
478
+ if path.suffix.lower() not in {".md", ".markdown"} or not path.is_file():
479
+ continue
480
+ front, _ = read_source(path)
481
+ if front.get("layout") == "manual-home" and str(front.get("lang") or default_lang) == lang:
482
+ home_candidates.append(path)
483
+ if len(home_candidates) > 1:
484
+ choices = ", ".join(str(path.relative_to(project)) for path in home_candidates)
485
+ raise ManualPdfError(f"Multiple manual-home pages found for language '{lang}': {choices}")
486
+ if home_candidates:
487
+ home = home_candidates[0]
488
+ return home, numbered + references, lang
489
+
490
+
491
+ def validate_capture_svg(path: Path) -> None:
492
+ text = path.read_text(encoding="utf-8")
493
+ lowered = text.lower()
494
+ if "<!doctype" in lowered or "<!entity" in lowered:
495
+ raise ManualPdfError(f"Unsafe web capture SVG: {path}")
496
+ try:
497
+ root = ET.fromstring(text)
498
+ except ET.ParseError as exc:
499
+ raise ManualPdfError(f"Invalid web capture SVG: {path}: {exc}") from exc
500
+ for element in root.iter():
501
+ tag = element.tag.rsplit("}", 1)[-1].lower()
502
+ if tag not in CAPTURE_SVG_ALLOWED_ELEMENTS:
503
+ raise ManualPdfError(f"Unsupported web capture SVG element in {path}: {tag}")
504
+ for raw_name, raw_value in element.attrib.items():
505
+ name = raw_name.rsplit("}", 1)[-1].lower()
506
+ value = raw_value.strip()
507
+ lowered_value = value.lower()
508
+ if name not in CAPTURE_SVG_ALLOWED_ATTRIBUTES:
509
+ raise ManualPdfError(f"Unsupported web capture SVG attribute in {path}: {name}")
510
+ if "@import" in lowered_value or "javascript:" in lowered_value or "expression(" in lowered_value:
511
+ raise ManualPdfError(f"Unsafe web capture SVG attribute in {path}: {name}")
512
+ if name == "href" and not (value.startswith("data:image/png;base64,") or value.startswith("#")):
513
+ raise ManualPdfError(f"External web capture SVG reference in {path}")
514
+ without_local_urls = re.sub(r"url\(\s*['\"]?#[A-Za-z_][-:.A-Za-z0-9_]*['\"]?\s*\)", "", lowered_value)
515
+ if "url(" in without_local_urls:
516
+ raise ManualPdfError(f"External CSS reference in web capture SVG: {path}")
517
+ if name == "style":
518
+ declarations = [part.strip() for part in value.split(";") if part.strip()]
519
+ for declaration in declarations:
520
+ property_name, separator, _ = declaration.partition(":")
521
+ if not separator or property_name.strip().lower() not in CAPTURE_SVG_ALLOWED_STYLE_PROPERTIES:
522
+ raise ManualPdfError(f"Unsupported CSS property in web capture SVG {path}: {property_name.strip()}")
523
+
524
+
525
+ def commented_front_matter(path: Path, prefix: str) -> str:
526
+ marker = f"{prefix} ---"
527
+ active = False
528
+ lines: list[str] = []
529
+ for line in path.read_text(encoding="utf-8").splitlines():
530
+ if line.strip() == marker:
531
+ if active:
532
+ return "\n".join(lines)
533
+ active = True
534
+ continue
535
+ if active:
536
+ if not line.startswith(prefix):
537
+ break
538
+ value = line[len(prefix):]
539
+ lines.append(value[1:] if value.startswith(" ") else value)
540
+ return ""
541
+
542
+
543
+ def computation_front_matter(path: Path) -> dict[str, Any]:
544
+ suffix = path.suffix.lower()
545
+ text = ""
546
+ if suffix in {".qmd", ".rmd"}:
547
+ match = FRONT_MATTER_RE.match(path.read_text(encoding="utf-8"))
548
+ text = match.group(1) if match else ""
549
+ elif suffix == ".r":
550
+ text = commented_front_matter(path, "#'")
551
+ elif suffix == ".py":
552
+ text = commented_front_matter(path, "#")
553
+ elif suffix == ".ipynb":
554
+ try:
555
+ notebook = json.loads(path.read_text(encoding="utf-8"))
556
+ except json.JSONDecodeError as exc:
557
+ raise ManualPdfError(f"Invalid notebook JSON in computation source {path}: {exc}") from exc
558
+ if not isinstance(notebook, dict):
559
+ raise ManualPdfError(f"Computation notebook must contain a JSON object: {path}")
560
+ metadata = notebook.get("metadata")
561
+ if isinstance(metadata, dict) and isinstance(metadata.get("unaltraweb_front_matter"), dict):
562
+ return metadata["unaltraweb_front_matter"]
563
+ for cell in notebook.get("cells", []):
564
+ if not isinstance(cell, dict) or cell.get("cell_type") not in {"raw", "markdown"}:
565
+ continue
566
+ source = cell.get("source", "")
567
+ cell_text = "".join(source) if isinstance(source, list) else str(source)
568
+ match = FRONT_MATTER_RE.match(cell_text)
569
+ text = match.group(1) if match else ""
570
+ break
571
+ if not text:
572
+ return {}
573
+ try:
574
+ parsed = yaml.safe_load(text)
575
+ except yaml.YAMLError as exc:
576
+ raise ManualPdfError(f"Invalid computation front matter in {path}: {exc}") from exc
577
+ if parsed is None:
578
+ return {}
579
+ if not isinstance(parsed, dict):
580
+ raise ManualPdfError(f"Computation front matter must be a YAML mapping: {path}")
581
+ return parsed
582
+
583
+
584
+ def split_url_decoration(raw: str) -> tuple[str, str]:
585
+ indexes = [index for token in ("?", "#") if (index := raw.find(token)) >= 0]
586
+ if not indexes:
587
+ return raw, ""
588
+ index = min(indexes)
589
+ return raw[:index], raw[index:]
590
+
591
+
592
+ def load_vega_manifest(project: Path) -> list[dict[str, str]]:
593
+ project = project.resolve()
594
+ manifest_path = project / VEGA_MANIFEST_NAME
595
+ if not manifest_path.is_file():
596
+ raise ManualPdfError(f"Missing required Vega visualization manifest: {VEGA_MANIFEST_NAME}")
597
+ if manifest_path.stat().st_size > 1024 * 1024:
598
+ raise ManualPdfError(f"Vega visualization manifest exceeds 1048576 bytes: {VEGA_MANIFEST_NAME}")
599
+ try:
600
+ text = manifest_path.read_text(encoding="utf-8")
601
+ if any(isinstance(token, yaml.tokens.AliasToken) for token in yaml.scan(text)):
602
+ raise ManualPdfError(f"YAML aliases are not allowed in {VEGA_MANIFEST_NAME}")
603
+ manifest = yaml.load(text, Loader=UniqueKeySafeLoader)
604
+ except ManualPdfError:
605
+ raise
606
+ except (OSError, UnicodeDecodeError, yaml.YAMLError) as exc:
607
+ raise ManualPdfError(f"Invalid Vega visualization manifest {VEGA_MANIFEST_NAME}: {exc}") from exc
608
+
609
+ if not isinstance(manifest, dict):
610
+ raise ManualPdfError(f"Vega visualization manifest must be a mapping: {VEGA_MANIFEST_NAME}")
611
+ if type(manifest.get("version")) is not int or manifest.get("version") != 1:
612
+ raise ManualPdfError("Vega visualization manifest version must be 1")
613
+ for field in ("profile", "family"):
614
+ if not isinstance(manifest.get(field), str) or not manifest[field]:
615
+ raise ManualPdfError(f"Vega visualization manifest requires a {field}")
616
+ visualizations = manifest.get("visualizations")
617
+ if not isinstance(visualizations, list):
618
+ raise ManualPdfError("Vega visualization manifest visualizations must be a list")
619
+
620
+ names: set[str] = set()
621
+ sources: set[str] = set()
622
+ outputs: set[str] = set()
623
+ normalized: list[dict[str, str]] = []
624
+ for index, item in enumerate(visualizations):
625
+ if not isinstance(item, dict):
626
+ raise ManualPdfError(f"Vega visualization at index {index} must be a mapping")
627
+ name = item.get("name")
628
+ if not isinstance(name, str) or not VEGA_SAFE_NAME_RE.fullmatch(name):
629
+ raise ManualPdfError(f"Vega visualization at index {index} has an invalid name")
630
+ if name in names:
631
+ raise ManualPdfError(f"Duplicate Vega visualization name: {name}")
632
+ source_raw = item.get("source")
633
+ output_raw = item.get("output")
634
+ if not isinstance(source_raw, str) or not source_raw:
635
+ raise ManualPdfError(f"Vega visualization {name} requires a source")
636
+ if not isinstance(output_raw, str) or not output_raw:
637
+ raise ManualPdfError(f"Vega visualization {name} requires an output")
638
+
639
+ source = safe_relative(project, source_raw, label=f"source for Vega visualization {name}", must_exist=True)
640
+ output = safe_relative(project, output_raw, label=f"output for Vega visualization {name}")
641
+ if not source.is_file():
642
+ raise ManualPdfError(f"Source for Vega visualization {name} is not a file: {source_raw}")
643
+ source_relative = source.relative_to(project).as_posix()
644
+ output_relative = output.relative_to(project).as_posix()
645
+ if source == output:
646
+ raise ManualPdfError(f"Vega visualization {name} output cannot replace its source")
647
+ if source_relative in sources:
648
+ raise ManualPdfError(f"Duplicate Vega visualization source: {source_relative}")
649
+ if output_relative in outputs:
650
+ raise ManualPdfError(f"Duplicate Vega visualization output: {output_relative}")
651
+
652
+ output_suffix = output.suffix.lower()
653
+ if output_suffix not in VEGA_OUTPUT_SUFFIXES:
654
+ raise ManualPdfError(f"Vega visualization {name} output must use .svg, .png, or .pdf")
655
+ item_format = item.get("format")
656
+ if item_format is not None and not isinstance(item_format, str):
657
+ raise ManualPdfError(f"Vega visualization {name} format must be a string")
658
+ if item_format and f".{item_format.strip().lower()}" != output_suffix:
659
+ raise ManualPdfError(f"Vega visualization {name} output suffix does not match format {item_format}")
660
+ if not isinstance(item.get("engine", "auto"), str):
661
+ raise ManualPdfError(f"Vega visualization {name} engine must be a string")
662
+ inputs = item.get("inputs", [])
663
+ if not isinstance(inputs, list) or not all(isinstance(entry, str) and entry for entry in inputs):
664
+ raise ManualPdfError(f"Vega visualization {name} inputs must be a list of paths")
665
+
666
+ names.add(name)
667
+ sources.add(source_relative)
668
+ outputs.add(output_relative)
669
+ normalized.append({"name": name, "source": source_relative, "output": output_relative})
670
+ return normalized
671
+
672
+
673
+ def resolve_vega_source(project: Path, source_path: str) -> str:
674
+ project = project.resolve()
675
+ if REMOTE_PATH_RE.match(source_path):
676
+ raise ManualPdfError(f"Vega visualization source must be a local project path: {source_path}")
677
+ source = safe_relative(project, source_path, label="Vega visualization source", must_exist=True)
678
+ if not source.is_file():
679
+ raise ManualPdfError(f"Vega visualization source is not a file: {source_path}")
680
+ source_relative = source.relative_to(project).as_posix()
681
+ matches = [item for item in load_vega_manifest(project) if item["source"] == source_relative]
682
+ if not matches:
683
+ raise ManualPdfError(f"Vega visualization source is not declared in {VEGA_MANIFEST_NAME}: {source_relative}")
684
+ if len(matches) > 1:
685
+ raise ManualPdfError(f"Vega visualization source is ambiguous in {VEGA_MANIFEST_NAME}: {source_relative}")
686
+ output_relative = matches[0]["output"]
687
+ output = safe_relative(project, output_relative, label="Vega visualization output")
688
+ if not output.is_file():
689
+ raise ManualPdfError(f"Missing rendered Vega visualization output: {output_relative}")
690
+ return output.relative_to(project).as_posix()
691
+
692
+
693
+ def localized_visual_source(
694
+ project: Path,
695
+ path: str,
696
+ *,
697
+ language: str = "",
698
+ default_language: str = "",
699
+ languages: list[str] | None = None,
700
+ ) -> str:
701
+ current = language.strip()
702
+ default = default_language.strip()
703
+ if not current or current == default:
704
+ return path
705
+ suffix = next((candidate for candidate in VISUAL_LOCALIZATION_SUFFIXES if path.lower().endswith(candidate)), "")
706
+ if not suffix:
707
+ return path
708
+ stem = path[:-len(suffix)]
709
+ configured = {str(value).strip().lower() for value in (languages or []) if str(value).strip()}
710
+ configured.update({current.lower(), default.lower()})
711
+ if any(stem.lower().endswith(f".{code}") for code in configured):
712
+ return path
713
+ localized = f"{stem}.{current}{suffix}"
714
+ candidate = (project.resolve() / localized.lstrip("/")).resolve()
715
+ try:
716
+ candidate.relative_to(project.resolve())
717
+ except ValueError:
718
+ return path
719
+ return localized if candidate.is_file() else path
720
+
721
+
722
+ def resolve_visual_source(
723
+ project: Path,
724
+ raw_path: str,
725
+ *,
726
+ language: str = "",
727
+ default_language: str = "",
728
+ languages: list[str] | None = None,
729
+ ) -> str:
730
+ undecorated, _ = split_url_decoration(raw_path)
731
+ if REMOTE_PATH_RE.match(undecorated):
732
+ if undecorated.lower().endswith(VEGA_SOURCE_SUFFIXES):
733
+ raise ManualPdfError(f"Vega visualization source must be a local project path: {undecorated}")
734
+ return raw_path
735
+ path = localized_visual_source(
736
+ project,
737
+ undecorated.lstrip("/"),
738
+ language=language,
739
+ default_language=default_language,
740
+ languages=languages,
741
+ )
742
+ vega_path = path.lstrip("/")
743
+ if vega_path.lower().endswith(VEGA_SOURCE_SUFFIXES):
744
+ return resolve_vega_source(project, vega_path)
745
+ path = path.lstrip("/")
746
+ if path.lower().endswith((".capture.yml", ".capture.yaml")):
747
+ source = safe_relative(project, path, label="web capture source", must_exist=True)
748
+ base = str(source).rsplit(".", 1)[0]
749
+ candidates = [Path(base + ".edited.svg"), Path(base + ".svg")]
750
+ for candidate in candidates:
751
+ if candidate.is_file():
752
+ validate_capture_svg(candidate)
753
+ return str(candidate.relative_to(project))
754
+ raise ManualPdfError(f"No printable SVG found for web capture source: {path}")
755
+ suffix = Path(path).suffix.lower()
756
+ if suffix in COMPUTATION_SUFFIXES:
757
+ source = safe_relative(project, path, label="computation figure source", must_exist=True)
758
+ front = computation_front_matter(source)
759
+ metadata = front.get("unaltraweb_compute")
760
+ if not isinstance(metadata, dict) or str(metadata.get("mode") or "").strip().lower() != "figure":
761
+ raise ManualPdfError(f"Computation source is not declared as unaltraweb_compute.mode: figure: {path}")
762
+ outputs = metadata.get("outputs")
763
+ if outputs is None and metadata.get("output"):
764
+ outputs = [metadata["output"]]
765
+ if not isinstance(outputs, list) or not outputs or not str(outputs[0] or "").strip():
766
+ raise ManualPdfError(f"Figure computation source declares no outputs: {path}")
767
+ output_raw = str(outputs[0]).strip()
768
+ output = safe_relative(project, output_raw, label="computed figure output")
769
+ if output.suffix.lower() not in GENERATED_MEDIA_SUFFIXES:
770
+ raise ManualPdfError(f"Unsupported computed figure output type for {path}: {output_raw}")
771
+ edited = output.with_suffix(".edited.svg")
772
+ if edited.is_file():
773
+ return str(edited.relative_to(project))
774
+ if output.is_file():
775
+ return str(output.relative_to(project))
776
+ raise ManualPdfError(
777
+ f"Missing rendered figure {output_raw} for computation source {path}. "
778
+ "Run `make manual-compute-render-figures` before building the PDF."
779
+ )
780
+ if suffix not in DIAGRAM_SUFFIXES:
781
+ return path
782
+ source = safe_relative(project, path, label="diagram source", must_exist=True)
783
+ candidates = [Path(str(source) + ".edited.svg"), Path(str(source) + ".svg")]
784
+ for candidate in candidates:
785
+ if candidate.is_file():
786
+ return str(candidate.relative_to(project))
787
+ raise ManualPdfError(f"No printable SVG found for diagram source: {path}")
788
+
789
+
790
+ def pandoc_image_attributes(raw: str) -> str:
791
+ source = raw.strip()
792
+ if not source:
793
+ return ""
794
+ source = source.removeprefix("{: ").removeprefix("{:").removesuffix("}").strip()
795
+ source = source.translate({0x2018: ord("'"), 0x2019: ord("'"), 0x201C: ord('"'), 0x201D: ord('"')})
796
+ dimensions: dict[str, str] = {}
797
+ while dimension_match := FIGURE_DIMENSION_ATTR_RE.search(source):
798
+ dimensions[dimension_match.group("name")] = next(
799
+ value for value in dimension_match.group("double", "single", "bare") if value is not None
800
+ ).strip()
801
+ source = f"{source[:dimension_match.start()]} {source[dimension_match.end():]}".strip()
802
+
803
+ width = dimensions.get("data-figure-width-pdf") or dimensions.get("data-figure-width") or ""
804
+ height = dimensions.get("data-figure-height-pdf") or dimensions.get("data-figure-height") or ""
805
+ rem_width = re.fullmatch(r"([0-9]+(?:\.[0-9]+)?)rem", width, re.IGNORECASE)
806
+ if rem_width:
807
+ percentage = min(100.0, float(rem_width.group(1)) / 60.0 * 100.0)
808
+ width = f"{percentage:.4f}".rstrip("0").rstrip(".") + "%"
809
+ rem_height = re.fullmatch(r"([0-9]+(?:\.[0-9]+)?)rem", height, re.IGNORECASE)
810
+ if rem_height:
811
+ height = f"{float(rem_height.group(1)) * 11.0:.4f}".rstrip("0").rstrip(".") + "pt"
812
+
813
+ attributes = [
814
+ value
815
+ for value in (
816
+ source,
817
+ f"width={width}" if width else "",
818
+ f"height={height}" if height else "",
819
+ )
820
+ if value
821
+ ]
822
+ return "{" + " ".join(attributes) + "}" if attributes else ""
823
+
824
+
825
+ def latex_escape(value: str) -> str:
826
+ replacements = {
827
+ "\\": r"\textbackslash{}",
828
+ "{": r"\{",
829
+ "}": r"\}",
830
+ "$": r"\$",
831
+ "&": r"\&",
832
+ "#": r"\#",
833
+ "_": r"\_",
834
+ "%": r"\%",
835
+ "~": r"\textasciitilde{}",
836
+ "^": r"\textasciicircum{}",
837
+ }
838
+ return "".join(replacements.get(character, character) for character in value)
839
+
840
+
841
+ def transform_markdown(
842
+ project: Path,
843
+ text: str,
844
+ source: Path,
845
+ language: str = "en",
846
+ config: dict[str, Any] | None = None,
847
+ citation_keys: list[str] | None = None,
848
+ ) -> str:
849
+ callout_labels, _ = resolve_callout_labels(project, config or {}, language)
850
+ visual_default_language = str((config or {}).get("default_lang") or (config or {}).get("lang") or language)
851
+ configured_visual_languages = (config or {}).get("languages")
852
+ if not isinstance(configured_visual_languages, list):
853
+ configured_visual_languages = [visual_default_language]
854
+ token_prefix = "UNALTRAWEBMANUALPROTECTED"
855
+ while token_prefix in text:
856
+ token_prefix += "X"
857
+ protected: list[str] = []
858
+
859
+ def protect(value: str) -> str:
860
+ token = f"{token_prefix}_{len(protected)}_"
861
+ protected.append(value)
862
+ return token
863
+
864
+ protected_token_re = re.compile(rf"{re.escape(token_prefix)}_(\d+)_")
865
+
866
+ def latex_caption(value: str) -> str:
867
+ chunks: list[str] = []
868
+ position = 0
869
+ for token in protected_token_re.finditer(value):
870
+ chunks.append(latex_escape(value[position:token.start()]))
871
+ protected_value = protected[int(token.group(1))]
872
+ inline_match = MARKDOWN_INLINE_CODE_RE.fullmatch(protected_value)
873
+ if inline_match:
874
+ fence_length = len(inline_match.group(1))
875
+ code = protected_value[fence_length:-fence_length]
876
+ chunks.append(r"\texttt{" + latex_escape(code) + "}")
877
+ else:
878
+ chunks.append(latex_escape(protected_value))
879
+ position = token.end()
880
+ chunks.append(latex_escape(value[position:]))
881
+ return "".join(chunks)
882
+
883
+ def citations(match: re.Match[str]) -> str:
884
+ keys = [key.lstrip("@").strip() for key in match.group(1).split() if key.strip()]
885
+ if citation_keys is not None:
886
+ citation_keys.extend(key for key in keys if key not in citation_keys)
887
+ return "[" + "; ".join(f"@{key}" for key in keys) + "]"
888
+
889
+ def table(match: re.Match[str]) -> str:
890
+ body = match.group(2).strip()
891
+ rows = [
892
+ re.sub(r"\s+", " ", line.strip().strip("|"))
893
+ for line in body.splitlines()
894
+ if "|" in line and not PIPE_TABLE_SEPARATOR_RE.fullmatch(line)
895
+ ]
896
+ estimated_lines = 2 + sum(max(1, (len(row) + 71) // 72) for row in rows)
897
+ required_baselines = max(10, round(estimated_lines * 1.25) + 2)
898
+ page_guard = r"\clearpage" if required_baselines >= 40 else f"\\Needspace{{{required_baselines}\\baselineskip}}"
899
+ return (
900
+ f"```{{=latex}}\n{page_guard}\n```\n\n"
901
+ f"Table: {match.group(1).strip()}\n\n{body}"
902
+ )
903
+
904
+ def listing(match: re.Match[str]) -> str:
905
+ token_match = protected_token_re.fullmatch(match.group("body").strip())
906
+ if not token_match:
907
+ raise ManualPdfError(
908
+ f"Listing block must contain exactly one fenced code block in {source.relative_to(project)}"
909
+ )
910
+
911
+ body = protected[int(token_match.group(1))]
912
+ opening_end = body.find("\n")
913
+ opening = body[:opening_end]
914
+ opening_match = re.fullmatch(r"(?P<indent>[ \t]*)(?P<fence>`{3,}|~{3,})(?P<info>[^\n]*)", opening)
915
+ if not opening_match: # pragma: no cover - guarded by FENCED_CODE_BLOCK_RE
916
+ raise ManualPdfError(f"Malformed listing fence in {source.relative_to(project)}")
917
+
918
+ caption = match.group("caption").replace("\\", "\\\\").replace('"', '\\"')
919
+ info = opening_match.group("info").strip()
920
+ if info.startswith("{") and info.endswith("}"):
921
+ attributes = info[:-1].rstrip()
922
+ attributes += f' data-listing-caption="{caption}"}}'
923
+ else:
924
+ language_class = f".{info} " if info else ""
925
+ attributes = f'{{{language_class}data-listing-caption="{caption}"}}'
926
+ rewritten = (
927
+ f"{opening_match.group('indent')}{opening_match.group('fence')}{attributes}"
928
+ f"{body[opening_end:]}"
929
+ )
930
+ return protect(rewritten)
931
+
932
+ def inline_code(match: re.Match[str]) -> str:
933
+ value = html.unescape(match.group(1))
934
+ longest_fence = max((len(run) for run in re.findall(r"`+", value)), default=0) + 1
935
+ fence = "`" * longest_fence
936
+ padding = " " if value.startswith("`") or value.endswith("`") else ""
937
+ return f"{fence}{padding}{value}{padding}{fence}"
938
+
939
+ def table_guard_before_heading(match: re.Match[str]) -> str:
940
+ guard = match.group("guard")
941
+ if match.group("baselines"):
942
+ guard = f"\\Needspace{{{int(match.group('baselines')) + 6}\\baselineskip}}"
943
+ return f"```{{=latex}}\n{guard}\n```\n\n{match.group('heading')}"
944
+
945
+ def display_math(match: re.Match[str]) -> str:
946
+ body = match.group("body").rstrip("\r\n")
947
+ return f"\\begin{{equation}}\n{body}\n\\end{{equation}}"
948
+
949
+ def image(match: re.Match[str]) -> str:
950
+ alt = match.group("alt")
951
+ raw_path = match.group("path")
952
+ title = match.group("double_title") or match.group("single_title")
953
+ printable = resolve_visual_source(
954
+ project,
955
+ raw_path,
956
+ language=language,
957
+ default_language=visual_default_language,
958
+ languages=[str(value) for value in configured_visual_languages],
959
+ )
960
+ caption = title or alt
961
+ attributes = pandoc_image_attributes(match.group("attrs") or "")
962
+ return f"![{caption}]({printable}){attributes}"
963
+
964
+ def callout(match: re.Match[str]) -> str:
965
+ marks = match.group("marks")
966
+ depth = min(len(marks), max(CALLOUT_STYLES))
967
+ color, callout_type = CALLOUT_STYLES[depth]
968
+ body = "\n".join(line[len(marks):].lstrip(" \t") for line in match.group("block").splitlines()).strip()
969
+ if not body:
970
+ raise ManualPdfError(f"Empty callout in {source.relative_to(project)}")
971
+ estimated_lines = 2 + sum(
972
+ max(1, (len(line.strip()) + 71) // 72)
973
+ for line in body.splitlines()
974
+ if line.strip()
975
+ )
976
+ options = "[enhanced,breakable]" if estimated_lines >= 40 else ""
977
+ label = latex_escape(callout_labels[callout_type])
978
+ return (
979
+ "```{=latex}\n"
980
+ f"\\begin{{manualcallout}}{options}{{{color}}}{{{label}}}\n"
981
+ "```\n\n"
982
+ f"{body}\n\n"
983
+ "```{=latex}\n"
984
+ "\\end{manualcallout}\n"
985
+ "```"
986
+ )
987
+
988
+ def subfigures(match: re.Match[str]) -> str:
989
+ def raw_latex_inline(value: str) -> str:
990
+ fence = "`" * (max((len(run) for run in re.findall(r"`+", value)), default=0) + 1)
991
+ padding = " " if value.startswith("`") or value.endswith("`") else ""
992
+ return f"{fence}{padding}{value}{padding}{fence}{{=latex}}"
993
+
994
+ images = list(IMAGE_RE.finditer(match.group("body")))
995
+ if not images:
996
+ raise ManualPdfError(f"Subfigures block contains no images in {source.relative_to(project)}")
997
+
998
+ layout = match.group("layout") or ""
999
+ row_sizes = [len([token for token in row.split("+") if token.strip()]) for row in layout.split("/") if row.strip()]
1000
+ if not row_sizes:
1001
+ row_sizes = [min(2, len(images))]
1002
+ while sum(row_sizes) < len(images):
1003
+ row_sizes.append(min(2, len(images) - sum(row_sizes)))
1004
+ if sum(row_sizes) != len(images):
1005
+ raise ManualPdfError(
1006
+ f"Subfigures layout '{layout}' declares {sum(row_sizes)} panels but contains {len(images)} images "
1007
+ f"in {source.relative_to(project)}"
1008
+ )
1009
+
1010
+ overall_caption = latex_caption(match.group("caption") or "")
1011
+ figure_start = "```{=latex}\n\\begin{figure}[H]\n\\centering\n"
1012
+ if overall_caption:
1013
+ figure_start += (
1014
+ f"\\caption{{{overall_caption}}}\n"
1015
+ "{\\color{ManualMuted!45}\\rule{\\linewidth}{0.35pt}}\\par\\medskip\n"
1016
+ )
1017
+ rendered = [figure_start + "```"]
1018
+ image_index = 0
1019
+ max_image_height = f"{0.52 / len(row_sizes):.3f}".rstrip("0").rstrip(".")
1020
+ for row_index, row_size in enumerate(row_sizes):
1021
+ panel_width = {1: "0.92", 2: "0.48", 3: "0.31", 4: "0.23"}.get(row_size, f"{0.92 / row_size:.3f}")
1022
+ row: list[str] = []
1023
+ for column_index in range(row_size):
1024
+ item = images[image_index]
1025
+ image_index += 1
1026
+ caption = item.group("double_title") or item.group("single_title") or item.group("alt")
1027
+ printable = resolve_visual_source(
1028
+ project,
1029
+ item.group("path"),
1030
+ language=language,
1031
+ default_language=visual_default_language,
1032
+ languages=[str(value) for value in configured_visual_languages],
1033
+ )
1034
+ attributes = pandoc_image_attributes(item.group("attrs") or "")
1035
+ row.extend([
1036
+ raw_latex_inline(
1037
+ f"\\begin{{subfigure}}[t]{{{panel_width}\\linewidth}}"
1038
+ "\\centering"
1039
+ f"\\def\\maxheight{{{max_image_height}\\textheight}}"
1040
+ ),
1041
+ f"![]({printable}){attributes}",
1042
+ ])
1043
+ separator = r"\hfill" if column_index < row_size - 1 else ""
1044
+ row.append(raw_latex_inline(
1045
+ f"\\caption{{{latex_caption(caption)}}}"
1046
+ f"\\end{{subfigure}}{separator}"
1047
+ ))
1048
+ rendered.append("".join(row))
1049
+ if row_index < len(row_sizes) - 1:
1050
+ rendered.append("```{=latex}\n\\par\\medskip\n```")
1051
+
1052
+ rendered.append("```{=latex}\n\\end{figure}\n```")
1053
+ return "\n\n".join(rendered)
1054
+
1055
+ transformed = FENCED_CODE_BLOCK_RE.sub(lambda match: protect(match.group(0)), text)
1056
+ transformed = LISTING_DIV_RE.sub(listing, transformed)
1057
+ transformed = MARKDOWN_INLINE_CODE_RE.sub(lambda match: protect(match.group(0)), transformed)
1058
+ transformed = INLINE_CODE_RE.sub(lambda match: protect(inline_code(match)), transformed)
1059
+ transformed = DISPLAY_MATH_BLOCK_RE.sub(display_math, transformed)
1060
+ transformed = transformed.replace(r"\begin{equation\*}", r"\begin{equation*}")
1061
+ transformed = transformed.replace(r"\end{equation\*}", r"\end{equation*}")
1062
+ transformed = BASEURL_RE.sub("", transformed)
1063
+ transformed = transformed.replace("{% include manual-bibliography.liquid %}", "::: {#refs}\n:::")
1064
+ transformed = CITE_RE.sub(citations, transformed)
1065
+ transformed = CALLOUT_BLOCK_RE.sub(callout, transformed)
1066
+ transformed = TABLE_DIV_RE.sub(table, transformed)
1067
+ transformed = TABLE_GUARD_AFTER_HEADING_RE.sub(table_guard_before_heading, transformed)
1068
+ transformed = SUBFIGURES_DIV_RE.sub(subfigures, transformed)
1069
+ transformed = IMAGE_RE.sub(image, transformed)
1070
+ if re.search(r"^:::\s*subfigures\b", transformed, re.MULTILINE):
1071
+ raise ManualPdfError(f"Malformed subfigures block in {source.relative_to(project)}")
1072
+ if re.search(r"^:::\s*listing\b", transformed, re.MULTILINE):
1073
+ raise ManualPdfError(f"Malformed listing block in {source.relative_to(project)}")
1074
+ unknown_includes = [item for item in INCLUDE_RE.findall(transformed) if item.strip()]
1075
+ unknown_liquid = [item for item in LIQUID_RE.findall(transformed) if item.strip()]
1076
+ if unknown_includes or unknown_liquid:
1077
+ token = (unknown_includes or unknown_liquid)[0]
1078
+ raise ManualPdfError(f"Unsupported Liquid in {source.relative_to(project)}: {token}")
1079
+ for index in range(len(protected) - 1, -1, -1):
1080
+ transformed = transformed.replace(f"{token_prefix}_{index}_", protected[index])
1081
+ return transformed.strip()
1082
+
1083
+
1084
+ def localized(value: Any, lang: str) -> str:
1085
+ if isinstance(value, dict):
1086
+ return str(value.get(lang) or value.get("default") or next(iter(value.values()), ""))
1087
+ return str(value or "")
1088
+
1089
+
1090
+ def build_metadata(project: Path, config: dict[str, Any], lang: str, source_lang: str, home_front: dict[str, Any], chapters: list[tuple[Path, dict[str, Any], str]]) -> dict[str, Any]:
1091
+ manual = nested(config, "unaltraweb", "manual")
1092
+ pdf = manual.get("pdf") if isinstance(manual.get("pdf"), dict) else {}
1093
+ metadata = manual.get("metadata") if isinstance(manual.get("metadata"), dict) else {}
1094
+ cover = pdf.get("cover") if isinstance(pdf.get("cover"), dict) else {}
1095
+ author = config.get("author") if isinstance(config.get("author"), dict) else {}
1096
+ instructors_raw = metadata.get("instructors")
1097
+ if isinstance(instructors_raw, list):
1098
+ instructors = [str(item.get("name") if isinstance(item, dict) else item) for item in instructors_raw]
1099
+ else:
1100
+ instructors = [localized(author.get(f"name_{source_lang}") or author.get("name"), source_lang)]
1101
+ instructors = [item for item in instructors if item]
1102
+ guides_raw = metadata.get("teaching_guides")
1103
+ teaching_guides: list[dict[str, str]] = []
1104
+ if isinstance(guides_raw, list):
1105
+ for item in guides_raw:
1106
+ if not isinstance(item, dict):
1107
+ continue
1108
+ degree = localized(item.get("degree"), source_lang)
1109
+ if degree:
1110
+ teaching_guides.append({"degree": degree, "subject-code": str(item.get("subject_code") or "")})
1111
+ if not teaching_guides:
1112
+ degree = localized(metadata.get("degree"), source_lang)
1113
+ if degree:
1114
+ teaching_guides.append({"degree": degree, "subject-code": str(metadata.get("subject_code") or "")})
1115
+ metadata_labels = METADATA_LABELS.get(source_lang, METADATA_LABELS["en"])
1116
+ unaltraweb = nested(config, "unaltraweb")
1117
+ status_field = str(unaltraweb.get("content_status_field") or "content_status")
1118
+ approved_status = str(unaltraweb.get("approved_status") or "approved")
1119
+ statuses = {str(front.get(status_field) or "") for _, front, _ in chapters}
1120
+ if home_front:
1121
+ statuses.add(str(home_front.get(status_field) or ""))
1122
+ draft = any(status != approved_status for status in statuses)
1123
+
1124
+ def asset(raw: Any) -> str:
1125
+ if not raw:
1126
+ return ""
1127
+ return str(safe_relative(project, str(raw), label="cover asset", must_exist=True).relative_to(project))
1128
+
1129
+ title = localized(metadata.get("title"), source_lang) or localized(home_front.get("title"), source_lang) or str(config.get("title") or "Manual")
1130
+ copyright_holder = str(config.get("copyright_holder") or "").strip()
1131
+ release = release_metadata()
1132
+ return {
1133
+ "title": title,
1134
+ "short-title": localized(metadata.get("short_title"), source_lang) or str(config.get("short_title") or title),
1135
+ "description": localized(metadata.get("description"), source_lang) or localized(home_front.get("description"), source_lang) or str(config.get("description") or ""),
1136
+ "author": ", ".join(instructors),
1137
+ "instructors": instructors,
1138
+ "series": localized(metadata.get("series"), source_lang) or "unaltremanual",
1139
+ "series-subtitle": localized(metadata.get("series_subtitle"), source_lang),
1140
+ "publisher": localized(metadata.get("publisher"), source_lang),
1141
+ "edition": localized(metadata.get("edition"), source_lang),
1142
+ "publication-date": localized(metadata.get("publication_date"), source_lang),
1143
+ **release,
1144
+ "identifier": localized(metadata.get("identifier"), source_lang),
1145
+ "license": localized(metadata.get("license"), source_lang),
1146
+ "source": localized(metadata.get("source"), source_lang),
1147
+ "subject": localized(metadata.get("subject"), source_lang),
1148
+ "subject-code": str(metadata.get("subject_code") or ""),
1149
+ "degree": localized(metadata.get("degree"), source_lang),
1150
+ "teaching-guides": teaching_guides,
1151
+ "department": localized(metadata.get("department"), source_lang),
1152
+ "faculty": localized(metadata.get("faculty"), source_lang),
1153
+ "institution": localized(metadata.get("institution"), source_lang) or localized(author.get(f"affiliation_{source_lang}") or author.get("affiliation"), source_lang),
1154
+ "location": localized(metadata.get("location"), source_lang) or localized(author.get(f"location_{source_lang}") or author.get("location"), source_lang),
1155
+ "academic-year": str(metadata.get("academic_year") or ""),
1156
+ "revision-date": localized(metadata.get("revision_date"), source_lang),
1157
+ "rights": localized(metadata.get("rights"), source_lang) or (f"© {copyright_holder}" if copyright_holder else ""),
1158
+ "metadata-page-title": metadata_labels["title"],
1159
+ "metadata-series-label": metadata_labels["series"],
1160
+ "metadata-publisher-label": metadata_labels["publisher"],
1161
+ "metadata-edition-label": metadata_labels["edition"],
1162
+ "metadata-publication-date-label": metadata_labels["publication_date"],
1163
+ "metadata-release-channel-label": metadata_labels["release_channel"],
1164
+ "metadata-release-version-label": metadata_labels["release_version"],
1165
+ "metadata-subject-label": metadata_labels["subject"],
1166
+ "metadata-teaching-guides-label": metadata_labels["teaching_guides"],
1167
+ "metadata-academic-year-label": metadata_labels["academic_year"],
1168
+ "metadata-department-label": metadata_labels["department"],
1169
+ "metadata-faculty-label": metadata_labels["faculty"],
1170
+ "metadata-institution-label": metadata_labels["institution"],
1171
+ "metadata-location-label": metadata_labels["location"],
1172
+ "metadata-revision-date-label": metadata_labels["revision_date"],
1173
+ "metadata-instructors-label": metadata_labels["instructors"],
1174
+ "metadata-identifier-label": metadata_labels["identifier"],
1175
+ "metadata-license-label": metadata_labels["license"],
1176
+ "metadata-source-label": metadata_labels["source"],
1177
+ "metadata-rights-label": metadata_labels["rights"],
1178
+ "chapter-references-title": metadata_labels["references"],
1179
+ "listing-label": metadata_labels["listing"],
1180
+ "list-of-figures-title": metadata_labels["list_of_figures"],
1181
+ "list-of-tables-title": metadata_labels["list_of_tables"],
1182
+ "list-of-listings-title": metadata_labels["list_of_listings"],
1183
+ "lang": lang,
1184
+ "babel-lang": LANGUAGE_NAMES.get(source_lang, "english"),
1185
+ "toc": bool(pdf.get("toc", True)),
1186
+ "nocite": "@*" if manual.get("bibliography", True) and bool(pdf.get("include_bibliography", True)) else "",
1187
+ "link-citations": bool(pdf.get("link_citations", True)),
1188
+ "draft": draft and bool(pdf.get("mark_drafts", True)),
1189
+ "draft-label": localized(pdf.get("draft_label"), source_lang) or {"ca": "ESBORRANY", "es": "BORRADOR", "en": "DRAFT"}.get(source_lang, "DRAFT"),
1190
+ "draft-description": localized(pdf.get("draft_description"), source_lang) or {"ca": "Material en revisió. No és una versió final.", "es": "Material en revisión. No es una versión final.", "en": "Material under review. This is not a final version."}.get(source_lang, "Material under review."),
1191
+ "primary-color": str(cover.get("primary_color") or "990000").lstrip("#"),
1192
+ "band-color": str(cover.get("band_color") or cover.get("primary_color") or "990000").lstrip("#"),
1193
+ "secondary-color": str(cover.get("secondary_color") or "003366").lstrip("#"),
1194
+ "muted-color": str(cover.get("muted_color") or "666666").lstrip("#"),
1195
+ "internal-link-color": str(pdf.get("internal_link_color") or cover.get("secondary_color") or "003366").lstrip("#"),
1196
+ "external-link-color": str(pdf.get("external_link_color") or cover.get("primary_color") or "990000").lstrip("#"),
1197
+ "citation-link-color": str(pdf.get("citation_link_color") or "C2185B").lstrip("#"),
1198
+ "inline-code-color": str(pdf.get("inline_code_color") or "5A1F5F").lstrip("#"),
1199
+ "cover-image": asset(cover.get("image")),
1200
+ "cover-logo": asset(cover.get("institution_logo")),
1201
+ "series-logo": asset(cover.get("series_logo")),
1202
+ }
1203
+
1204
+
1205
+ def assemble(project: Path, config: dict[str, Any], lang: str, paths: dict[str, Path]) -> tuple[dict[str, Any], list[Path], str]:
1206
+ manual = nested(config, "unaltraweb", "manual")
1207
+ pdf = manual.get("pdf") if isinstance(manual.get("pdf"), dict) else {}
1208
+ home, chapters, source_lang = manual_sources(project, config, lang)
1209
+ home_front: dict[str, Any] = {}
1210
+ chunks: list[str] = []
1211
+ _, callout_label_sources = resolve_callout_labels(project, config, source_lang)
1212
+ source_paths: list[Path] = list(callout_label_sources)
1213
+ includes_home = bool(home and pdf.get("include_home", True))
1214
+
1215
+ if includes_home:
1216
+ assert home is not None
1217
+ home_front, body = read_source(home)
1218
+ body = transform_markdown(project, body, home, source_lang, config)
1219
+ home_title = localized(pdf.get("home_title"), source_lang) or {"ca": "Inici i presentació del curs", "es": "Inicio y presentación del curso", "en": "Course introduction"}.get(source_lang, "Introduction")
1220
+ first_heading = re.compile(rf"^##\s+{re.escape(home_title)}\s*$", re.MULTILINE)
1221
+ body = first_heading.sub("", body, count=1).strip()
1222
+ chunks.append(f"# {home_title}\n\n{body}")
1223
+ source_paths.append(home)
1224
+
1225
+ included_chapters: list[tuple[Path, dict[str, Any], str]] = []
1226
+ for path, front, body in chapters:
1227
+ is_bibliography = front.get("manual_bibliography") is True or str(front.get("ref") or "") == "manual-bibliography"
1228
+ if is_bibliography and not bool(pdf.get("include_bibliography", True)):
1229
+ continue
1230
+ title = str(front.get("title") or path.stem)
1231
+ heading = f"# {title}"
1232
+ if front.get("manual_numbered") is False:
1233
+ heading += " {-}"
1234
+ citation_keys: list[str] = []
1235
+ transformed_body = transform_markdown(project, body, path, source_lang, config, citation_keys)
1236
+ references_requested = bool(front.get("related_publications") or front.get("manual_references") or front.get("references"))
1237
+ if citation_keys and references_requested and manual.get("bibliography", True) is not False:
1238
+ marker = html.escape(",".join(citation_keys), quote=True)
1239
+ transformed_body += f'\n\n::: {{.manual-chapter-citations data-citations="{marker}"}}\n:::'
1240
+ chunks.append(f"{heading}\n\n{transformed_body}")
1241
+ included_chapters.append((path, front, body))
1242
+ source_paths.append(path)
1243
+
1244
+ assembled_markdown = "\n\n\\newpage\n\n".join(chunks) + "\n"
1245
+ metadata = build_metadata(project, config, lang, source_lang, home_front, included_chapters)
1246
+ metadata["include-home"] = includes_home
1247
+ metadata["has-listings"] = "data-listing-caption=" in assembled_markdown
1248
+ prose_markdown = FENCED_CODE_BLOCK_RE.sub("", assembled_markdown)
1249
+ metadata["has-figures"] = bool(IMAGE_RE.search(prose_markdown) or r"\begin{figure}" in prose_markdown)
1250
+ metadata["has-tables"] = bool(re.search(r"^Table:\s+\S", prose_markdown, re.MULTILINE))
1251
+ return metadata, source_paths, assembled_markdown
1252
+
1253
+
1254
+ def bibliography_source(project: Path, config: dict[str, Any]) -> Path | None:
1255
+ manual = nested(config, "unaltraweb", "manual")
1256
+ if manual.get("bibliography", True) is False:
1257
+ return None
1258
+ filename = str(manual.get("bibliography_file") or "manual.bib").strip()
1259
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*\.bib", filename) or Path(filename).name != filename:
1260
+ raise ManualPdfError("unaltraweb.manual.bibliography_file must be a .bib filename under _bibliography/.")
1261
+ return safe_relative(project, f"_bibliography/{filename}", label="bibliography", must_exist=True)
1262
+
1263
+
1264
+ def clean_bibliography(source: Path | None, destination: Path) -> None:
1265
+ if source is None:
1266
+ return
1267
+ text = source.read_text(encoding="utf-8")
1268
+ text = FRONT_MATTER_RE.sub("", text, count=1)
1269
+ destination.write_text(text, encoding="utf-8")
1270
+
1271
+
1272
+ def bibliography_custom_urls(text: str) -> dict[str, list[str]]:
1273
+ matches = list(BIB_ENTRY_HEADER_RE.finditer(text))
1274
+ urls: dict[str, list[str]] = {}
1275
+ for index, match in enumerate(matches):
1276
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
1277
+ values = [(custom.group(1) or custom.group(2)).strip() for custom in BIB_CUSTOM_URL_RE.finditer(text, match.end(), end)]
1278
+ if values:
1279
+ urls[match.group(1)] = list(dict.fromkeys(values))
1280
+ return urls
1281
+
1282
+
1283
+ def fold_bibliography_value(value: Any) -> str:
1284
+ decomposed = unicodedata.normalize("NFKD", str(value or "").casefold())
1285
+ return " ".join("".join(character for character in decomposed if not unicodedata.combining(character)).split())
1286
+
1287
+
1288
+ def contributor_sort_value(record: dict[str, Any]) -> str:
1289
+ contributors: Any = []
1290
+ for field in ("author", "editor", "collection-editor", "translator"):
1291
+ if isinstance(record.get(field), list) and record[field]:
1292
+ contributors = record[field]
1293
+ break
1294
+ values: list[str] = []
1295
+ for contributor in contributors:
1296
+ if not isinstance(contributor, dict):
1297
+ continue
1298
+ literal = str(contributor.get("literal") or "").strip()
1299
+ if literal:
1300
+ values.append(literal)
1301
+ continue
1302
+ family = str(contributor.get("family") or "").strip()
1303
+ given = str(contributor.get("given") or "").strip()
1304
+ values.append(", ".join(part for part in (family, given) if part))
1305
+ if values:
1306
+ return "; ".join(values)
1307
+ return str(record.get("publisher") or record.get("container-title") or record.get("title") or "")
1308
+
1309
+
1310
+ def normalize_doi(value: Any) -> str:
1311
+ return re.sub(r"(?i)^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", str(value or "").strip()).rstrip("/")
1312
+
1313
+
1314
+ def bibliography_filter_metadata(records: Any, custom_urls: dict[str, list[str]]) -> dict[str, dict[str, Any]]:
1315
+ if not isinstance(records, list):
1316
+ raise ManualPdfError("Pandoc returned invalid CSL bibliography data.")
1317
+ sort_keys: dict[str, str] = {}
1318
+ access: dict[str, dict[str, str]] = {}
1319
+ for record in records:
1320
+ if not isinstance(record, dict) or not str(record.get("id") or "").strip():
1321
+ continue
1322
+ key = str(record["id"]).strip()
1323
+ issued = record.get("issued") if isinstance(record.get("issued"), dict) else {}
1324
+ date_parts = issued.get("date-parts") if isinstance(issued, dict) else []
1325
+ year = ""
1326
+ if isinstance(date_parts, list) and date_parts and isinstance(date_parts[0], list) and date_parts[0]:
1327
+ year = str(date_parts[0][0])
1328
+ sort_keys[key] = " | ".join(
1329
+ fold_bibliography_value(value)
1330
+ for value in (contributor_sort_value(record), year, record.get("title"), key)
1331
+ )
1332
+ doi = normalize_doi(record.get("DOI") or record.get("doi"))
1333
+ standard_url = str(record.get("URL") or record.get("url") or "").strip()
1334
+ urls = list(dict.fromkeys(url for url in [standard_url, *custom_urls.get(key, [])] if url))
1335
+ normalized_doi = doi.casefold()
1336
+ if normalized_doi:
1337
+ urls = [
1338
+ url
1339
+ for url in urls
1340
+ if re.sub(r"(?i)^https?://(?:dx\.)?doi\.org/", "", url).rstrip("/").casefold() != normalized_doi
1341
+ ]
1342
+ if doi or urls:
1343
+ access[key] = {"doi": doi, "urls": urls}
1344
+ return {"bibliography-sort-keys": sort_keys, "bibliography-access": access}
1345
+
1346
+
1347
+ def extract_bibliography_metadata(source: Path, destination: Path, project: Path) -> dict[str, dict[str, Any]]:
1348
+ csl_json = destination.with_suffix(".json")
1349
+ run_command(
1350
+ [
1351
+ "pandoc",
1352
+ str(destination),
1353
+ "--from=biblatex",
1354
+ "--to=csljson",
1355
+ f"--output={csl_json}",
1356
+ ],
1357
+ project,
1358
+ )
1359
+ try:
1360
+ records = json.loads(csl_json.read_text(encoding="utf-8"))
1361
+ except (OSError, json.JSONDecodeError) as exc:
1362
+ raise ManualPdfError(f"Could not read Pandoc CSL bibliography data: {exc}") from exc
1363
+ metadata = bibliography_filter_metadata(records, bibliography_custom_urls(source.read_text(encoding="utf-8")))
1364
+ for record in records:
1365
+ if not isinstance(record, dict):
1366
+ continue
1367
+ doi = normalize_doi(record.get("DOI") or record.get("doi"))
1368
+ if doi:
1369
+ record["DOI"] = doi
1370
+ record.pop("doi", None)
1371
+ url = str(record.get("URL") or record.get("url") or "").strip()
1372
+ if normalize_doi(url).casefold() == doi.casefold() and re.match(r"(?i)^https?://(?:dx\.)?doi\.org/", url):
1373
+ record.pop("URL", None)
1374
+ record.pop("url", None)
1375
+ csl_json.write_text(json.dumps(records, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
1376
+ return metadata
1377
+
1378
+
1379
+ def configured_template(project: Path, config: dict[str, Any]) -> Path:
1380
+ pdf = nested(config, "unaltraweb", "manual", "pdf")
1381
+ template_raw = str(pdf.get("template") or "").strip()
1382
+ return safe_relative(project, template_raw, label="PDF template", must_exist=True) if template_raw else DEFAULT_TEMPLATE
1383
+
1384
+
1385
+ def configured_csl(project: Path, config: dict[str, Any], bibliography: Path | None) -> Path | None:
1386
+ if bibliography is None:
1387
+ return None
1388
+ pdf = nested(config, "unaltraweb", "manual", "pdf")
1389
+ csl_raw = str(pdf.get("csl") or "").strip()
1390
+ return safe_relative(project, csl_raw, label="CSL file", must_exist=True) if csl_raw else None
1391
+
1392
+
1393
+ def build_dependencies(project: Path, metadata: dict[str, Any], source_paths: list[Path], markdown: str, template: Path, csl: Path | None) -> list[tuple[str, Path]]:
1394
+ dependencies: list[tuple[str, Path]] = [
1395
+ ("config:_config.yml", project / "_config.yml"),
1396
+ ("builder:build_pdf.py", Path(__file__).resolve()),
1397
+ ("toolchain:Dockerfile", DEFAULT_DOCKERFILE),
1398
+ (f"template:{template.name}", template),
1399
+ ("filter:bibliography.lua", DEFAULT_BIBLIOGRAPHY_FILTER),
1400
+ ("filter:code-blocks.lua", DEFAULT_CODE_BLOCK_FILTER),
1401
+ ("filter:figure-captions.lua", DEFAULT_FIGURE_FILTER),
1402
+ ]
1403
+ for path in source_paths:
1404
+ dependencies.append((f"source:{path.relative_to(project)}", path))
1405
+ if csl:
1406
+ dependencies.append((f"csl:{csl.relative_to(project)}", csl))
1407
+ for key in ["cover-image", "cover-logo", "series-logo"]:
1408
+ if metadata.get(key):
1409
+ path = safe_relative(project, str(metadata[key]), label=key, must_exist=True)
1410
+ dependencies.append((f"asset:{path.relative_to(project)}", path))
1411
+ dependency_markdown = FENCED_CODE_BLOCK_RE.sub("", markdown)
1412
+ dependency_markdown = MARKDOWN_INLINE_CODE_RE.sub("", dependency_markdown)
1413
+ for match in IMAGE_RE.finditer(dependency_markdown):
1414
+ raw = match.group("path")
1415
+ if raw.startswith(("http://", "https://", "data:", "#")):
1416
+ continue
1417
+ local_path, _ = split_url_decoration(raw)
1418
+ path = safe_relative(project, local_path, label="manual image", must_exist=True)
1419
+ dependencies.append((f"asset:{path.relative_to(project)}", path))
1420
+ unique: dict[str, Path] = {}
1421
+ for label, path in dependencies:
1422
+ unique[label] = path
1423
+ return sorted(unique.items())
1424
+
1425
+
1426
+ def dependency_fingerprint(dependencies: list[tuple[str, Path]], release: dict[str, str]) -> str:
1427
+ digest = hashlib.sha256()
1428
+ for label, path in dependencies:
1429
+ digest.update(label.encode("utf-8"))
1430
+ digest.update(b"\0")
1431
+ digest.update(path.read_bytes())
1432
+ digest.update(b"\0")
1433
+ digest.update(b"release-metadata\0")
1434
+ digest.update(json.dumps(release, sort_keys=True, separators=(",", ":")).encode("utf-8"))
1435
+ digest.update(b"\0")
1436
+ return digest.hexdigest()
1437
+
1438
+
1439
+ def file_signature(path: Path) -> dict[str, Any]:
1440
+ return {"sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "size": path.stat().st_size}
1441
+
1442
+
1443
+ def prepare_build(project: Path, config: dict[str, Any], lang: str) -> tuple[dict[str, Any], list[Path], str, Path, Path | None, Path | None, list[tuple[str, Path]], str]:
1444
+ metadata, source_paths, markdown = assemble(project, config, lang, artifact_paths(project, config, lang))
1445
+ bibliography = bibliography_source(project, config)
1446
+ if bibliography:
1447
+ source_paths.append(bibliography)
1448
+ template = configured_template(project, config)
1449
+ csl = configured_csl(project, config, bibliography)
1450
+ dependencies = build_dependencies(project, metadata, source_paths, markdown, template, csl)
1451
+ fingerprint = dependency_fingerprint(dependencies, {
1452
+ "release_channel": str(metadata["release-channel"]),
1453
+ "release_selector": str(metadata["release-selector"]),
1454
+ })
1455
+ metadata["trailer-id"] = fingerprint[:32]
1456
+ return metadata, source_paths, markdown, template, bibliography, csl, dependencies, fingerprint
1457
+
1458
+
1459
+ def paths_in_build_dir(paths: dict[str, Path], build_dir: Path) -> dict[str, Path]:
1460
+ staged = dict(paths)
1461
+ staged["build_dir"] = build_dir
1462
+ for key in ["pdf", "cover", "source", "metadata", "bibliography", "manifest"]:
1463
+ staged[key] = build_dir / paths[key].name
1464
+ return staged
1465
+
1466
+
1467
+ def run_command(command: list[str], project: Path) -> None:
1468
+ environment = os.environ.copy()
1469
+ environment.update({"SOURCE_DATE_EPOCH": REPRODUCIBLE_BUILD_EPOCH, "FORCE_SOURCE_DATE": "1", "TZ": "UTC"})
1470
+ completed = subprocess.run(
1471
+ command,
1472
+ cwd=project,
1473
+ env=environment,
1474
+ text=True,
1475
+ stdout=subprocess.PIPE,
1476
+ stderr=subprocess.PIPE,
1477
+ check=False,
1478
+ )
1479
+ if completed.returncode != 0:
1480
+ detail = completed.stderr.strip() or completed.stdout.strip()
1481
+ raise ManualPdfError(f"Command failed ({completed.returncode}): {' '.join(command)}\n{detail}")
1482
+
1483
+
1484
+ def build_language(project: Path, config: dict[str, Any], lang: str) -> dict[str, Any]:
1485
+ paths = artifact_paths(project, config, lang)
1486
+ paths["build_dir"].parent.mkdir(parents=True, exist_ok=True)
1487
+ paths["manifest"].unlink(missing_ok=True)
1488
+ metadata, source_paths, markdown, template, bibliography, csl, dependencies, fingerprint = prepare_build(project, config, lang)
1489
+
1490
+ with tempfile.TemporaryDirectory(prefix=f".{lang}-", dir=paths["build_dir"].parent) as temporary:
1491
+ staged = paths_in_build_dir(paths, Path(temporary))
1492
+ staged["source"].write_text(markdown, encoding="utf-8")
1493
+ clean_bibliography(bibliography, staged["bibliography"])
1494
+ if bibliography:
1495
+ metadata.update(extract_bibliography_metadata(bibliography, staged["bibliography"], project))
1496
+ staged["metadata"].write_text(yaml.safe_dump(metadata, allow_unicode=True, sort_keys=False), encoding="utf-8")
1497
+ command = [
1498
+ "pandoc",
1499
+ str(staged["source"]),
1500
+ "--from=markdown+fenced_divs+pipe_tables+link_attributes",
1501
+ "--standalone",
1502
+ "--top-level-division=chapter",
1503
+ "--number-sections",
1504
+ f"--metadata-file={staged['metadata']}",
1505
+ f"--template={template}",
1506
+ "--pdf-engine=xelatex",
1507
+ f"--resource-path={project}:{staged['build_dir']}",
1508
+ f"--output={staged['pdf']}",
1509
+ ]
1510
+ if bibliography:
1511
+ command.extend(["--citeproc", f"--bibliography={staged['bibliography'].with_suffix('.json')}"])
1512
+ if csl:
1513
+ command.append(f"--csl={csl}")
1514
+ command.append(f"--lua-filter={DEFAULT_BIBLIOGRAPHY_FILTER}")
1515
+ command.append(f"--lua-filter={DEFAULT_CODE_BLOCK_FILTER}")
1516
+ command.append(f"--lua-filter={DEFAULT_FIGURE_FILTER}")
1517
+ run_command(command, project)
1518
+ normalized_pdf = staged["pdf"].with_suffix(".normalized.pdf")
1519
+ run_command(
1520
+ [
1521
+ "qpdf",
1522
+ "--deterministic-id",
1523
+ "--object-streams=generate",
1524
+ "--recompress-flate",
1525
+ "--compression-level=9",
1526
+ str(staged["pdf"]),
1527
+ str(normalized_pdf),
1528
+ ],
1529
+ project,
1530
+ )
1531
+ os.replace(normalized_pdf, staged["pdf"])
1532
+ cover_prefix = staged["cover"].with_suffix("")
1533
+ run_command(["pdftoppm", "-f", "1", "-singlefile", "-png", "-r", "150", str(staged["pdf"]), str(cover_prefix)], project)
1534
+ manifest = {
1535
+ "language": lang,
1536
+ "built_at": dt.datetime.now(dt.timezone.utc).isoformat(),
1537
+ "draft": metadata["draft"],
1538
+ "release_selector": metadata["release-selector"],
1539
+ "release_channel": metadata["release-channel"],
1540
+ "fingerprint": fingerprint,
1541
+ "dependencies": [label for label, _ in dependencies],
1542
+ "source_files": [str(path.relative_to(project)) for path in source_paths],
1543
+ "pdf": str(paths["pdf"].relative_to(project)),
1544
+ "cover": str(paths["cover"].relative_to(project)),
1545
+ "public_pdf": str(paths["public_pdf"].relative_to(project)),
1546
+ "public_cover": str(paths["public_cover"].relative_to(project)),
1547
+ "artifacts": {
1548
+ "pdf": file_signature(staged["pdf"]),
1549
+ "cover": file_signature(staged["cover"]),
1550
+ },
1551
+ }
1552
+ staged["manifest"].write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
1553
+ previous: Path | None = None
1554
+ if paths["build_dir"].exists():
1555
+ previous = Path(tempfile.mkdtemp(prefix=f".{lang}-previous-", dir=paths["build_dir"].parent))
1556
+ previous.rmdir()
1557
+ os.replace(paths["build_dir"], previous)
1558
+ try:
1559
+ os.replace(staged["build_dir"], paths["build_dir"])
1560
+ except OSError:
1561
+ if previous and previous.exists():
1562
+ os.replace(previous, paths["build_dir"])
1563
+ raise
1564
+ if previous and previous.exists():
1565
+ shutil.rmtree(previous)
1566
+ return manifest
1567
+
1568
+
1569
+ def status_language(project: Path, config: dict[str, Any], lang: str) -> dict[str, Any]:
1570
+ paths = artifact_paths(project, config, lang)
1571
+ manifest: dict[str, Any] = {}
1572
+ try:
1573
+ _, source_files, _, _, _, _, _, fingerprint = prepare_build(project, config, lang)
1574
+ source_lang = lang
1575
+ error = ""
1576
+ except ManualPdfError as exc:
1577
+ source_lang = lang
1578
+ source_files = []
1579
+ fingerprint = ""
1580
+ error = str(exc)
1581
+ if paths["manifest"].is_file():
1582
+ try:
1583
+ loaded = json.loads(paths["manifest"].read_text(encoding="utf-8"))
1584
+ manifest = loaded if isinstance(loaded, dict) else {}
1585
+ except (OSError, json.JSONDecodeError):
1586
+ manifest = {}
1587
+ expected = {
1588
+ "language": lang,
1589
+ "fingerprint": fingerprint,
1590
+ "pdf": str(paths["pdf"].relative_to(project)),
1591
+ "cover": str(paths["cover"].relative_to(project)),
1592
+ "public_pdf": str(paths["public_pdf"].relative_to(project)),
1593
+ "public_cover": str(paths["public_cover"].relative_to(project)),
1594
+ "release_selector": release_metadata()["release-selector"],
1595
+ "release_channel": release_metadata()["release-channel"],
1596
+ }
1597
+ manifest_valid = bool(manifest) and all(manifest.get(key) == value for key, value in expected.items())
1598
+ generated_pdf_exists = paths["pdf"].is_file()
1599
+ generated_cover_exists = paths["cover"].is_file()
1600
+ expected_artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), dict) else {}
1601
+ artifacts_valid = (
1602
+ generated_pdf_exists
1603
+ and generated_cover_exists
1604
+ and expected_artifacts.get("pdf") == file_signature(paths["pdf"])
1605
+ and expected_artifacts.get("cover") == file_signature(paths["cover"])
1606
+ )
1607
+ ready = not error and manifest_valid and artifacts_valid
1608
+ published_current = (
1609
+ ready
1610
+ and paths["public_pdf"].is_file()
1611
+ and paths["public_cover"].is_file()
1612
+ and paths["public_pdf"].read_bytes() == paths["pdf"].read_bytes()
1613
+ and paths["public_cover"].read_bytes() == paths["cover"].read_bytes()
1614
+ )
1615
+ return {
1616
+ "language": lang,
1617
+ "source_language": source_lang,
1618
+ "source_count": len(source_files),
1619
+ "generated_pdf": str(paths["pdf"].relative_to(project)),
1620
+ "generated_pdf_exists": generated_pdf_exists,
1621
+ "generated_cover": str(paths["cover"].relative_to(project)),
1622
+ "generated_cover_exists": generated_cover_exists,
1623
+ "published_pdf": str(paths["public_pdf"].relative_to(project)),
1624
+ "published_pdf_exists": paths["public_pdf"].is_file(),
1625
+ "published_cover": str(paths["public_cover"].relative_to(project)),
1626
+ "published_cover_exists": paths["public_cover"].is_file(),
1627
+ "manifest_valid": manifest_valid,
1628
+ "artifacts_valid": artifacts_valid,
1629
+ "fresh": ready,
1630
+ "ready_to_publish": ready,
1631
+ "published_current": published_current,
1632
+ "release_selector": expected["release_selector"],
1633
+ "release_channel": expected["release_channel"],
1634
+ "error": error,
1635
+ }
1636
+
1637
+
1638
+ def stage_copy(source: Path, destination: Path, suffix: str) -> Path:
1639
+ destination.parent.mkdir(parents=True, exist_ok=True)
1640
+ descriptor, raw_path = tempfile.mkstemp(prefix=f".{destination.name}.", suffix=suffix, dir=destination.parent)
1641
+ temporary = Path(raw_path)
1642
+ try:
1643
+ with os.fdopen(descriptor, "wb") as target, source.open("rb") as origin:
1644
+ shutil.copyfileobj(origin, target)
1645
+ shutil.copystat(source, temporary)
1646
+ return temporary
1647
+ except BaseException:
1648
+ temporary.unlink(missing_ok=True)
1649
+ raise
1650
+
1651
+
1652
+ def publish_language(project: Path, config: dict[str, Any], lang: str, dry_run: bool) -> dict[str, Any]:
1653
+ paths = artifact_paths(project, config, lang)
1654
+ status = status_language(project, config, lang)
1655
+ if not status["ready_to_publish"]:
1656
+ raise ManualPdfError(f"Build language '{lang}' successfully after the latest source or configuration change before publishing it.")
1657
+ for destination in [paths["public_pdf"], paths["public_cover"]]:
1658
+ if destination.exists() and not destination.is_file():
1659
+ raise ManualPdfError(f"Publication destination is not a regular file: {destination.relative_to(project)}")
1660
+ operations = [
1661
+ {"source": str(paths["pdf"].relative_to(project)), "destination": str(paths["public_pdf"].relative_to(project))},
1662
+ {"source": str(paths["cover"].relative_to(project)), "destination": str(paths["public_cover"].relative_to(project))},
1663
+ ]
1664
+ if not dry_run:
1665
+ pairs = [(paths["pdf"], paths["public_pdf"]), (paths["cover"], paths["public_cover"])]
1666
+ staged: list[tuple[Path, Path]] = []
1667
+ try:
1668
+ for source, destination in pairs:
1669
+ staged.append((stage_copy(source, destination, ".tmp"), destination))
1670
+ except BaseException:
1671
+ for temporary, _ in staged:
1672
+ temporary.unlink(missing_ok=True)
1673
+ raise
1674
+ backups: list[tuple[Path | None, Path]] = []
1675
+ try:
1676
+ for _, destination in staged:
1677
+ backup = stage_copy(destination, destination, ".backup") if destination.is_file() else None
1678
+ backups.append((backup, destination))
1679
+ for temporary, destination in staged:
1680
+ os.replace(temporary, destination)
1681
+ except BaseException:
1682
+ for backup, destination in backups:
1683
+ if backup and backup.exists():
1684
+ os.replace(backup, destination)
1685
+ else:
1686
+ destination.unlink(missing_ok=True)
1687
+ raise
1688
+ finally:
1689
+ for temporary, _ in staged:
1690
+ temporary.unlink(missing_ok=True)
1691
+ for backup, _ in backups:
1692
+ if backup:
1693
+ backup.unlink(missing_ok=True)
1694
+ return {"language": lang, "dry_run": dry_run, "operations": operations}
1695
+
1696
+
1697
+ def sync_language(project: Path, config: dict[str, Any], lang: str) -> dict[str, Any]:
1698
+ status = status_language(project, config, lang)
1699
+ built = False
1700
+ published = False
1701
+ if not status["ready_to_publish"]:
1702
+ build_language(project, config, lang)
1703
+ built = True
1704
+ status = status_language(project, config, lang)
1705
+ if not status["published_current"]:
1706
+ publish_language(project, config, lang, False)
1707
+ published = True
1708
+ return {
1709
+ "language": lang,
1710
+ "state": "updated" if built or published else "current",
1711
+ "built": built,
1712
+ "published": published,
1713
+ }
1714
+
1715
+
1716
+ def main(argv: list[str] | None = None) -> int:
1717
+ parser = argparse.ArgumentParser(prog="unaltraweb-manual-pdf")
1718
+ parser.add_argument("command", choices=["status", "check", "build", "publish", "sync", "public-paths"])
1719
+ parser.add_argument("--project", default=".")
1720
+ parser.add_argument("--language", default="")
1721
+ parser.add_argument("--dry-run", action="store_true")
1722
+ args = parser.parse_args(argv)
1723
+
1724
+ project = Path(args.project).expanduser().resolve()
1725
+ try:
1726
+ config = read_yaml(project / "_config.yml")
1727
+ pdf = nested(config, "unaltraweb", "manual", "pdf")
1728
+ enabled = bool(pdf.get("enabled", False))
1729
+ configured_languages = language_list(config, pdf)
1730
+ configured_plan = artifact_plan(project, config, configured_languages) if enabled else []
1731
+ if args.command == "public-paths":
1732
+ languages = language_list(config, pdf, args.language) if enabled else []
1733
+ selected = set(languages)
1734
+ paths = [paths for language, paths in configured_plan if language in selected]
1735
+ payload = {
1736
+ "project": str(project),
1737
+ "ok": True,
1738
+ "enabled": enabled,
1739
+ "paths": sorted({
1740
+ str(paths_for_language[key].relative_to(project))
1741
+ for paths_for_language in paths
1742
+ for key in ["public_pdf", "public_cover"]
1743
+ }),
1744
+ }
1745
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
1746
+ return 0
1747
+ languages = language_list(config, pdf, args.language)
1748
+ if args.command == "sync" and not enabled:
1749
+ print(json.dumps({"project": str(project), "ok": True, "enabled": False, "skipped": True}, ensure_ascii=False, indent=2))
1750
+ return 0
1751
+ if args.command not in {"status", "check"} and not enabled:
1752
+ raise ManualPdfError("Manual PDF generation is disabled in unaltraweb.manual.pdf.enabled.")
1753
+ if args.command in {"status", "check"}:
1754
+ payload: dict[str, Any] = {
1755
+ "project": str(project),
1756
+ "enabled": enabled,
1757
+ "languages": [status_language(project, config, lang) for lang in languages],
1758
+ }
1759
+ payload["configuration_ok"] = payload["enabled"] and all(not item["error"] for item in payload["languages"])
1760
+ payload["ready_to_publish"] = payload["configuration_ok"] and all(item["ready_to_publish"] for item in payload["languages"])
1761
+ payload["published_current"] = payload["configuration_ok"] and all(item["published_current"] for item in payload["languages"])
1762
+ payload["ok"] = payload["published_current"] if args.command == "check" else payload["ready_to_publish"]
1763
+ elif args.command == "build":
1764
+ results = [build_language(project, config, lang) for lang in languages]
1765
+ payload = {"project": str(project), "ok": True, "built": results}
1766
+ elif args.command == "publish":
1767
+ results = [publish_language(project, config, lang, args.dry_run) for lang in languages]
1768
+ payload = {"project": str(project), "ok": True, "dry_run": args.dry_run, "published": results}
1769
+ else:
1770
+ results = [sync_language(project, config, lang) for lang in languages]
1771
+ payload = {"project": str(project), "ok": True, "synced": results}
1772
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
1773
+ return 0 if payload["ok"] else 1
1774
+ except (ManualPdfError, OSError, yaml.YAMLError) as exc:
1775
+ print(json.dumps({"project": str(project), "ok": False, "error": str(exc)}, ensure_ascii=False, indent=2), file=sys.stderr)
1776
+ return 2
1777
+
1778
+
1779
+ if __name__ == "__main__":
1780
+ raise SystemExit(main())