docrev 0.8.1 → 0.8.5

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 (306) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/PLAN-tables-and-postprocess.md +850 -0
  3. package/README.md +33 -0
  4. package/bin/rev.js +12 -131
  5. package/bin/rev.ts +145 -0
  6. package/dist/bin/rev.d.ts +9 -0
  7. package/dist/bin/rev.d.ts.map +1 -0
  8. package/dist/bin/rev.js +118 -0
  9. package/dist/bin/rev.js.map +1 -0
  10. package/dist/lib/annotations.d.ts +91 -0
  11. package/dist/lib/annotations.d.ts.map +1 -0
  12. package/dist/lib/annotations.js +554 -0
  13. package/dist/lib/annotations.js.map +1 -0
  14. package/dist/lib/build.d.ts +171 -0
  15. package/dist/lib/build.d.ts.map +1 -0
  16. package/dist/lib/build.js +755 -0
  17. package/dist/lib/build.js.map +1 -0
  18. package/dist/lib/citations.d.ts +34 -0
  19. package/dist/lib/citations.d.ts.map +1 -0
  20. package/dist/lib/citations.js +140 -0
  21. package/dist/lib/citations.js.map +1 -0
  22. package/dist/lib/commands/build.d.ts +13 -0
  23. package/dist/lib/commands/build.d.ts.map +1 -0
  24. package/dist/lib/commands/build.js +678 -0
  25. package/dist/lib/commands/build.js.map +1 -0
  26. package/dist/lib/commands/citations.d.ts +11 -0
  27. package/dist/lib/commands/citations.d.ts.map +1 -0
  28. package/dist/lib/commands/citations.js +428 -0
  29. package/dist/lib/commands/citations.js.map +1 -0
  30. package/dist/lib/commands/comments.d.ts +11 -0
  31. package/dist/lib/commands/comments.d.ts.map +1 -0
  32. package/dist/lib/commands/comments.js +883 -0
  33. package/dist/lib/commands/comments.js.map +1 -0
  34. package/dist/lib/commands/context.d.ts +35 -0
  35. package/dist/lib/commands/context.d.ts.map +1 -0
  36. package/dist/lib/commands/context.js +59 -0
  37. package/dist/lib/commands/context.js.map +1 -0
  38. package/dist/lib/commands/core.d.ts +11 -0
  39. package/dist/lib/commands/core.d.ts.map +1 -0
  40. package/dist/lib/commands/core.js +246 -0
  41. package/dist/lib/commands/core.js.map +1 -0
  42. package/dist/lib/commands/doi.d.ts +11 -0
  43. package/dist/lib/commands/doi.d.ts.map +1 -0
  44. package/dist/lib/commands/doi.js +373 -0
  45. package/dist/lib/commands/doi.js.map +1 -0
  46. package/dist/lib/commands/history.d.ts +11 -0
  47. package/dist/lib/commands/history.d.ts.map +1 -0
  48. package/dist/lib/commands/history.js +245 -0
  49. package/dist/lib/commands/history.js.map +1 -0
  50. package/dist/lib/commands/index.d.ts +28 -0
  51. package/dist/lib/commands/index.d.ts.map +1 -0
  52. package/dist/lib/commands/index.js +35 -0
  53. package/dist/lib/commands/index.js.map +1 -0
  54. package/dist/lib/commands/init.d.ts +11 -0
  55. package/dist/lib/commands/init.d.ts.map +1 -0
  56. package/dist/lib/commands/init.js +209 -0
  57. package/dist/lib/commands/init.js.map +1 -0
  58. package/dist/lib/commands/response.d.ts +11 -0
  59. package/dist/lib/commands/response.d.ts.map +1 -0
  60. package/dist/lib/commands/response.js +317 -0
  61. package/dist/lib/commands/response.js.map +1 -0
  62. package/dist/lib/commands/sections.d.ts +11 -0
  63. package/dist/lib/commands/sections.d.ts.map +1 -0
  64. package/dist/lib/commands/sections.js +1071 -0
  65. package/dist/lib/commands/sections.js.map +1 -0
  66. package/dist/lib/commands/utilities.d.ts +19 -0
  67. package/dist/lib/commands/utilities.d.ts.map +1 -0
  68. package/dist/lib/commands/utilities.js +2009 -0
  69. package/dist/lib/commands/utilities.js.map +1 -0
  70. package/dist/lib/comment-realign.d.ts +50 -0
  71. package/dist/lib/comment-realign.d.ts.map +1 -0
  72. package/dist/lib/comment-realign.js +372 -0
  73. package/dist/lib/comment-realign.js.map +1 -0
  74. package/dist/lib/config.d.ts +41 -0
  75. package/dist/lib/config.d.ts.map +1 -0
  76. package/dist/lib/config.js +76 -0
  77. package/dist/lib/config.js.map +1 -0
  78. package/dist/lib/crossref.d.ts +108 -0
  79. package/dist/lib/crossref.d.ts.map +1 -0
  80. package/dist/lib/crossref.js +597 -0
  81. package/dist/lib/crossref.js.map +1 -0
  82. package/dist/lib/dependencies.d.ts +30 -0
  83. package/dist/lib/dependencies.d.ts.map +1 -0
  84. package/dist/lib/dependencies.js +95 -0
  85. package/dist/lib/dependencies.js.map +1 -0
  86. package/dist/lib/doi-cache.d.ts +29 -0
  87. package/dist/lib/doi-cache.d.ts.map +1 -0
  88. package/dist/lib/doi-cache.js +104 -0
  89. package/dist/lib/doi-cache.js.map +1 -0
  90. package/dist/lib/doi.d.ts +65 -0
  91. package/dist/lib/doi.d.ts.map +1 -0
  92. package/dist/lib/doi.js +710 -0
  93. package/dist/lib/doi.js.map +1 -0
  94. package/dist/lib/equations.d.ts +61 -0
  95. package/dist/lib/equations.d.ts.map +1 -0
  96. package/dist/lib/equations.js +445 -0
  97. package/dist/lib/equations.js.map +1 -0
  98. package/dist/lib/errors.d.ts +60 -0
  99. package/dist/lib/errors.d.ts.map +1 -0
  100. package/dist/lib/errors.js +303 -0
  101. package/dist/lib/errors.js.map +1 -0
  102. package/dist/lib/format.d.ts +104 -0
  103. package/dist/lib/format.d.ts.map +1 -0
  104. package/dist/lib/format.js +416 -0
  105. package/dist/lib/format.js.map +1 -0
  106. package/dist/lib/git.d.ts +88 -0
  107. package/dist/lib/git.d.ts.map +1 -0
  108. package/dist/lib/git.js +304 -0
  109. package/dist/lib/git.js.map +1 -0
  110. package/dist/lib/grammar.d.ts +62 -0
  111. package/dist/lib/grammar.d.ts.map +1 -0
  112. package/dist/lib/grammar.js +244 -0
  113. package/dist/lib/grammar.js.map +1 -0
  114. package/dist/lib/image-registry.d.ts +68 -0
  115. package/dist/lib/image-registry.d.ts.map +1 -0
  116. package/dist/lib/image-registry.js +112 -0
  117. package/dist/lib/image-registry.js.map +1 -0
  118. package/dist/lib/import.d.ts +184 -0
  119. package/dist/lib/import.d.ts.map +1 -0
  120. package/dist/lib/import.js +1581 -0
  121. package/dist/lib/import.js.map +1 -0
  122. package/dist/lib/journals.d.ts +55 -0
  123. package/dist/lib/journals.d.ts.map +1 -0
  124. package/dist/lib/journals.js +417 -0
  125. package/dist/lib/journals.js.map +1 -0
  126. package/dist/lib/merge.d.ts +138 -0
  127. package/dist/lib/merge.d.ts.map +1 -0
  128. package/dist/lib/merge.js +603 -0
  129. package/dist/lib/merge.js.map +1 -0
  130. package/dist/lib/orcid.d.ts +36 -0
  131. package/dist/lib/orcid.d.ts.map +1 -0
  132. package/dist/lib/orcid.js +117 -0
  133. package/dist/lib/orcid.js.map +1 -0
  134. package/dist/lib/pdf-comments.d.ts +95 -0
  135. package/dist/lib/pdf-comments.d.ts.map +1 -0
  136. package/dist/lib/pdf-comments.js +192 -0
  137. package/dist/lib/pdf-comments.js.map +1 -0
  138. package/dist/lib/pdf-import.d.ts +118 -0
  139. package/dist/lib/pdf-import.d.ts.map +1 -0
  140. package/dist/lib/pdf-import.js +397 -0
  141. package/dist/lib/pdf-import.js.map +1 -0
  142. package/dist/lib/plugins.d.ts +76 -0
  143. package/dist/lib/plugins.d.ts.map +1 -0
  144. package/dist/lib/plugins.js +235 -0
  145. package/dist/lib/plugins.js.map +1 -0
  146. package/dist/lib/postprocess.d.ts +42 -0
  147. package/dist/lib/postprocess.d.ts.map +1 -0
  148. package/dist/lib/postprocess.js +138 -0
  149. package/dist/lib/postprocess.js.map +1 -0
  150. package/dist/lib/pptx-template.d.ts +59 -0
  151. package/dist/lib/pptx-template.d.ts.map +1 -0
  152. package/dist/lib/pptx-template.js +613 -0
  153. package/dist/lib/pptx-template.js.map +1 -0
  154. package/dist/lib/pptx-themes.d.ts +80 -0
  155. package/dist/lib/pptx-themes.d.ts.map +1 -0
  156. package/dist/lib/pptx-themes.js +818 -0
  157. package/dist/lib/pptx-themes.js.map +1 -0
  158. package/dist/lib/protect-restore.d.ts +137 -0
  159. package/dist/lib/protect-restore.d.ts.map +1 -0
  160. package/dist/lib/protect-restore.js +394 -0
  161. package/dist/lib/protect-restore.js.map +1 -0
  162. package/dist/lib/rate-limiter.d.ts +27 -0
  163. package/dist/lib/rate-limiter.d.ts.map +1 -0
  164. package/dist/lib/rate-limiter.js +79 -0
  165. package/dist/lib/rate-limiter.js.map +1 -0
  166. package/dist/lib/response.d.ts +41 -0
  167. package/dist/lib/response.d.ts.map +1 -0
  168. package/dist/lib/response.js +150 -0
  169. package/dist/lib/response.js.map +1 -0
  170. package/dist/lib/review.d.ts +35 -0
  171. package/dist/lib/review.d.ts.map +1 -0
  172. package/dist/lib/review.js +263 -0
  173. package/dist/lib/review.js.map +1 -0
  174. package/dist/lib/schema.d.ts +66 -0
  175. package/dist/lib/schema.d.ts.map +1 -0
  176. package/dist/lib/schema.js +339 -0
  177. package/dist/lib/schema.js.map +1 -0
  178. package/dist/lib/scientific-words.d.ts +6 -0
  179. package/dist/lib/scientific-words.d.ts.map +1 -0
  180. package/dist/lib/scientific-words.js +66 -0
  181. package/dist/lib/scientific-words.js.map +1 -0
  182. package/dist/lib/sections.d.ts +40 -0
  183. package/dist/lib/sections.d.ts.map +1 -0
  184. package/dist/lib/sections.js +288 -0
  185. package/dist/lib/sections.js.map +1 -0
  186. package/dist/lib/slides.d.ts +86 -0
  187. package/dist/lib/slides.d.ts.map +1 -0
  188. package/dist/lib/slides.js +676 -0
  189. package/dist/lib/slides.js.map +1 -0
  190. package/dist/lib/spelling.d.ts +76 -0
  191. package/dist/lib/spelling.d.ts.map +1 -0
  192. package/dist/lib/spelling.js +272 -0
  193. package/dist/lib/spelling.js.map +1 -0
  194. package/dist/lib/templates.d.ts +30 -0
  195. package/dist/lib/templates.d.ts.map +1 -0
  196. package/dist/lib/templates.js +504 -0
  197. package/dist/lib/templates.js.map +1 -0
  198. package/dist/lib/themes.d.ts +85 -0
  199. package/dist/lib/themes.d.ts.map +1 -0
  200. package/dist/lib/themes.js +652 -0
  201. package/dist/lib/themes.js.map +1 -0
  202. package/dist/lib/trackchanges.d.ts +51 -0
  203. package/dist/lib/trackchanges.d.ts.map +1 -0
  204. package/dist/lib/trackchanges.js +202 -0
  205. package/dist/lib/trackchanges.js.map +1 -0
  206. package/dist/lib/tui.d.ts +76 -0
  207. package/dist/lib/tui.d.ts.map +1 -0
  208. package/dist/lib/tui.js +377 -0
  209. package/dist/lib/tui.js.map +1 -0
  210. package/dist/lib/types.d.ts +447 -0
  211. package/dist/lib/types.d.ts.map +1 -0
  212. package/dist/lib/types.js +6 -0
  213. package/dist/lib/types.js.map +1 -0
  214. package/dist/lib/undo.d.ts +57 -0
  215. package/dist/lib/undo.d.ts.map +1 -0
  216. package/dist/lib/undo.js +185 -0
  217. package/dist/lib/undo.js.map +1 -0
  218. package/dist/lib/utils.d.ts +16 -0
  219. package/dist/lib/utils.d.ts.map +1 -0
  220. package/dist/lib/utils.js +40 -0
  221. package/dist/lib/utils.js.map +1 -0
  222. package/dist/lib/variables.d.ts +42 -0
  223. package/dist/lib/variables.d.ts.map +1 -0
  224. package/dist/lib/variables.js +141 -0
  225. package/dist/lib/variables.js.map +1 -0
  226. package/dist/lib/word.d.ts +80 -0
  227. package/dist/lib/word.d.ts.map +1 -0
  228. package/dist/lib/word.js +360 -0
  229. package/dist/lib/word.js.map +1 -0
  230. package/dist/lib/wordcomments.d.ts +51 -0
  231. package/dist/lib/wordcomments.d.ts.map +1 -0
  232. package/dist/lib/wordcomments.js +587 -0
  233. package/dist/lib/wordcomments.js.map +1 -0
  234. package/eslint.config.js +27 -0
  235. package/lib/annotations.ts +622 -0
  236. package/lib/apply-buildup-colors.py +88 -0
  237. package/lib/build.ts +1013 -0
  238. package/lib/{citations.js → citations.ts} +38 -27
  239. package/lib/commands/{build.js → build.ts} +80 -27
  240. package/lib/commands/{citations.js → citations.ts} +36 -18
  241. package/lib/commands/{comments.js → comments.ts} +187 -54
  242. package/lib/commands/{context.js → context.ts} +18 -8
  243. package/lib/commands/{core.js → core.ts} +34 -20
  244. package/lib/commands/{doi.js → doi.ts} +32 -16
  245. package/lib/commands/{history.js → history.ts} +25 -12
  246. package/lib/commands/{index.js → index.ts} +9 -5
  247. package/lib/commands/{init.js → init.ts} +20 -8
  248. package/lib/commands/{response.js → response.ts} +47 -20
  249. package/lib/commands/{sections.js → sections.ts} +273 -68
  250. package/lib/commands/{utilities.js → utilities.ts} +338 -158
  251. package/lib/{comment-realign.js → comment-realign.ts} +117 -45
  252. package/lib/config.ts +84 -0
  253. package/lib/{crossref.js → crossref.ts} +213 -138
  254. package/lib/dependencies.ts +106 -0
  255. package/lib/doi-cache.ts +115 -0
  256. package/lib/{doi.js → doi.ts} +115 -281
  257. package/lib/{equations.js → equations.ts} +60 -64
  258. package/lib/{errors.js → errors.ts} +56 -48
  259. package/lib/{format.js → format.ts} +137 -63
  260. package/lib/{git.js → git.ts} +66 -63
  261. package/lib/{grammar.js → grammar.ts} +45 -32
  262. package/lib/image-registry.ts +180 -0
  263. package/lib/import.ts +2060 -0
  264. package/lib/journals.ts +505 -0
  265. package/lib/{merge.js → merge.ts} +185 -135
  266. package/lib/{orcid.js → orcid.ts} +17 -22
  267. package/lib/{pdf-comments.js → pdf-comments.ts} +76 -18
  268. package/lib/{pdf-import.js → pdf-import.ts} +148 -70
  269. package/lib/{plugins.js → plugins.ts} +82 -39
  270. package/lib/postprocess.ts +188 -0
  271. package/lib/pptx-color-filter.lua +37 -0
  272. package/lib/pptx-template.ts +625 -0
  273. package/lib/pptx-themes/academic.pptx +0 -0
  274. package/lib/pptx-themes/corporate.pptx +0 -0
  275. package/lib/pptx-themes/dark.pptx +0 -0
  276. package/lib/pptx-themes/default.pptx +0 -0
  277. package/lib/pptx-themes/minimal.pptx +0 -0
  278. package/lib/pptx-themes/plant.pptx +0 -0
  279. package/lib/pptx-themes.ts +896 -0
  280. package/lib/protect-restore.ts +516 -0
  281. package/lib/rate-limiter.ts +94 -0
  282. package/lib/{response.js → response.ts} +36 -21
  283. package/lib/{review.js → review.ts} +53 -43
  284. package/lib/{schema.js → schema.ts} +70 -25
  285. package/lib/{sections.js → sections.ts} +71 -76
  286. package/lib/slides.ts +793 -0
  287. package/lib/{spelling.js → spelling.ts} +43 -59
  288. package/lib/{templates.js → templates.ts} +20 -17
  289. package/lib/themes.ts +742 -0
  290. package/lib/{trackchanges.js → trackchanges.ts} +52 -23
  291. package/lib/types.ts +509 -0
  292. package/lib/{undo.js → undo.ts} +75 -52
  293. package/lib/utils.ts +41 -0
  294. package/lib/{variables.js → variables.ts} +60 -54
  295. package/lib/word.ts +428 -0
  296. package/lib/{wordcomments.js → wordcomments.ts} +94 -40
  297. package/package.json +15 -5
  298. package/skill/REFERENCE.md +67 -0
  299. package/tsconfig.json +26 -0
  300. package/lib/annotations.js +0 -414
  301. package/lib/build.js +0 -639
  302. package/lib/config.js +0 -79
  303. package/lib/import.js +0 -1145
  304. package/lib/journals.js +0 -629
  305. package/lib/word.js +0 -225
  306. /package/lib/{scientific-words.js → scientific-words.ts} +0 -0
package/lib/build.ts ADDED
@@ -0,0 +1,1013 @@
1
+ /**
2
+ * Build system - combines sections → paper.md → PDF/DOCX/TEX
3
+ *
4
+ * Features:
5
+ * - Reads rev.yaml config
6
+ * - Combines section files into paper.md (persisted)
7
+ * - Strips annotations appropriately per output format
8
+ * - Runs pandoc with crossref filter
9
+ */
10
+
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import { execSync, spawn, ChildProcess } from 'child_process';
14
+ import YAML from 'yaml';
15
+ import { stripAnnotations } from './annotations.js';
16
+ import { buildRegistry, labelToDisplay, detectDynamicRefs, resolveForwardRefs } from './crossref.js';
17
+ import { processVariables, hasVariables } from './variables.js';
18
+ import { processSlideMarkdown, hasSlideSyntax } from './slides.js';
19
+ import { generatePptxTemplate, templateNeedsRegeneration, injectMediaIntoPptx, injectSlideNumbers, applyThemeFonts, applyCentering, applyBuildupColors } from './pptx-template.js';
20
+ import { getThemePath, getThemeNames, PPTX_THEMES } from './pptx-themes.js';
21
+ import { runPostprocess } from './postprocess.js';
22
+ import { hasPandoc, hasPandocCrossref, hasLatex } from './dependencies.js';
23
+ import { buildImageRegistry, writeImageRegistry } from './image-registry.js';
24
+ import type { Author } from './types.js';
25
+
26
+ // =============================================================================
27
+ // Constants
28
+ // =============================================================================
29
+
30
+ /** Supported output formats */
31
+ const SUPPORTED_FORMATS = ['pdf', 'docx', 'tex', 'beamer', 'pptx'] as const;
32
+
33
+ /** Maximum title length for output filename */
34
+ const MAX_TITLE_FILENAME_LENGTH = 50;
35
+
36
+ // =============================================================================
37
+ // Interfaces
38
+ // =============================================================================
39
+
40
+ export interface CrossrefConfig {
41
+ figureTitle?: string;
42
+ tableTitle?: string;
43
+ figPrefix?: string | string[];
44
+ tblPrefix?: string | string[];
45
+ secPrefix?: string | string[];
46
+ linkReferences?: boolean;
47
+ }
48
+
49
+ export interface PdfConfig {
50
+ template?: string | null;
51
+ documentclass?: string;
52
+ fontsize?: string;
53
+ geometry?: string;
54
+ linestretch?: number;
55
+ numbersections?: boolean;
56
+ toc?: boolean;
57
+ }
58
+
59
+ export interface DocxConfig {
60
+ reference?: string | null;
61
+ keepComments?: boolean;
62
+ toc?: boolean;
63
+ }
64
+
65
+ export interface TexConfig {
66
+ standalone?: boolean;
67
+ }
68
+
69
+ export interface BeamerConfig {
70
+ theme?: string;
71
+ colortheme?: string | null;
72
+ fonttheme?: string | null;
73
+ aspectratio?: string | null;
74
+ navigation?: string | null;
75
+ section?: boolean;
76
+ notes?: string | false;
77
+ fit_images?: boolean;
78
+ }
79
+
80
+ export interface PptxConfig {
81
+ theme?: string;
82
+ reference?: string | null;
83
+ media?: string | null;
84
+ colors?: {
85
+ default?: string;
86
+ title?: string;
87
+ };
88
+ buildup?: {
89
+ grey?: string;
90
+ accent?: string;
91
+ enabled?: boolean;
92
+ };
93
+ }
94
+
95
+ export interface TablesConfig {
96
+ nowrap?: string[];
97
+ }
98
+
99
+ export interface PostprocessConfig {
100
+ pdf?: string | null;
101
+ docx?: string | null;
102
+ tex?: string | null;
103
+ pptx?: string | null;
104
+ beamer?: string | null;
105
+ all?: string | null;
106
+ [key: string]: string | null | undefined;
107
+ }
108
+
109
+ export interface BuildConfig {
110
+ title: string;
111
+ authors: (string | Author)[];
112
+ sections: string[];
113
+ bibliography: string | null;
114
+ csl: string | null;
115
+ crossref: CrossrefConfig;
116
+ pdf: PdfConfig;
117
+ docx: DocxConfig;
118
+ tex: TexConfig;
119
+ beamer: BeamerConfig;
120
+ pptx: PptxConfig;
121
+ tables: TablesConfig;
122
+ postprocess: PostprocessConfig;
123
+ _configPath?: string | null;
124
+ }
125
+
126
+ export interface BuildResult {
127
+ format: string;
128
+ success: boolean;
129
+ outputPath?: string;
130
+ error?: string;
131
+ }
132
+
133
+ interface BuildOptions {
134
+ verbose?: boolean;
135
+ config?: BuildConfig;
136
+ outputPath?: string;
137
+ crossref?: boolean;
138
+ _refsAutoInjected?: boolean;
139
+ _forwardRefsResolved?: number;
140
+ }
141
+
142
+ interface CombineOptions extends BuildOptions {
143
+ _refsAutoInjected?: boolean;
144
+ }
145
+
146
+ interface VariablesContext {
147
+ sectionContents: string[];
148
+ }
149
+
150
+ interface PandocResult {
151
+ outputPath: string;
152
+ success: boolean;
153
+ error?: string;
154
+ }
155
+
156
+ interface FullBuildResult {
157
+ results: BuildResult[];
158
+ paperPath: string;
159
+ warnings: string[];
160
+ forwardRefsResolved: number;
161
+ refsAutoInjected?: boolean;
162
+ }
163
+
164
+ interface DynamicRef {
165
+ type: string;
166
+ label: string;
167
+ match: string;
168
+ position: number;
169
+ }
170
+
171
+ interface Registry {
172
+ figures: Map<string, unknown>;
173
+ tables: Map<string, unknown>;
174
+ equations: Map<string, unknown>;
175
+ byNumber: {
176
+ fig?: Map<number, string>;
177
+ figS?: Map<number, string>;
178
+ tbl?: Map<number, string>;
179
+ tblS?: Map<number, string>;
180
+ eq?: Map<number, string>;
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Default rev.yaml configuration
186
+ */
187
+ export const DEFAULT_CONFIG: BuildConfig = {
188
+ title: 'Untitled Document',
189
+ authors: [],
190
+ sections: [],
191
+ bibliography: null,
192
+ csl: null,
193
+ crossref: {
194
+ figureTitle: 'Figure',
195
+ tableTitle: 'Table',
196
+ figPrefix: ['Fig.', 'Figs.'],
197
+ tblPrefix: ['Table', 'Tables'],
198
+ secPrefix: ['Section', 'Sections'],
199
+ linkReferences: true,
200
+ },
201
+ pdf: {
202
+ template: null,
203
+ documentclass: 'article',
204
+ fontsize: '12pt',
205
+ geometry: 'margin=1in',
206
+ linestretch: 1.5,
207
+ numbersections: false,
208
+ toc: false,
209
+ },
210
+ docx: {
211
+ reference: null,
212
+ keepComments: true,
213
+ toc: false,
214
+ },
215
+ tex: {
216
+ standalone: true,
217
+ },
218
+ // Slide formats
219
+ beamer: {
220
+ theme: 'default',
221
+ colortheme: null,
222
+ fonttheme: null,
223
+ aspectratio: null, // '169' for 16:9, '43' for 4:3
224
+ navigation: null, // 'horizontal', 'vertical', 'frame', 'empty'
225
+ section: true, // section divider slides
226
+ notes: 'show', // 'show' (presenter view), 'only' (notes only), 'hide', or false
227
+ fit_images: true, // scale images to fit within slide bounds
228
+ },
229
+ pptx: {
230
+ theme: 'default', // Built-in theme: default, dark, academic, minimal, corporate
231
+ reference: null, // Custom reference-doc (overrides theme)
232
+ media: null, // directory with logo images (e.g., logo-left.png, logo-right.png)
233
+ },
234
+ // Table formatting
235
+ tables: {
236
+ nowrap: [], // Column headers to apply nowrap formatting (converts Normal() → $\mathcal{N}()$ etc.)
237
+ },
238
+ // Postprocess scripts
239
+ postprocess: {
240
+ pdf: null,
241
+ docx: null,
242
+ tex: null,
243
+ pptx: null,
244
+ beamer: null,
245
+ all: null, // Runs after any format
246
+ },
247
+ };
248
+
249
+ // =============================================================================
250
+ // Public API
251
+ // =============================================================================
252
+
253
+ /**
254
+ * Load rev.yaml config from directory
255
+ * @param directory - Project directory path
256
+ * @returns Merged config with defaults
257
+ * @throws {TypeError} If directory is not a string
258
+ * @throws {Error} If rev.yaml exists but cannot be parsed
259
+ */
260
+ export function loadConfig(directory: string): BuildConfig {
261
+ if (typeof directory !== 'string') {
262
+ throw new TypeError(`directory must be a string, got ${typeof directory}`);
263
+ }
264
+
265
+ const configPath = path.join(directory, 'rev.yaml');
266
+
267
+ if (!fs.existsSync(configPath)) {
268
+ return { ...DEFAULT_CONFIG, _configPath: null };
269
+ }
270
+
271
+ try {
272
+ const content = fs.readFileSync(configPath, 'utf-8');
273
+ const userConfig = YAML.parse(content) || {};
274
+
275
+ // Deep merge with defaults
276
+ const config: BuildConfig = {
277
+ ...DEFAULT_CONFIG,
278
+ ...userConfig,
279
+ crossref: { ...DEFAULT_CONFIG.crossref, ...userConfig.crossref },
280
+ pdf: { ...DEFAULT_CONFIG.pdf, ...userConfig.pdf },
281
+ docx: { ...DEFAULT_CONFIG.docx, ...userConfig.docx },
282
+ tex: { ...DEFAULT_CONFIG.tex, ...userConfig.tex },
283
+ beamer: { ...DEFAULT_CONFIG.beamer, ...userConfig.beamer },
284
+ pptx: { ...DEFAULT_CONFIG.pptx, ...userConfig.pptx },
285
+ tables: { ...DEFAULT_CONFIG.tables, ...userConfig.tables },
286
+ postprocess: { ...DEFAULT_CONFIG.postprocess, ...userConfig.postprocess },
287
+ _configPath: configPath,
288
+ };
289
+
290
+ return config;
291
+ } catch (err) {
292
+ const error = err as Error;
293
+ throw new Error(`Failed to parse rev.yaml: ${error.message}`);
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Find section files in directory
299
+ * @param directory - Project directory path
300
+ * @param configSections - Sections from rev.yaml (optional)
301
+ * @returns Ordered list of section file names
302
+ * @throws {TypeError} If directory is not a string
303
+ */
304
+ export function findSections(directory: string, configSections: string[] = []): string[] {
305
+ if (typeof directory !== 'string') {
306
+ throw new TypeError(`directory must be a string, got ${typeof directory}`);
307
+ }
308
+
309
+ // If sections specified in config, use that order
310
+ if (configSections.length > 0) {
311
+ const sections: string[] = [];
312
+ for (const section of configSections) {
313
+ const filePath = path.join(directory, section);
314
+ if (fs.existsSync(filePath)) {
315
+ sections.push(section);
316
+ } else {
317
+ console.warn(`Warning: Section file not found: ${section}`);
318
+ }
319
+ }
320
+ return sections;
321
+ }
322
+
323
+ // Try sections.yaml
324
+ const sectionsYamlPath = path.join(directory, 'sections.yaml');
325
+ if (fs.existsSync(sectionsYamlPath)) {
326
+ try {
327
+ const sectionsConfig = YAML.parse(fs.readFileSync(sectionsYamlPath, 'utf-8'));
328
+ if (sectionsConfig.sections) {
329
+ return Object.entries(sectionsConfig.sections)
330
+ .sort((a: [string, any], b: [string, any]) => (a[1].order ?? 999) - (b[1].order ?? 999))
331
+ .map(([file]) => file)
332
+ .filter((f) => fs.existsSync(path.join(directory, f)));
333
+ }
334
+ } catch (e) {
335
+ if (process.env.DEBUG) {
336
+ const error = e as Error;
337
+ console.warn('build: YAML parse error in sections.yaml:', error.message);
338
+ }
339
+ }
340
+ }
341
+
342
+ // Default: find all .md files except special ones
343
+ const exclude = ['paper.md', 'readme.md', 'claude.md'];
344
+ const files = fs.readdirSync(directory).filter((f) => {
345
+ if (!f.endsWith('.md')) return false;
346
+ if (exclude.includes(f.toLowerCase())) return false;
347
+ return true;
348
+ });
349
+
350
+ // Sort alphabetically as fallback
351
+ return files.sort();
352
+ }
353
+
354
+ /**
355
+ * Combine section files into paper.md
356
+ */
357
+ export function combineSections(directory: string, config: BuildConfig, options: CombineOptions = {}): string {
358
+ const sections = findSections(directory, config.sections);
359
+
360
+ if (sections.length === 0) {
361
+ throw new Error('No section files found. Create .md files or specify sections in rev.yaml');
362
+ }
363
+
364
+ const parts: string[] = [];
365
+
366
+ // Add YAML frontmatter
367
+ const frontmatter = buildFrontmatter(config);
368
+ parts.push('---');
369
+ parts.push(YAML.stringify(frontmatter).trim());
370
+ parts.push('---');
371
+ parts.push('');
372
+
373
+ // Read all section contents for variable processing
374
+ const sectionContents: string[] = [];
375
+
376
+ // Check if we need to auto-inject references before supplementary
377
+ // Pandoc places refs at the end by default, which breaks when supplementary follows
378
+ const hasRefsSection = sections.some(s =>
379
+ s.toLowerCase().includes('reference') || s.toLowerCase().includes('refs')
380
+ );
381
+ const suppIndex = sections.findIndex(s =>
382
+ s.toLowerCase().includes('supp') || s.toLowerCase().includes('appendix')
383
+ );
384
+ const hasBibliography = config.bibliography && fs.existsSync(path.join(directory, config.bibliography));
385
+
386
+ // Track if we find an explicit refs div in any section
387
+ let hasExplicitRefsDiv = false;
388
+
389
+ // Combine sections
390
+ for (let i = 0; i < sections.length; i++) {
391
+ const section = sections[i];
392
+ if (!section) continue;
393
+ const filePath = path.join(directory, section);
394
+ let content = fs.readFileSync(filePath, 'utf-8');
395
+
396
+ // Remove any existing frontmatter from section files
397
+ content = stripFrontmatter(content);
398
+ sectionContents.push(content);
399
+
400
+ // Check if this section has an explicit refs div
401
+ if (content.includes('::: {#refs}') || content.includes('::: {#refs}')) {
402
+ hasExplicitRefsDiv = true;
403
+ }
404
+
405
+ // Auto-inject references before supplementary if needed
406
+ if (i === suppIndex && hasBibliography && !hasRefsSection && !hasExplicitRefsDiv) {
407
+ parts.push('# References\n');
408
+ parts.push('::: {#refs}');
409
+ parts.push(':::');
410
+ parts.push('');
411
+ parts.push('');
412
+ options._refsAutoInjected = true;
413
+ }
414
+
415
+ parts.push(content.trim());
416
+ parts.push('');
417
+ parts.push(''); // Double newline between sections
418
+ }
419
+
420
+ let paperContent = parts.join('\n');
421
+
422
+ // Process template variables if any exist
423
+ if (hasVariables(paperContent)) {
424
+ paperContent = processVariables(paperContent, config as any, { sectionContents });
425
+ }
426
+
427
+ // Resolve forward references (refs that appear before their anchor definition)
428
+ // This fixes pandoc-crossref limitation with multi-file documents
429
+ if (hasPandocCrossref()) {
430
+ const registry = buildRegistry(directory, sections);
431
+ const { text, resolved } = resolveForwardRefs(paperContent, registry);
432
+ if (resolved.length > 0) {
433
+ paperContent = text;
434
+ // Store resolved count for optional reporting
435
+ options._forwardRefsResolved = resolved.length;
436
+ }
437
+ }
438
+
439
+ const paperPath = path.join(directory, 'paper.md');
440
+
441
+ fs.writeFileSync(paperPath, paperContent, 'utf-8');
442
+
443
+ return paperPath;
444
+ }
445
+
446
+ /**
447
+ * Build YAML frontmatter from config
448
+ */
449
+ function buildFrontmatter(config: BuildConfig): Record<string, unknown> {
450
+ const fm: Record<string, unknown> = {};
451
+
452
+ if (config.title) fm.title = config.title;
453
+
454
+ if (config.authors && config.authors.length > 0) {
455
+ fm.author = config.authors;
456
+ }
457
+
458
+ if (config.bibliography) {
459
+ fm.bibliography = config.bibliography;
460
+ }
461
+
462
+ if (config.csl) {
463
+ fm.csl = config.csl;
464
+ }
465
+
466
+ return fm;
467
+ }
468
+
469
+ /**
470
+ * Strip YAML frontmatter from content
471
+ */
472
+ function stripFrontmatter(content: string): string {
473
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
474
+ if (match) {
475
+ return content.slice(match[0].length);
476
+ }
477
+ return content;
478
+ }
479
+
480
+ /**
481
+ * Process markdown tables to apply nowrap formatting to specified columns.
482
+ * Converts distribution notation (Normal, Student-t, Gamma) to LaTeX math.
483
+ * @param content - Markdown content
484
+ * @param tablesConfig - tables config from rev.yaml
485
+ * @param format - output format (pdf, docx, etc.)
486
+ * @returns processed content
487
+ */
488
+ export function processTablesForFormat(content: string, tablesConfig: TablesConfig, format: string): string {
489
+ // Only process for PDF/TeX output
490
+ if (format !== 'pdf' && format !== 'tex') {
491
+ return content;
492
+ }
493
+
494
+ // Check if we have nowrap columns configured
495
+ if (!tablesConfig?.nowrap?.length) {
496
+ return content;
497
+ }
498
+
499
+ const nowrapPatterns = tablesConfig.nowrap.map((p) => p.toLowerCase());
500
+
501
+ // Match pipe tables: header row, separator row, body rows
502
+ // Header: | Col1 | Col2 | Col3 |
503
+ // Separator: |:-----|:-----|:-----|
504
+ // Body: | val1 | val2 | val3 |
505
+ const tableRegex = /^(\|[^\n]+\|\r?\n\|[-:| ]+\|\r?\n)((?:\|[^\n]+\|\r?\n?)+)/gm;
506
+
507
+ return content.replace(tableRegex, (match, headerAndSep, body) => {
508
+ // Split header from separator
509
+ const lines = headerAndSep.split(/\r?\n/);
510
+ const headerLine = lines[0] ?? '';
511
+
512
+ // Parse header cells to find nowrap column indices
513
+ const headerCells = headerLine
514
+ .split('|')
515
+ .slice(1, -1)
516
+ .map((c: string) => c.trim().toLowerCase());
517
+
518
+ const nowrapCols: number[] = [];
519
+ headerCells.forEach((cell: string, i: number) => {
520
+ if (nowrapPatterns.some((p) => cell.includes(p))) {
521
+ nowrapCols.push(i);
522
+ }
523
+ });
524
+
525
+ // If no nowrap columns found in this table, return unchanged
526
+ if (nowrapCols.length === 0) {
527
+ return match;
528
+ }
529
+
530
+ // Process body rows
531
+ const bodyLines = body.split(/\r?\n/).filter((l: string) => l.trim());
532
+ const processedBody = bodyLines
533
+ .map((row: string) => {
534
+ // Split row into cells, keeping the pipe structure
535
+ const cells = row.split('|');
536
+ // cells[0] is empty (before first |), cells[last] is empty (after last |)
537
+
538
+ nowrapCols.forEach((colIdx) => {
539
+ const cellIdx = colIdx + 1; // Account for empty first element
540
+ if (cells[cellIdx] !== undefined) {
541
+ const cellContent = cells[cellIdx].trim();
542
+
543
+ // Skip if empty, already math, or already has LaTeX commands
544
+ if (!cellContent || cellContent.startsWith('$') || cellContent.startsWith('\\')) {
545
+ return;
546
+ }
547
+
548
+ // Convert distribution notation to LaTeX math
549
+ // Order matters: compound names (Half-Normal) must come before simple names (Normal)
550
+ let processed = cellContent;
551
+
552
+ // Half-Normal(x) → $\text{Half-Normal}(x)$ (must come before Normal)
553
+ processed = processed.replace(/Half-Normal\(([^)]+)\)/g, '$\\text{Half-Normal}($1)$');
554
+
555
+ // Normal(x, y) → $\mathcal{N}(x, y)$
556
+ processed = processed.replace(/Normal\(([^)]+)\)/g, '$\\mathcal{N}($1)$');
557
+
558
+ // Student-t(df, loc, scale) → $t_{df}(loc, scale)$
559
+ processed = processed.replace(/Student-t\((\d+),\s*([^)]+)\)/g, '$t_{$1}($2)$');
560
+
561
+ // Gamma(a, b) → $\text{Gamma}(a, b)$
562
+ processed = processed.replace(/Gamma\(([^)]+)\)/g, '$\\text{Gamma}($1)$');
563
+
564
+ // Exponential(x) → $\text{Exp}(x)$
565
+ processed = processed.replace(/Exponential\(([^)]+)\)/g, '$\\text{Exp}($1)$');
566
+
567
+ // Update cell with padding
568
+ cells[cellIdx] = ` ${processed} `;
569
+ }
570
+ });
571
+
572
+ return cells.join('|');
573
+ })
574
+ .join('\n');
575
+
576
+ return headerAndSep + processedBody + '\n';
577
+ });
578
+ }
579
+
580
+ /**
581
+ * Prepare paper.md for specific output format
582
+ */
583
+ export function prepareForFormat(
584
+ paperPath: string,
585
+ format: string,
586
+ config: BuildConfig,
587
+ options: BuildOptions = {}
588
+ ): string {
589
+ const directory = path.dirname(paperPath);
590
+ let content = fs.readFileSync(paperPath, 'utf-8');
591
+
592
+ // Build crossref registry for reference conversion
593
+ // Pass sections from config to ensure correct file ordering
594
+ const registry = buildRegistry(directory, config.sections);
595
+
596
+ if (format === 'pdf' || format === 'tex') {
597
+ // Strip all annotations for clean output
598
+ content = stripAnnotations(content);
599
+
600
+ // Process tables for nowrap columns (convert Normal() → $\mathcal{N}()$ etc.)
601
+ content = processTablesForFormat(content, config.tables, format);
602
+ } else if (format === 'docx') {
603
+ // Strip track changes, optionally keep comments
604
+ content = stripAnnotations(content, { keepComments: config.docx.keepComments });
605
+
606
+ // Convert @fig:label to "Figure 1" for Word readers
607
+ content = convertDynamicRefsToDisplay(content, registry);
608
+ } else if (format === 'beamer' || format === 'pptx') {
609
+ // Strip annotations for slide output
610
+ content = stripAnnotations(content);
611
+
612
+ // Process slide syntax (::: step, ::: notes)
613
+ if (hasSlideSyntax(content)) {
614
+ content = processSlideMarkdown(content, format);
615
+ }
616
+ }
617
+
618
+ // Write to temporary file
619
+ const preparedPath = path.join(directory, `.paper-${format}.md`);
620
+ fs.writeFileSync(preparedPath, content, 'utf-8');
621
+
622
+ return preparedPath;
623
+ }
624
+
625
+ /**
626
+ * Convert @fig:label references to display format (Figure 1)
627
+ */
628
+ function convertDynamicRefsToDisplay(text: string, registry: Registry): string {
629
+ const refs = detectDynamicRefs(text);
630
+
631
+ // Process in reverse order to preserve positions
632
+ let result = text;
633
+ for (let i = refs.length - 1; i >= 0; i--) {
634
+ const ref = refs[i];
635
+ if (!ref) continue;
636
+ const display = labelToDisplay(ref.type, ref.label, registry as any);
637
+
638
+ if (display) {
639
+ result = result.slice(0, ref.position) + display + result.slice(ref.position + ref.match.length);
640
+ }
641
+ }
642
+
643
+ return result;
644
+ }
645
+
646
+ /**
647
+ * Build pandoc arguments for format
648
+ */
649
+ export function buildPandocArgs(format: string, config: BuildConfig, outputPath: string): string[] {
650
+ const args: string[] = [];
651
+
652
+ // Output format
653
+ if (format === 'tex') {
654
+ args.push('-t', 'latex');
655
+ if (config.tex.standalone) {
656
+ args.push('-s');
657
+ }
658
+ } else if (format === 'pdf') {
659
+ args.push('-t', 'pdf');
660
+ } else if (format === 'docx') {
661
+ args.push('-t', 'docx');
662
+ } else if (format === 'beamer') {
663
+ args.push('-t', 'beamer');
664
+ } else if (format === 'pptx') {
665
+ args.push('-t', 'pptx');
666
+ }
667
+
668
+ // Output file (use basename since we set cwd to directory in runPandoc)
669
+ args.push('-o', path.basename(outputPath));
670
+
671
+ // Crossref filter (if available) - skip for slides
672
+ if (hasPandocCrossref() && format !== 'beamer' && format !== 'pptx') {
673
+ args.push('--filter', 'pandoc-crossref');
674
+ }
675
+
676
+ // Bibliography
677
+ if (config.bibliography) {
678
+ args.push('--citeproc');
679
+ }
680
+
681
+ // Format-specific options
682
+ if (format === 'pdf') {
683
+ if (config.pdf.template) {
684
+ args.push('--template', config.pdf.template);
685
+ }
686
+ args.push('-V', `documentclass=${config.pdf.documentclass}`);
687
+ args.push('-V', `fontsize=${config.pdf.fontsize}`);
688
+ args.push('-V', `geometry:${config.pdf.geometry}`);
689
+ if (config.pdf.linestretch !== 1) {
690
+ args.push('-V', `linestretch=${config.pdf.linestretch}`);
691
+ }
692
+ if (config.pdf.numbersections) {
693
+ args.push('--number-sections');
694
+ }
695
+ if (config.pdf.toc) {
696
+ args.push('--toc');
697
+ }
698
+ } else if (format === 'docx') {
699
+ if (config.docx.reference) {
700
+ args.push('--reference-doc', config.docx.reference);
701
+ }
702
+ if (config.docx.toc) {
703
+ args.push('--toc');
704
+ }
705
+ } else if (format === 'beamer') {
706
+ // Beamer slide options
707
+ const beamer = config.beamer || {};
708
+ if (beamer.theme) {
709
+ args.push('-V', `theme=${beamer.theme}`);
710
+ }
711
+ if (beamer.colortheme) {
712
+ args.push('-V', `colortheme=${beamer.colortheme}`);
713
+ }
714
+ if (beamer.fonttheme) {
715
+ args.push('-V', `fonttheme=${beamer.fonttheme}`);
716
+ }
717
+ if (beamer.aspectratio) {
718
+ args.push('-V', `aspectratio=${beamer.aspectratio}`);
719
+ }
720
+ if (beamer.navigation) {
721
+ args.push('-V', `navigation=${beamer.navigation}`);
722
+ }
723
+ // Speaker notes - default to 'show' which creates presenter view PDF
724
+ // Options: 'show' (dual screen), 'only' (notes only), 'hide' (no notes), false (disabled)
725
+ const notesMode = beamer.notes !== undefined ? beamer.notes : 'show';
726
+ if (notesMode && notesMode !== 'hide') {
727
+ args.push('-V', `classoption=notes=${notesMode}`);
728
+ }
729
+ // Fit images within slide bounds (default: true)
730
+ if (beamer.fit_images !== false) {
731
+ const fitImagesHeader = `\\makeatletter
732
+ \\def\\maxwidth{\\ifdim\\Gin@nat@width>\\linewidth\\linewidth\\else\\Gin@nat@width\\fi}
733
+ \\def\\maxheight{\\ifdim\\Gin@nat@height>0.75\\textheight 0.75\\textheight\\else\\Gin@nat@height\\fi}
734
+ \\makeatother
735
+ \\setkeys{Gin}{width=\\maxwidth,height=\\maxheight,keepaspectratio}`;
736
+ args.push('-V', `header-includes=${fitImagesHeader}`);
737
+ }
738
+ // Slides need standalone
739
+ args.push('-s');
740
+ } else if (format === 'pptx') {
741
+ // PowerPoint options - handled separately in preparePptxTemplate
742
+ // Reference doc is set by caller after template generation
743
+ }
744
+
745
+ return args;
746
+ }
747
+
748
+ /**
749
+ * Write crossref.yaml if needed
750
+ */
751
+ function ensureCrossrefConfig(directory: string, config: BuildConfig): void {
752
+ const crossrefPath = path.join(directory, 'crossref.yaml');
753
+
754
+ if (!fs.existsSync(crossrefPath) && hasPandocCrossref()) {
755
+ fs.writeFileSync(crossrefPath, YAML.stringify(config.crossref), 'utf-8');
756
+ }
757
+ }
758
+
759
+ /**
760
+ * Get install instructions for missing dependency
761
+ */
762
+ function getInstallInstructions(tool: string): string {
763
+ const instructions: Record<string, string> = {
764
+ pandoc: 'https://pandoc.org/installing.html',
765
+ latex: 'https://www.latex-project.org/get/',
766
+ };
767
+ return instructions[tool] || 'Check documentation';
768
+ }
769
+
770
+ /**
771
+ * Run pandoc build
772
+ */
773
+ export async function runPandoc(
774
+ inputPath: string,
775
+ format: string,
776
+ config: BuildConfig,
777
+ options: BuildOptions = {}
778
+ ): Promise<PandocResult> {
779
+ const directory = path.dirname(inputPath);
780
+ const baseName = config.title
781
+ ? config.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50)
782
+ : 'paper';
783
+
784
+ // Map format to file extension
785
+ const extMap: Record<string, string> = {
786
+ tex: '.tex',
787
+ pdf: '.pdf',
788
+ docx: '.docx',
789
+ beamer: '.pdf', // beamer outputs PDF
790
+ pptx: '.pptx',
791
+ };
792
+ const ext = extMap[format] || '.pdf';
793
+
794
+ // For beamer, use -slides suffix to distinguish from regular PDF
795
+ const suffix = format === 'beamer' ? '-slides' : '';
796
+ // Allow custom output path via options
797
+ const outputPath = options.outputPath || path.join(directory, `${baseName}${suffix}${ext}`);
798
+
799
+ // Ensure crossref.yaml exists
800
+ ensureCrossrefConfig(directory, config);
801
+
802
+ const args = buildPandocArgs(format, config, outputPath);
803
+
804
+ // Handle PPTX reference template and themes
805
+ let pptxMediaDir: string | null = null;
806
+ if (format === 'pptx') {
807
+ const pptx = config.pptx || {};
808
+
809
+ // Determine media directory (default: pptx/media or slides/media)
810
+ let mediaDir = pptx.media;
811
+ if (!mediaDir) {
812
+ if (fs.existsSync(path.join(directory, 'pptx', 'media'))) {
813
+ mediaDir = path.join(directory, 'pptx', 'media');
814
+ } else if (fs.existsSync(path.join(directory, 'slides', 'media'))) {
815
+ mediaDir = path.join(directory, 'slides', 'media');
816
+ }
817
+ } else if (!path.isAbsolute(mediaDir)) {
818
+ mediaDir = path.join(directory, mediaDir);
819
+ }
820
+ pptxMediaDir = mediaDir || null;
821
+
822
+ // Determine reference doc: custom reference overrides theme
823
+ let referenceDoc: string | null = null;
824
+ if (pptx.reference && fs.existsSync(path.join(directory, pptx.reference))) {
825
+ // Custom reference doc takes precedence
826
+ referenceDoc = path.join(directory, pptx.reference);
827
+ } else {
828
+ // Use built-in theme (default: 'default')
829
+ const themeName = pptx.theme || 'default';
830
+ const themePath = getThemePath(themeName);
831
+ if (themePath && fs.existsSync(themePath)) {
832
+ referenceDoc = themePath;
833
+ }
834
+ }
835
+
836
+ if (referenceDoc) {
837
+ args.push('--reference-doc', referenceDoc);
838
+ }
839
+
840
+ // Add color filter for PPTX (handles [text]{color=#RRGGBB} syntax)
841
+ const colorFilterPath = path.join(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1')), 'pptx-color-filter.lua');
842
+ if (fs.existsSync(colorFilterPath)) {
843
+ args.push('--lua-filter', colorFilterPath);
844
+ }
845
+ }
846
+
847
+ // Add crossref metadata file if exists (skip for slides - they don't use crossref)
848
+ if (format !== 'beamer' && format !== 'pptx') {
849
+ const crossrefPath = path.join(directory, 'crossref.yaml');
850
+ if (fs.existsSync(crossrefPath) && hasPandocCrossref()) {
851
+ // Use basename since we set cwd to directory
852
+ args.push('--metadata-file', 'crossref.yaml');
853
+ }
854
+ }
855
+
856
+ // Input file (use basename since we set cwd to directory)
857
+ args.push(path.basename(inputPath));
858
+
859
+ return new Promise((resolve) => {
860
+ const pandoc: ChildProcess = spawn('pandoc', args, {
861
+ cwd: directory,
862
+ stdio: ['ignore', 'pipe', 'pipe'],
863
+ });
864
+
865
+ let stderr = '';
866
+ pandoc.stderr?.on('data', (data) => {
867
+ stderr += data.toString();
868
+ });
869
+
870
+ pandoc.on('close', async (code) => {
871
+ if (code === 0) {
872
+ // For PPTX, post-process to add slide numbers, buildup colors, and logos
873
+ if (format === 'pptx') {
874
+ try {
875
+ // Inject slide numbers into content slides only
876
+ await injectSlideNumbers(outputPath);
877
+ } catch (e) {
878
+ // Slide number injection failed but output was created
879
+ }
880
+ try {
881
+ // Apply colors (default text color, title color, buildup greying)
882
+ const pptxConfig = config.pptx || {};
883
+ const colorsConfig = pptxConfig.colors || {};
884
+ const buildupConfig = pptxConfig.buildup || {};
885
+ // Merge colors and buildup config for applyBuildupColors
886
+ const colorConfig = {
887
+ default: colorsConfig.default,
888
+ title: colorsConfig.title,
889
+ grey: buildupConfig.grey,
890
+ accent: buildupConfig.accent,
891
+ enabled: buildupConfig.enabled
892
+ };
893
+ await applyBuildupColors(outputPath, colorConfig);
894
+ } catch (e) {
895
+ // Color application failed but output was created
896
+ }
897
+ // Inject logos into cover slide (if media dir configured)
898
+ if (pptxMediaDir) {
899
+ try {
900
+ await injectMediaIntoPptx(outputPath, pptxMediaDir);
901
+ } catch (e) {
902
+ // Logo injection failed but output was created
903
+ }
904
+ }
905
+ }
906
+
907
+ // Run user postprocess scripts
908
+ const postResult = await runPostprocess(outputPath, format, config as unknown as Parameters<typeof runPostprocess>[2], options);
909
+ if (!postResult.success && options.verbose) {
910
+ console.error(`Postprocess warning: ${postResult.error}`);
911
+ }
912
+
913
+ resolve({ outputPath, success: true });
914
+ } else {
915
+ resolve({ outputPath, success: false, error: stderr || `Exit code ${code}` });
916
+ }
917
+ });
918
+
919
+ pandoc.on('error', (err) => {
920
+ resolve({ outputPath, success: false, error: err.message });
921
+ });
922
+ });
923
+ }
924
+
925
+ /**
926
+ * Full build pipeline
927
+ */
928
+ export async function build(
929
+ directory: string,
930
+ formats: string[] = ['pdf', 'docx'],
931
+ options: BuildOptions = {}
932
+ ): Promise<FullBuildResult> {
933
+ const warnings: string[] = [];
934
+ let forwardRefsResolved = 0;
935
+
936
+ // Check pandoc
937
+ if (!hasPandoc()) {
938
+ const instruction = getInstallInstructions('pandoc');
939
+ throw new Error(`Pandoc not found. Install with: ${instruction}\nOr run: rev doctor`);
940
+ }
941
+
942
+ // Check LaTeX if PDF is requested
943
+ if ((formats.includes('pdf') || formats.includes('all')) && !hasLatex()) {
944
+ warnings.push(`LaTeX not found - PDF generation may fail. Install with: ${getInstallInstructions('latex')}`);
945
+ }
946
+
947
+ // Check pandoc-crossref
948
+ if (!hasPandocCrossref()) {
949
+ warnings.push('pandoc-crossref not found - figure/table numbering will not work');
950
+ }
951
+
952
+ // Load config (use passed config if provided, otherwise load from file)
953
+ const config = options.config || loadConfig(directory);
954
+
955
+ // Combine sections → paper.md
956
+ const buildOptions: CombineOptions = { ...options };
957
+ const paperPath = combineSections(directory, config, buildOptions);
958
+ forwardRefsResolved = buildOptions._forwardRefsResolved || 0;
959
+ const refsAutoInjected = buildOptions._refsAutoInjected || false;
960
+
961
+ // Expand 'all' to all formats
962
+ if (formats.includes('all')) {
963
+ formats = ['pdf', 'docx', 'tex'];
964
+ }
965
+
966
+ // Build and save image registry when DOCX is being built
967
+ // This allows import to restore proper image syntax from Word documents
968
+ if (formats.includes('docx')) {
969
+ const paperContent = fs.readFileSync(paperPath, 'utf-8');
970
+ const crossrefReg = buildRegistry(directory, config.sections);
971
+ const imageReg = buildImageRegistry(paperContent, crossrefReg as any);
972
+ if ((imageReg as any).figures?.length > 0) {
973
+ writeImageRegistry(directory, imageReg);
974
+ }
975
+ }
976
+
977
+ const results: BuildResult[] = [];
978
+
979
+ for (const format of formats) {
980
+ // Prepare format-specific version
981
+ const preparedPath = prepareForFormat(paperPath, format, config, options);
982
+
983
+ // Run pandoc
984
+ const result = await runPandoc(preparedPath, format, config, options);
985
+ results.push({ format, ...result });
986
+
987
+ // Clean up temp file
988
+ try {
989
+ fs.unlinkSync(preparedPath);
990
+ } catch {
991
+ // Ignore cleanup errors
992
+ }
993
+ }
994
+
995
+ return { results, paperPath, warnings, forwardRefsResolved, refsAutoInjected };
996
+ }
997
+
998
+ /**
999
+ * Get build status summary
1000
+ */
1001
+ export function formatBuildResults(results: BuildResult[]): string {
1002
+ const lines: string[] = [];
1003
+
1004
+ for (const r of results) {
1005
+ if (r.success) {
1006
+ lines.push(` ${r.format.toUpperCase()}: ${path.basename(r.outputPath!)}`);
1007
+ } else {
1008
+ lines.push(` ${r.format.toUpperCase()}: FAILED - ${r.error}`);
1009
+ }
1010
+ }
1011
+
1012
+ return lines.join('\n');
1013
+ }