docrev 0.9.11 → 0.9.14

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 (138) hide show
  1. package/.claude/settings.local.json +9 -9
  2. package/.gitattributes +1 -1
  3. package/CHANGELOG.md +149 -149
  4. package/PLAN-tables-and-postprocess.md +850 -850
  5. package/README.md +391 -391
  6. package/bin/rev.js +11 -11
  7. package/bin/rev.ts +145 -145
  8. package/completions/rev.bash +127 -127
  9. package/completions/rev.ps1 +210 -210
  10. package/completions/rev.zsh +207 -207
  11. package/dev_notes/stress2/build_adversarial.ts +186 -186
  12. package/dev_notes/stress2/drift_matcher.ts +62 -62
  13. package/dev_notes/stress2/probe_anchors.ts +35 -35
  14. package/dev_notes/stress2/project/discussion.before.md +3 -3
  15. package/dev_notes/stress2/project/discussion.md +3 -3
  16. package/dev_notes/stress2/project/methods.before.md +20 -20
  17. package/dev_notes/stress2/project/methods.md +20 -20
  18. package/dev_notes/stress2/project/rev.yaml +5 -5
  19. package/dev_notes/stress2/project/sections.yaml +4 -4
  20. package/dev_notes/stress2/sections.yaml +5 -5
  21. package/dev_notes/stress2/trace_placement.ts +50 -50
  22. package/dev_notes/stresstest_boundaries.ts +27 -27
  23. package/dev_notes/stresstest_drift_apply.ts +43 -43
  24. package/dev_notes/stresstest_drift_compare.ts +43 -43
  25. package/dev_notes/stresstest_drift_v2.ts +54 -54
  26. package/dev_notes/stresstest_inspect.ts +54 -54
  27. package/dev_notes/stresstest_pstyle.ts +55 -55
  28. package/dev_notes/stresstest_section_debug.ts +23 -23
  29. package/dev_notes/stresstest_split.ts +70 -70
  30. package/dev_notes/stresstest_trace.ts +19 -19
  31. package/dev_notes/stresstest_verify_no_overwrite.ts +40 -40
  32. package/dist/lib/build.d.ts +50 -1
  33. package/dist/lib/build.d.ts.map +1 -1
  34. package/dist/lib/build.js +80 -30
  35. package/dist/lib/build.js.map +1 -1
  36. package/dist/lib/commands/build.d.ts.map +1 -1
  37. package/dist/lib/commands/build.js +38 -5
  38. package/dist/lib/commands/build.js.map +1 -1
  39. package/dist/lib/commands/utilities.js +164 -164
  40. package/dist/lib/commands/word-tools.js +8 -8
  41. package/dist/lib/grammar.js +3 -3
  42. package/dist/lib/import.d.ts.map +1 -1
  43. package/dist/lib/import.js +146 -24
  44. package/dist/lib/import.js.map +1 -1
  45. package/dist/lib/pdf-comments.js +44 -44
  46. package/dist/lib/plugins.js +57 -57
  47. package/dist/lib/pptx-themes.js +115 -115
  48. package/dist/lib/spelling.js +2 -2
  49. package/dist/lib/templates.js +387 -387
  50. package/dist/lib/themes.js +51 -51
  51. package/dist/lib/types.d.ts +20 -0
  52. package/dist/lib/types.d.ts.map +1 -1
  53. package/dist/lib/word-extraction.d.ts +6 -0
  54. package/dist/lib/word-extraction.d.ts.map +1 -1
  55. package/dist/lib/word-extraction.js +46 -3
  56. package/dist/lib/word-extraction.js.map +1 -1
  57. package/dist/lib/wordcomments.d.ts.map +1 -1
  58. package/dist/lib/wordcomments.js +23 -5
  59. package/dist/lib/wordcomments.js.map +1 -1
  60. package/eslint.config.js +27 -27
  61. package/lib/anchor-match.ts +276 -276
  62. package/lib/annotations.ts +644 -644
  63. package/lib/build.ts +1300 -1227
  64. package/lib/citations.ts +160 -160
  65. package/lib/commands/build.ts +833 -801
  66. package/lib/commands/citations.ts +515 -515
  67. package/lib/commands/comments.ts +1050 -1050
  68. package/lib/commands/context.ts +174 -174
  69. package/lib/commands/core.ts +309 -309
  70. package/lib/commands/doi.ts +435 -435
  71. package/lib/commands/file-ops.ts +372 -372
  72. package/lib/commands/history.ts +320 -320
  73. package/lib/commands/index.ts +87 -87
  74. package/lib/commands/init.ts +259 -259
  75. package/lib/commands/merge-resolve.ts +378 -378
  76. package/lib/commands/preview.ts +178 -178
  77. package/lib/commands/project-info.ts +244 -244
  78. package/lib/commands/quality.ts +517 -517
  79. package/lib/commands/response.ts +454 -454
  80. package/lib/commands/section-boundaries.ts +82 -82
  81. package/lib/commands/sections.ts +451 -451
  82. package/lib/commands/sync.ts +706 -706
  83. package/lib/commands/text-ops.ts +449 -449
  84. package/lib/commands/utilities.ts +448 -448
  85. package/lib/commands/verify-anchors.ts +272 -272
  86. package/lib/commands/word-tools.ts +340 -340
  87. package/lib/comment-realign.ts +517 -517
  88. package/lib/config.ts +84 -84
  89. package/lib/crossref.ts +781 -781
  90. package/lib/csl.ts +191 -191
  91. package/lib/dependencies.ts +98 -98
  92. package/lib/diff-engine.ts +465 -465
  93. package/lib/doi-cache.ts +115 -115
  94. package/lib/doi.ts +897 -897
  95. package/lib/equations.ts +506 -506
  96. package/lib/errors.ts +346 -346
  97. package/lib/format.ts +541 -541
  98. package/lib/git.ts +326 -326
  99. package/lib/grammar.ts +303 -303
  100. package/lib/image-registry.ts +180 -180
  101. package/lib/import.ts +911 -792
  102. package/lib/journals.ts +543 -543
  103. package/lib/merge.ts +633 -633
  104. package/lib/orcid.ts +144 -144
  105. package/lib/pdf-comments.ts +263 -263
  106. package/lib/pdf-import.ts +524 -524
  107. package/lib/plugins.ts +362 -362
  108. package/lib/postprocess.ts +188 -188
  109. package/lib/pptx-color-filter.lua +37 -37
  110. package/lib/pptx-template.ts +469 -469
  111. package/lib/pptx-themes.ts +483 -483
  112. package/lib/protect-restore.ts +520 -520
  113. package/lib/rate-limiter.ts +94 -94
  114. package/lib/response.ts +197 -197
  115. package/lib/restore-references.ts +240 -240
  116. package/lib/review.ts +327 -327
  117. package/lib/schema.ts +417 -417
  118. package/lib/scientific-words.ts +73 -73
  119. package/lib/sections.ts +335 -335
  120. package/lib/slides.ts +756 -756
  121. package/lib/spelling.ts +334 -334
  122. package/lib/templates.ts +526 -526
  123. package/lib/themes.ts +742 -742
  124. package/lib/trackchanges.ts +247 -247
  125. package/lib/tui.ts +450 -450
  126. package/lib/types.ts +550 -530
  127. package/lib/undo.ts +250 -250
  128. package/lib/utils.ts +69 -69
  129. package/lib/variables.ts +179 -179
  130. package/lib/word-extraction.ts +806 -759
  131. package/lib/word.ts +643 -643
  132. package/lib/wordcomments.ts +817 -798
  133. package/package.json +137 -137
  134. package/scripts/postbuild.js +28 -28
  135. package/skill/REFERENCE.md +431 -431
  136. package/skill/SKILL.md +258 -258
  137. package/tsconfig.json +26 -26
  138. package/types/index.d.ts +525 -525
package/lib/build.ts CHANGED
@@ -1,1227 +1,1300 @@
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, resolveSupplementaryRefs } 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, JournalFormatting } from './types.js';
25
- import { getJournalProfile } from './journals.js';
26
- import { resolveCSL } from './csl.js';
27
-
28
- // =============================================================================
29
- // Constants
30
- // =============================================================================
31
-
32
- /** Supported output formats */
33
- const SUPPORTED_FORMATS = ['pdf', 'docx', 'tex', 'beamer', 'pptx'] as const;
34
-
35
- /** Maximum title length for output filename */
36
- const MAX_TITLE_FILENAME_LENGTH = 50;
37
-
38
- // =============================================================================
39
- // Interfaces
40
- // =============================================================================
41
-
42
- export interface CrossrefConfig {
43
- figureTitle?: string;
44
- tableTitle?: string;
45
- figPrefix?: string | string[];
46
- tblPrefix?: string | string[];
47
- secPrefix?: string | string[];
48
- linkReferences?: boolean;
49
- }
50
-
51
- export interface PdfConfig {
52
- template?: string | null;
53
- headerIncludes?: string | null;
54
- documentclass?: string;
55
- fontsize?: string;
56
- geometry?: string;
57
- linestretch?: number;
58
- numbersections?: boolean;
59
- toc?: boolean;
60
- }
61
-
62
- export interface DocxConfig {
63
- reference?: string | null;
64
- keepComments?: boolean;
65
- toc?: boolean;
66
- }
67
-
68
- export interface TexConfig {
69
- standalone?: boolean;
70
- }
71
-
72
- export interface BeamerConfig {
73
- theme?: string;
74
- colortheme?: string | null;
75
- fonttheme?: string | null;
76
- aspectratio?: string | null;
77
- navigation?: string | null;
78
- section?: boolean;
79
- notes?: string | false;
80
- fit_images?: boolean;
81
- }
82
-
83
- export interface PptxConfig {
84
- theme?: string;
85
- reference?: string | null;
86
- media?: string | null;
87
- colors?: {
88
- default?: string;
89
- title?: string;
90
- };
91
- buildup?: {
92
- grey?: string;
93
- accent?: string;
94
- enabled?: boolean;
95
- };
96
- }
97
-
98
- export interface TablesConfig {
99
- nowrap?: string[];
100
- }
101
-
102
- export interface PostprocessConfig {
103
- pdf?: string | null;
104
- docx?: string | null;
105
- tex?: string | null;
106
- pptx?: string | null;
107
- beamer?: string | null;
108
- all?: string | null;
109
- [key: string]: string | null | undefined;
110
- }
111
-
112
- export interface BuildConfig {
113
- title: string;
114
- authors: (string | Author)[];
115
- affiliations: Record<string, string>;
116
- sections: string[];
117
- bibliography: string | null;
118
- csl: string | null;
119
- crossref: CrossrefConfig;
120
- pdf: PdfConfig;
121
- docx: DocxConfig;
122
- tex: TexConfig;
123
- beamer: BeamerConfig;
124
- pptx: PptxConfig;
125
- tables: TablesConfig;
126
- postprocess: PostprocessConfig;
127
- _configPath?: string | null;
128
- }
129
-
130
- export interface BuildResult {
131
- format: string;
132
- success: boolean;
133
- outputPath?: string;
134
- error?: string;
135
- }
136
-
137
- interface BuildOptions {
138
- verbose?: boolean;
139
- config?: BuildConfig;
140
- outputPath?: string;
141
- crossref?: boolean;
142
- _refsAutoInjected?: boolean;
143
- _forwardRefsResolved?: number;
144
- }
145
-
146
- interface CombineOptions extends BuildOptions {
147
- _refsAutoInjected?: boolean;
148
- }
149
-
150
- interface VariablesContext {
151
- sectionContents: string[];
152
- }
153
-
154
- interface PandocResult {
155
- outputPath: string;
156
- success: boolean;
157
- error?: string;
158
- }
159
-
160
- interface FullBuildResult {
161
- results: BuildResult[];
162
- paperPath: string;
163
- warnings: string[];
164
- forwardRefsResolved: number;
165
- refsAutoInjected?: boolean;
166
- }
167
-
168
- interface DynamicRef {
169
- type: string;
170
- label: string;
171
- match: string;
172
- position: number;
173
- }
174
-
175
- interface Registry {
176
- figures: Map<string, unknown>;
177
- tables: Map<string, unknown>;
178
- equations: Map<string, unknown>;
179
- byNumber: {
180
- fig?: Map<number, string>;
181
- figS?: Map<number, string>;
182
- tbl?: Map<number, string>;
183
- tblS?: Map<number, string>;
184
- eq?: Map<number, string>;
185
- };
186
- }
187
-
188
- /**
189
- * Default rev.yaml configuration
190
- */
191
- export const DEFAULT_CONFIG: BuildConfig = {
192
- title: 'Untitled Document',
193
- authors: [],
194
- affiliations: {},
195
- sections: [],
196
- bibliography: null,
197
- csl: null,
198
- crossref: {
199
- figureTitle: 'Figure',
200
- tableTitle: 'Table',
201
- figPrefix: ['Fig.', 'Figs.'],
202
- tblPrefix: ['Table', 'Tables'],
203
- secPrefix: ['Section', 'Sections'],
204
- linkReferences: true,
205
- },
206
- pdf: {
207
- template: null,
208
- documentclass: 'article',
209
- fontsize: '12pt',
210
- geometry: 'margin=1in',
211
- linestretch: 1.5,
212
- numbersections: false,
213
- toc: false,
214
- },
215
- docx: {
216
- reference: null,
217
- keepComments: true,
218
- toc: false,
219
- },
220
- tex: {
221
- standalone: true,
222
- },
223
- // Slide formats
224
- beamer: {
225
- theme: 'default',
226
- colortheme: null,
227
- fonttheme: null,
228
- aspectratio: null, // '169' for 16:9, '43' for 4:3
229
- navigation: null, // 'horizontal', 'vertical', 'frame', 'empty'
230
- section: true, // section divider slides
231
- notes: 'show', // 'show' (presenter view), 'only' (notes only), 'hide', or false
232
- fit_images: true, // scale images to fit within slide bounds
233
- },
234
- pptx: {
235
- theme: 'default', // Built-in theme: default, dark, academic, minimal, corporate
236
- reference: null, // Custom reference-doc (overrides theme)
237
- media: null, // directory with logo images (e.g., logo-left.png, logo-right.png)
238
- },
239
- // Table formatting
240
- tables: {
241
- nowrap: [], // Column headers to apply nowrap formatting (converts Normal() → $\mathcal{N}()$ etc.)
242
- },
243
- // Postprocess scripts
244
- postprocess: {
245
- pdf: null,
246
- docx: null,
247
- tex: null,
248
- pptx: null,
249
- beamer: null,
250
- all: null, // Runs after any format
251
- },
252
- };
253
-
254
- // =============================================================================
255
- // Public API
256
- // =============================================================================
257
-
258
- /**
259
- * Merge journal formatting defaults into a config.
260
- * Priority: DEFAULT_CONFIG < journal formatting < rev.yaml explicit settings
261
- */
262
- export function mergeJournalFormatting(config: BuildConfig, formatting: JournalFormatting, directory: string): BuildConfig {
263
- const merged = { ...config };
264
-
265
- // CSL: only apply if user hasn't set one
266
- if (formatting.csl && !config.csl) {
267
- const resolved = resolveCSL(formatting.csl, directory);
268
- if (resolved) {
269
- merged.csl = resolved;
270
- }
271
- // If not resolved locally, store the name — pandoc --citeproc
272
- // can sometimes resolve it, and the user can fetch with rev profiles --fetch-csl
273
- if (!resolved) {
274
- merged.csl = formatting.csl;
275
- }
276
- }
277
-
278
- // PDF settings: merge only unset fields
279
- if (formatting.pdf) {
280
- const userPdf = config.pdf || {};
281
- const defaults = DEFAULT_CONFIG.pdf;
282
- merged.pdf = { ...config.pdf };
283
- for (const [key, value] of Object.entries(formatting.pdf)) {
284
- const k = key as keyof PdfConfig;
285
- // Apply journal value only if user config matches the default (i.e., wasn't explicitly set)
286
- if (value !== undefined && JSON.stringify(userPdf[k]) === JSON.stringify(defaults[k])) {
287
- (merged.pdf as Record<string, unknown>)[k] = value;
288
- }
289
- }
290
- }
291
-
292
- // DOCX settings: merge only unset fields
293
- if (formatting.docx) {
294
- const userDocx = config.docx || {};
295
- const defaults = DEFAULT_CONFIG.docx;
296
- merged.docx = { ...config.docx };
297
- for (const [key, value] of Object.entries(formatting.docx)) {
298
- const k = key as keyof DocxConfig;
299
- if (value !== undefined && JSON.stringify(userDocx[k]) === JSON.stringify(defaults[k])) {
300
- (merged.docx as Record<string, unknown>)[k] = value;
301
- }
302
- }
303
- }
304
-
305
- // Crossref settings: merge only unset fields
306
- if (formatting.crossref) {
307
- const userCrossref = config.crossref || {};
308
- const defaults = DEFAULT_CONFIG.crossref;
309
- merged.crossref = { ...config.crossref };
310
- for (const [key, value] of Object.entries(formatting.crossref)) {
311
- const k = key as keyof CrossrefConfig;
312
- if (value !== undefined && JSON.stringify(userCrossref[k]) === JSON.stringify(defaults[k])) {
313
- (merged.crossref as Record<string, unknown>)[k] = value;
314
- }
315
- }
316
- }
317
-
318
- return merged;
319
- }
320
-
321
- /**
322
- * Load rev.yaml config from directory
323
- * @param directory - Project directory path
324
- * @returns Merged config with defaults
325
- * @throws {TypeError} If directory is not a string
326
- * @throws {Error} If rev.yaml exists but cannot be parsed
327
- */
328
- export function loadConfig(directory: string): BuildConfig {
329
- if (typeof directory !== 'string') {
330
- throw new TypeError(`directory must be a string, got ${typeof directory}`);
331
- }
332
-
333
- const configPath = path.join(directory, 'rev.yaml');
334
-
335
- if (!fs.existsSync(configPath)) {
336
- return { ...DEFAULT_CONFIG, _configPath: null };
337
- }
338
-
339
- try {
340
- const content = fs.readFileSync(configPath, 'utf-8');
341
- const userConfig = YAML.parse(content) || {};
342
-
343
- // Deep merge with defaults
344
- let config: BuildConfig = {
345
- ...DEFAULT_CONFIG,
346
- ...userConfig,
347
- crossref: { ...DEFAULT_CONFIG.crossref, ...userConfig.crossref },
348
- pdf: { ...DEFAULT_CONFIG.pdf, ...userConfig.pdf },
349
- docx: { ...DEFAULT_CONFIG.docx, ...userConfig.docx },
350
- tex: { ...DEFAULT_CONFIG.tex, ...userConfig.tex },
351
- beamer: { ...DEFAULT_CONFIG.beamer, ...userConfig.beamer },
352
- pptx: { ...DEFAULT_CONFIG.pptx, ...userConfig.pptx },
353
- tables: { ...DEFAULT_CONFIG.tables, ...userConfig.tables },
354
- postprocess: { ...DEFAULT_CONFIG.postprocess, ...userConfig.postprocess },
355
- _configPath: configPath,
356
- };
357
-
358
- // Apply journal formatting defaults (between DEFAULT_CONFIG and user settings)
359
- if (userConfig.journal) {
360
- const profile = getJournalProfile(userConfig.journal);
361
- if (profile?.formatting) {
362
- config = mergeJournalFormatting(config, profile.formatting, directory);
363
- }
364
- }
365
-
366
- return config;
367
- } catch (err) {
368
- const error = err as Error;
369
- throw new Error(`Failed to parse rev.yaml: ${error.message}`);
370
- }
371
- }
372
-
373
- /**
374
- * Find section files in directory
375
- * @param directory - Project directory path
376
- * @param configSections - Sections from rev.yaml (optional)
377
- * @returns Ordered list of section file names
378
- * @throws {TypeError} If directory is not a string
379
- */
380
- export function findSections(directory: string, configSections: string[] = []): string[] {
381
- if (typeof directory !== 'string') {
382
- throw new TypeError(`directory must be a string, got ${typeof directory}`);
383
- }
384
-
385
- // If sections specified in config, use that order
386
- if (configSections.length > 0) {
387
- const sections: string[] = [];
388
- for (const section of configSections) {
389
- const filePath = path.join(directory, section);
390
- if (fs.existsSync(filePath)) {
391
- sections.push(section);
392
- } else {
393
- console.warn(`Warning: Section file not found: ${section}`);
394
- }
395
- }
396
- return sections;
397
- }
398
-
399
- // Try sections.yaml
400
- const sectionsYamlPath = path.join(directory, 'sections.yaml');
401
- if (fs.existsSync(sectionsYamlPath)) {
402
- try {
403
- const sectionsConfig = YAML.parse(fs.readFileSync(sectionsYamlPath, 'utf-8'));
404
- if (sectionsConfig.sections) {
405
- return Object.entries(sectionsConfig.sections)
406
- .sort((a: [string, any], b: [string, any]) => (a[1].order ?? 999) - (b[1].order ?? 999))
407
- .map(([file]) => file)
408
- .filter((f) => fs.existsSync(path.join(directory, f)));
409
- }
410
- } catch (e) {
411
- if (process.env.DEBUG) {
412
- const error = e as Error;
413
- console.warn('build: YAML parse error in sections.yaml:', error.message);
414
- }
415
- }
416
- }
417
-
418
- // Default: find all .md files except special ones
419
- const exclude = ['paper.md', 'readme.md', 'claude.md'];
420
- const files = fs.readdirSync(directory).filter((f) => {
421
- if (!f.endsWith('.md')) return false;
422
- if (exclude.includes(f.toLowerCase())) return false;
423
- return true;
424
- });
425
-
426
- // Sort alphabetically as fallback
427
- return files.sort();
428
- }
429
-
430
- /**
431
- * Combine section files into paper.md
432
- */
433
- export function combineSections(directory: string, config: BuildConfig, options: CombineOptions = {}): string {
434
- const sections = findSections(directory, config.sections);
435
-
436
- if (sections.length === 0) {
437
- throw new Error('No section files found. Create .md files or specify sections in rev.yaml');
438
- }
439
-
440
- const parts: string[] = [];
441
-
442
- // Add YAML frontmatter
443
- const frontmatter = buildFrontmatter(config);
444
- parts.push('---');
445
- parts.push(YAML.stringify(frontmatter).trim());
446
- parts.push('---');
447
- parts.push('');
448
-
449
- // Read all section contents for variable processing
450
- const sectionContents: string[] = [];
451
-
452
- // Check if we need to auto-inject references before supplementary
453
- // Pandoc places refs at the end by default, which breaks when supplementary follows
454
- const hasRefsSection = sections.some(s =>
455
- s.toLowerCase().includes('reference') || s.toLowerCase().includes('refs')
456
- );
457
- const suppIndex = sections.findIndex(s =>
458
- s.toLowerCase().includes('supp') || s.toLowerCase().includes('appendix')
459
- );
460
- const hasBibliography = config.bibliography && fs.existsSync(path.join(directory, config.bibliography));
461
-
462
- // Track if we find an explicit refs div in any section
463
- let hasExplicitRefsDiv = false;
464
-
465
- // Combine sections
466
- for (let i = 0; i < sections.length; i++) {
467
- const section = sections[i];
468
- if (!section) continue;
469
- const filePath = path.join(directory, section);
470
- let content = fs.readFileSync(filePath, 'utf-8');
471
-
472
- // Remove any existing frontmatter from section files
473
- content = stripFrontmatter(content);
474
- sectionContents.push(content);
475
-
476
- // Check if this section has an explicit refs div
477
- if (content.includes('::: {#refs}') || content.includes('::: {#refs}')) {
478
- hasExplicitRefsDiv = true;
479
- }
480
-
481
- // Auto-inject references before supplementary if needed
482
- if (i === suppIndex && hasBibliography && !hasRefsSection && !hasExplicitRefsDiv) {
483
- parts.push('# References\n');
484
- parts.push('::: {#refs}');
485
- parts.push(':::');
486
- parts.push('');
487
- parts.push('');
488
- options._refsAutoInjected = true;
489
- }
490
-
491
- parts.push(content.trim());
492
- parts.push('');
493
- parts.push(''); // Double newline between sections
494
- }
495
-
496
- let paperContent = parts.join('\n');
497
-
498
- // Process template variables if any exist
499
- if (hasVariables(paperContent)) {
500
- paperContent = processVariables(paperContent, config as any, { sectionContents });
501
- }
502
-
503
- // Resolve forward references (refs that appear before their anchor definition)
504
- // This fixes pandoc-crossref limitation with multi-file documents
505
- if (hasPandocCrossref()) {
506
- const registry = buildRegistry(directory, sections);
507
- const { text, resolved } = resolveForwardRefs(paperContent, registry);
508
- if (resolved.length > 0) {
509
- paperContent = text;
510
- // Store resolved count for optional reporting
511
- options._forwardRefsResolved = resolved.length;
512
- }
513
-
514
- // Resolve supplementary references and strip their anchors.
515
- // pandoc-crossref cannot produce "Figure S1" numbering — it numbers all
516
- // figures sequentially. We resolve supplementary refs to plain text and
517
- // remove the {#fig:...} attributes so crossref ignores them.
518
- const supp = resolveSupplementaryRefs(paperContent, registry);
519
- if (supp.resolved.length > 0) {
520
- paperContent = supp.text;
521
- }
522
- }
523
-
524
- const paperPath = path.join(directory, 'paper.md');
525
-
526
- fs.writeFileSync(paperPath, paperContent, 'utf-8');
527
-
528
- return paperPath;
529
- }
530
-
531
- /**
532
- * Build YAML frontmatter from config
533
- */
534
- function buildFrontmatter(config: BuildConfig): Record<string, unknown> {
535
- const fm: Record<string, unknown> = {};
536
-
537
- if (config.title) fm.title = config.title;
538
-
539
- // Skip author in frontmatter when using numbered affiliations —
540
- // the author block is injected separately per format
541
- if (config.authors && config.authors.length > 0 && !hasNumberedAffiliations(config)) {
542
- fm.author = config.authors;
543
- }
544
-
545
- if (config.bibliography) {
546
- fm.bibliography = config.bibliography;
547
- }
548
-
549
- if (config.csl) {
550
- fm.csl = config.csl;
551
- }
552
-
553
- return fm;
554
- }
555
-
556
- /**
557
- * Strip YAML frontmatter from content
558
- */
559
- function stripFrontmatter(content: string): string {
560
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
561
- if (match) {
562
- return content.slice(match[0].length);
563
- }
564
- return content;
565
- }
566
-
567
- /**
568
- * Check if config uses numbered affiliation mode
569
- * (authors have `affiliations` arrays and an affiliations map is defined)
570
- */
571
- function hasNumberedAffiliations(config: BuildConfig): boolean {
572
- if (!config.affiliations || Object.keys(config.affiliations).length === 0) return false;
573
- return config.authors.some(a => typeof a !== 'string' && a.affiliations && a.affiliations.length > 0);
574
- }
575
-
576
- /**
577
- * Generate LaTeX author block using authblk package for numbered superscript affiliations.
578
- * Returns LaTeX code to be injected via header-includes.
579
- */
580
- function generateLatexAuthorBlock(config: BuildConfig): string {
581
- const lines: string[] = [];
582
- lines.push('\\usepackage{authblk}');
583
- lines.push('\\renewcommand\\Authfont{\\normalsize}');
584
- lines.push('\\renewcommand\\Affilfont{\\small}');
585
- lines.push('');
586
-
587
- // Map affiliation keys to numbers
588
- const affiliationKeys = Object.keys(config.affiliations);
589
- const keyToNum = new Map<string, number>();
590
- affiliationKeys.forEach((key, i) => keyToNum.set(key, i + 1));
591
-
592
- // Authors
593
- for (const author of config.authors) {
594
- if (typeof author === 'string') {
595
- lines.push(`\\author{${author}}`);
596
- continue;
597
- }
598
- const marks = (author.affiliations || [])
599
- .map(k => keyToNum.get(k))
600
- .filter((n): n is number => n !== undefined);
601
-
602
- const markStr = marks.length > 0 ? `[${marks.join(',')}]` : '';
603
- let nameStr = author.name;
604
- if (author.corresponding && author.email) {
605
- nameStr += `\\thanks{Corresponding author: ${author.email}}`;
606
- } else if (author.corresponding) {
607
- nameStr += '\\thanks{Corresponding author}';
608
- }
609
- lines.push(`\\author${markStr}{${nameStr}}`);
610
- }
611
-
612
- // Affiliations
613
- for (const [key, text] of Object.entries(config.affiliations)) {
614
- const num = keyToNum.get(key);
615
- if (num !== undefined) {
616
- lines.push(`\\affil[${num}]{${text}}`);
617
- }
618
- }
619
-
620
- return lines.join('\n');
621
- }
622
-
623
- /**
624
- * Generate markdown author block for DOCX output with superscript affiliations.
625
- * Returns markdown text to insert after the YAML frontmatter.
626
- */
627
- function generateMarkdownAuthorBlock(config: BuildConfig): string {
628
- const lines: string[] = [];
629
-
630
- // Map affiliation keys to numbers
631
- const affiliationKeys = Object.keys(config.affiliations);
632
- const keyToNum = new Map<string, number>();
633
- affiliationKeys.forEach((key, i) => keyToNum.set(key, i + 1));
634
-
635
- // Author line: Name^1,2^, Name^3^, ...
636
- const authorParts: string[] = [];
637
- for (const author of config.authors) {
638
- if (typeof author === 'string') {
639
- authorParts.push(author);
640
- continue;
641
- }
642
- const marks = (author.affiliations || [])
643
- .map(k => keyToNum.get(k))
644
- .filter((n): n is number => n !== undefined);
645
- let entry = author.name;
646
- const superParts = marks.map(String);
647
- if (author.corresponding) superParts.push('\\*');
648
- if (superParts.length > 0) {
649
- entry += `^${superParts.join(',')}^`;
650
- }
651
- authorParts.push(entry);
652
- }
653
- lines.push(authorParts.join(', '));
654
- lines.push('');
655
-
656
- // Affiliation lines: ^1^ Department of ...
657
- for (const [key, text] of Object.entries(config.affiliations)) {
658
- const num = keyToNum.get(key);
659
- if (num !== undefined) {
660
- lines.push(`^${num}^ ${text}`);
661
- }
662
- }
663
-
664
- // Corresponding author footnote
665
- const corresponding = config.authors.find(a => typeof a !== 'string' && a.corresponding) as Author | undefined;
666
- if (corresponding?.email) {
667
- lines.push('');
668
- lines.push(`^\\*^ Corresponding author: ${corresponding.email}`);
669
- }
670
-
671
- lines.push('');
672
- return lines.join('\n');
673
- }
674
-
675
- /**
676
- * Process markdown tables to apply nowrap formatting to specified columns.
677
- * Converts distribution notation (Normal, Student-t, Gamma) to LaTeX math.
678
- * @param content - Markdown content
679
- * @param tablesConfig - tables config from rev.yaml
680
- * @param format - output format (pdf, docx, etc.)
681
- * @returns processed content
682
- */
683
- export function processTablesForFormat(content: string, tablesConfig: TablesConfig, format: string): string {
684
- // Only process for PDF/TeX output
685
- if (format !== 'pdf' && format !== 'tex') {
686
- return content;
687
- }
688
-
689
- // Check if we have nowrap columns configured
690
- if (!tablesConfig?.nowrap?.length) {
691
- return content;
692
- }
693
-
694
- const nowrapPatterns = tablesConfig.nowrap.map((p) => p.toLowerCase());
695
-
696
- // Match pipe tables: header row, separator row, body rows
697
- // Header: | Col1 | Col2 | Col3 |
698
- // Separator: |:-----|:-----|:-----|
699
- // Body: | val1 | val2 | val3 |
700
- const tableRegex = /^(\|[^\n]+\|\r?\n\|[-:| ]+\|\r?\n)((?:\|[^\n]+\|\r?\n?)+)/gm;
701
-
702
- return content.replace(tableRegex, (match, headerAndSep, body) => {
703
- // Split header from separator
704
- const lines = headerAndSep.split(/\r?\n/);
705
- const headerLine = lines[0] ?? '';
706
-
707
- // Parse header cells to find nowrap column indices
708
- const headerCells = headerLine
709
- .split('|')
710
- .slice(1, -1)
711
- .map((c: string) => c.trim().toLowerCase());
712
-
713
- const nowrapCols: number[] = [];
714
- headerCells.forEach((cell: string, i: number) => {
715
- if (nowrapPatterns.some((p) => cell.includes(p))) {
716
- nowrapCols.push(i);
717
- }
718
- });
719
-
720
- // If no nowrap columns found in this table, return unchanged
721
- if (nowrapCols.length === 0) {
722
- return match;
723
- }
724
-
725
- // Process body rows
726
- const bodyLines = body.split(/\r?\n/).filter((l: string) => l.trim());
727
- const processedBody = bodyLines
728
- .map((row: string) => {
729
- // Split row into cells, keeping the pipe structure
730
- const cells = row.split('|');
731
- // cells[0] is empty (before first |), cells[last] is empty (after last |)
732
-
733
- nowrapCols.forEach((colIdx) => {
734
- const cellIdx = colIdx + 1; // Account for empty first element
735
- if (cells[cellIdx] !== undefined) {
736
- const cellContent = cells[cellIdx].trim();
737
-
738
- // Skip if empty, already math, or already has LaTeX commands
739
- if (!cellContent || cellContent.startsWith('$') || cellContent.startsWith('\\')) {
740
- return;
741
- }
742
-
743
- // Convert distribution notation to LaTeX math
744
- // Order matters: compound names (Half-Normal) must come before simple names (Normal)
745
- let processed = cellContent;
746
-
747
- // Half-Normal(x) $\text{Half-Normal}(x)$ (must come before Normal)
748
- processed = processed.replace(/Half-Normal\(([^)]+)\)/g, '$\\text{Half-Normal}($1)$');
749
-
750
- // Normal(x, y) $\mathcal{N}(x, y)$
751
- processed = processed.replace(/Normal\(([^)]+)\)/g, '$\\mathcal{N}($1)$');
752
-
753
- // Student-t(df, loc, scale) → $t_{df}(loc, scale)$
754
- processed = processed.replace(/Student-t\((\d+),\s*([^)]+)\)/g, '$t_{$1}($2)$');
755
-
756
- // Gamma(a, b) → $\text{Gamma}(a, b)$
757
- processed = processed.replace(/Gamma\(([^)]+)\)/g, '$\\text{Gamma}($1)$');
758
-
759
- // Exponential(x) $\text{Exp}(x)$
760
- processed = processed.replace(/Exponential\(([^)]+)\)/g, '$\\text{Exp}($1)$');
761
-
762
- // Update cell with padding
763
- cells[cellIdx] = ` ${processed} `;
764
- }
765
- });
766
-
767
- return cells.join('|');
768
- })
769
- .join('\n');
770
-
771
- return headerAndSep + processedBody + '\n';
772
- });
773
- }
774
-
775
- /**
776
- * Prepare paper.md for specific output format
777
- */
778
- export function prepareForFormat(
779
- paperPath: string,
780
- format: string,
781
- config: BuildConfig,
782
- options: BuildOptions = {}
783
- ): string {
784
- const directory = path.dirname(paperPath);
785
- let content = fs.readFileSync(paperPath, 'utf-8');
786
-
787
- // Build crossref registry for reference conversion
788
- // Pass sections from config to ensure correct file ordering
789
- const registry = buildRegistry(directory, config.sections);
790
-
791
- if (format === 'pdf' || format === 'tex') {
792
- // Strip all annotations for clean output
793
- content = stripAnnotations(content);
794
-
795
- // Process tables for nowrap columns (convert Normal() → $\mathcal{N}()$ etc.)
796
- content = processTablesForFormat(content, config.tables, format);
797
-
798
- // Inject LaTeX author block with numbered affiliations
799
- if (hasNumberedAffiliations(config)) {
800
- const latexBlock = generateLatexAuthorBlock(config);
801
- // Inject as header-includes in the YAML frontmatter
802
- content = content.replace(/^(---\r?\n[\s\S]*?)(---\r?\n)/, (match, yamlContent, closing) => {
803
- return `${yamlContent}header-includes: |\n${latexBlock.split('\n').map(l => ' ' + l).join('\n')}\n${closing}`;
804
- });
805
- }
806
- } else if (format === 'docx') {
807
- // Strip track changes, optionally keep comments
808
- content = stripAnnotations(content, { keepComments: config.docx.keepComments });
809
-
810
- // Convert @fig:label to "Figure 1" for Word readers
811
- content = convertDynamicRefsToDisplay(content, registry);
812
-
813
- // Inject markdown author block with superscript affiliations
814
- if (hasNumberedAffiliations(config)) {
815
- const mdBlock = generateMarkdownAuthorBlock(config);
816
- // Insert after YAML frontmatter, before body content
817
- content = content.replace(/^(---\r?\n[\s\S]*?---\r?\n)/, `$1\n${mdBlock}\n`);
818
- }
819
- } else if (format === 'beamer' || format === 'pptx') {
820
- // Strip annotations for slide output
821
- content = stripAnnotations(content);
822
-
823
- // Process slide syntax (::: step, ::: notes)
824
- if (hasSlideSyntax(content)) {
825
- content = processSlideMarkdown(content, format);
826
- }
827
- }
828
-
829
- // Write to temporary file
830
- const preparedPath = path.join(directory, `.paper-${format}.md`);
831
- fs.writeFileSync(preparedPath, content, 'utf-8');
832
-
833
- return preparedPath;
834
- }
835
-
836
- /**
837
- * Convert @fig:label references to display format (Figure 1)
838
- */
839
- function convertDynamicRefsToDisplay(text: string, registry: Registry): string {
840
- const refs = detectDynamicRefs(text);
841
-
842
- // Process in reverse order to preserve positions
843
- let result = text;
844
- for (let i = refs.length - 1; i >= 0; i--) {
845
- const ref = refs[i];
846
- if (!ref) continue;
847
- const display = labelToDisplay(ref.type, ref.label, registry as any);
848
-
849
- if (display) {
850
- result = result.slice(0, ref.position) + display + result.slice(ref.position + ref.match.length);
851
- }
852
- }
853
-
854
- return result;
855
- }
856
-
857
- /**
858
- * Build pandoc arguments for format
859
- */
860
- export function buildPandocArgs(format: string, config: BuildConfig, outputPath: string): string[] {
861
- const args: string[] = [];
862
-
863
- // Output format
864
- if (format === 'tex') {
865
- args.push('-t', 'latex');
866
- if (config.tex.standalone) {
867
- args.push('-s');
868
- }
869
- } else if (format === 'pdf') {
870
- args.push('-t', 'pdf');
871
- } else if (format === 'docx') {
872
- args.push('-t', 'docx');
873
- } else if (format === 'beamer') {
874
- args.push('-t', 'beamer');
875
- } else if (format === 'pptx') {
876
- args.push('-t', 'pptx');
877
- }
878
-
879
- // Output file (use basename since we set cwd to directory in runPandoc)
880
- args.push('-o', path.basename(outputPath));
881
-
882
- // Crossref filter (if available) - skip for slides
883
- if (hasPandocCrossref() && format !== 'beamer' && format !== 'pptx') {
884
- args.push('--filter', 'pandoc-crossref');
885
- }
886
-
887
- // Bibliography
888
- if (config.bibliography) {
889
- args.push('--citeproc');
890
- }
891
-
892
- // Format-specific options
893
- if (format === 'pdf') {
894
- if (config.pdf.template) {
895
- args.push('--template', config.pdf.template);
896
- }
897
- args.push('-V', `documentclass=${config.pdf.documentclass}`);
898
- args.push('-V', `fontsize=${config.pdf.fontsize}`);
899
- args.push('-V', `geometry:${config.pdf.geometry}`);
900
- if (config.pdf.headerIncludes) {
901
- args.push('-H', config.pdf.headerIncludes);
902
- }
903
- if (config.pdf.linestretch !== 1) {
904
- args.push('-V', `linestretch=${config.pdf.linestretch}`);
905
- }
906
- if (config.pdf.numbersections) {
907
- args.push('--number-sections');
908
- }
909
- if (config.pdf.toc) {
910
- args.push('--toc');
911
- }
912
- } else if (format === 'docx') {
913
- if (config.docx.reference) {
914
- args.push('--reference-doc', config.docx.reference);
915
- }
916
- if (config.docx.toc) {
917
- args.push('--toc');
918
- }
919
- } else if (format === 'beamer') {
920
- // Beamer slide options
921
- const beamer = config.beamer || {};
922
- if (beamer.theme) {
923
- args.push('-V', `theme=${beamer.theme}`);
924
- }
925
- if (beamer.colortheme) {
926
- args.push('-V', `colortheme=${beamer.colortheme}`);
927
- }
928
- if (beamer.fonttheme) {
929
- args.push('-V', `fonttheme=${beamer.fonttheme}`);
930
- }
931
- if (beamer.aspectratio) {
932
- args.push('-V', `aspectratio=${beamer.aspectratio}`);
933
- }
934
- if (beamer.navigation) {
935
- args.push('-V', `navigation=${beamer.navigation}`);
936
- }
937
- // Speaker notes - default to 'show' which creates presenter view PDF
938
- // Options: 'show' (dual screen), 'only' (notes only), 'hide' (no notes), false (disabled)
939
- const notesMode = beamer.notes !== undefined ? beamer.notes : 'show';
940
- if (notesMode && notesMode !== 'hide') {
941
- args.push('-V', `classoption=notes=${notesMode}`);
942
- }
943
- // Fit images within slide bounds (default: true)
944
- if (beamer.fit_images !== false) {
945
- const fitImagesHeader = `\\makeatletter
946
- \\def\\maxwidth{\\ifdim\\Gin@nat@width>\\linewidth\\linewidth\\else\\Gin@nat@width\\fi}
947
- \\def\\maxheight{\\ifdim\\Gin@nat@height>0.75\\textheight 0.75\\textheight\\else\\Gin@nat@height\\fi}
948
- \\makeatother
949
- \\setkeys{Gin}{width=\\maxwidth,height=\\maxheight,keepaspectratio}`;
950
- args.push('-V', `header-includes=${fitImagesHeader}`);
951
- }
952
- // Slides need standalone
953
- args.push('-s');
954
- } else if (format === 'pptx') {
955
- // PowerPoint options - handled separately in preparePptxTemplate
956
- // Reference doc is set by caller after template generation
957
- }
958
-
959
- return args;
960
- }
961
-
962
- /**
963
- * Write crossref.yaml if needed
964
- */
965
- function ensureCrossrefConfig(directory: string, config: BuildConfig): void {
966
- const crossrefPath = path.join(directory, 'crossref.yaml');
967
-
968
- if (!fs.existsSync(crossrefPath) && hasPandocCrossref()) {
969
- fs.writeFileSync(crossrefPath, YAML.stringify(config.crossref), 'utf-8');
970
- }
971
- }
972
-
973
- /**
974
- * Get install instructions for missing dependency
975
- */
976
- function getInstallInstructions(tool: string): string {
977
- const instructions: Record<string, string> = {
978
- pandoc: 'https://pandoc.org/installing.html',
979
- latex: 'https://www.latex-project.org/get/',
980
- };
981
- return instructions[tool] || 'Check documentation';
982
- }
983
-
984
- /**
985
- * Run pandoc build
986
- */
987
- export async function runPandoc(
988
- inputPath: string,
989
- format: string,
990
- config: BuildConfig,
991
- options: BuildOptions = {}
992
- ): Promise<PandocResult> {
993
- const directory = path.dirname(inputPath);
994
- const baseName = config.title
995
- ? config.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50)
996
- : 'paper';
997
-
998
- // Map format to file extension
999
- const extMap: Record<string, string> = {
1000
- tex: '.tex',
1001
- pdf: '.pdf',
1002
- docx: '.docx',
1003
- beamer: '.pdf', // beamer outputs PDF
1004
- pptx: '.pptx',
1005
- };
1006
- const ext = extMap[format] || '.pdf';
1007
-
1008
- // For beamer, use -slides suffix to distinguish from regular PDF
1009
- const suffix = format === 'beamer' ? '-slides' : '';
1010
- // Allow custom output path via options
1011
- const outputPath = options.outputPath || path.join(directory, `${baseName}${suffix}${ext}`);
1012
-
1013
- // Ensure crossref.yaml exists
1014
- ensureCrossrefConfig(directory, config);
1015
-
1016
- const args = buildPandocArgs(format, config, outputPath);
1017
-
1018
- // Handle PPTX reference template and themes
1019
- let pptxMediaDir: string | null = null;
1020
- if (format === 'pptx') {
1021
- const pptx = config.pptx || {};
1022
-
1023
- // Determine media directory (default: pptx/media or slides/media)
1024
- let mediaDir = pptx.media;
1025
- if (!mediaDir) {
1026
- if (fs.existsSync(path.join(directory, 'pptx', 'media'))) {
1027
- mediaDir = path.join(directory, 'pptx', 'media');
1028
- } else if (fs.existsSync(path.join(directory, 'slides', 'media'))) {
1029
- mediaDir = path.join(directory, 'slides', 'media');
1030
- }
1031
- } else if (!path.isAbsolute(mediaDir)) {
1032
- mediaDir = path.join(directory, mediaDir);
1033
- }
1034
- pptxMediaDir = mediaDir || null;
1035
-
1036
- // Determine reference doc: custom reference overrides theme
1037
- let referenceDoc: string | null = null;
1038
- if (pptx.reference && fs.existsSync(path.join(directory, pptx.reference))) {
1039
- // Custom reference doc takes precedence
1040
- referenceDoc = path.join(directory, pptx.reference);
1041
- } else {
1042
- // Use built-in theme (default: 'default')
1043
- const themeName = pptx.theme || 'default';
1044
- const themePath = getThemePath(themeName);
1045
- if (themePath && fs.existsSync(themePath)) {
1046
- referenceDoc = themePath;
1047
- }
1048
- }
1049
-
1050
- if (referenceDoc) {
1051
- args.push('--reference-doc', referenceDoc);
1052
- }
1053
-
1054
- // Add color filter for PPTX (handles [text]{color=#RRGGBB} syntax)
1055
- const colorFilterPath = path.join(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1')), 'pptx-color-filter.lua');
1056
- if (fs.existsSync(colorFilterPath)) {
1057
- args.push('--lua-filter', colorFilterPath);
1058
- }
1059
- }
1060
-
1061
- // Add crossref metadata file if exists (skip for slides - they don't use crossref)
1062
- if (format !== 'beamer' && format !== 'pptx') {
1063
- const crossrefPath = path.join(directory, 'crossref.yaml');
1064
- if (fs.existsSync(crossrefPath) && hasPandocCrossref()) {
1065
- // Use basename since we set cwd to directory
1066
- args.push('--metadata-file', 'crossref.yaml');
1067
- }
1068
- }
1069
-
1070
- // Input file (use basename since we set cwd to directory)
1071
- args.push(path.basename(inputPath));
1072
-
1073
- return new Promise((resolve) => {
1074
- const pandoc: ChildProcess = spawn('pandoc', args, {
1075
- cwd: directory,
1076
- stdio: ['ignore', 'pipe', 'pipe'],
1077
- });
1078
-
1079
- let stderr = '';
1080
- pandoc.stderr?.on('data', (data) => {
1081
- stderr += data.toString();
1082
- });
1083
-
1084
- pandoc.on('close', async (code) => {
1085
- if (code === 0) {
1086
- // For PPTX, post-process to add slide numbers, buildup colors, and logos
1087
- if (format === 'pptx') {
1088
- try {
1089
- // Inject slide numbers into content slides only
1090
- await injectSlideNumbers(outputPath);
1091
- } catch (e) {
1092
- // Slide number injection failed but output was created
1093
- }
1094
- try {
1095
- // Apply colors (default text color, title color, buildup greying)
1096
- const pptxConfig = config.pptx || {};
1097
- const colorsConfig = pptxConfig.colors || {};
1098
- const buildupConfig = pptxConfig.buildup || {};
1099
- // Merge colors and buildup config for applyBuildupColors
1100
- const colorConfig = {
1101
- default: colorsConfig.default,
1102
- title: colorsConfig.title,
1103
- grey: buildupConfig.grey,
1104
- accent: buildupConfig.accent,
1105
- enabled: buildupConfig.enabled
1106
- };
1107
- await applyBuildupColors(outputPath, colorConfig);
1108
- } catch (e) {
1109
- // Color application failed but output was created
1110
- }
1111
- // Inject logos into cover slide (if media dir configured)
1112
- if (pptxMediaDir) {
1113
- try {
1114
- await injectMediaIntoPptx(outputPath, pptxMediaDir);
1115
- } catch (e) {
1116
- // Logo injection failed but output was created
1117
- }
1118
- }
1119
- }
1120
-
1121
- // Run user postprocess scripts
1122
- const postResult = await runPostprocess(outputPath, format, config as unknown as Parameters<typeof runPostprocess>[2], options);
1123
- if (!postResult.success && options.verbose) {
1124
- console.error(`Postprocess warning: ${postResult.error}`);
1125
- }
1126
-
1127
- resolve({ outputPath, success: true });
1128
- } else {
1129
- resolve({ outputPath, success: false, error: stderr || `Exit code ${code}` });
1130
- }
1131
- });
1132
-
1133
- pandoc.on('error', (err) => {
1134
- resolve({ outputPath, success: false, error: err.message });
1135
- });
1136
- });
1137
- }
1138
-
1139
- /**
1140
- * Full build pipeline
1141
- */
1142
- export async function build(
1143
- directory: string,
1144
- formats: string[] = ['pdf', 'docx'],
1145
- options: BuildOptions = {}
1146
- ): Promise<FullBuildResult> {
1147
- const warnings: string[] = [];
1148
- let forwardRefsResolved = 0;
1149
-
1150
- // Check pandoc
1151
- if (!hasPandoc()) {
1152
- const instruction = getInstallInstructions('pandoc');
1153
- throw new Error(`Pandoc not found. Install with: ${instruction}\nOr run: rev doctor`);
1154
- }
1155
-
1156
- // Check LaTeX if PDF is requested
1157
- if ((formats.includes('pdf') || formats.includes('all')) && !hasLatex()) {
1158
- warnings.push(`LaTeX not found - PDF generation may fail. Install with: ${getInstallInstructions('latex')}`);
1159
- }
1160
-
1161
- // Check pandoc-crossref
1162
- if (!hasPandocCrossref()) {
1163
- warnings.push('pandoc-crossref not found - figure/table numbering will not work');
1164
- }
1165
-
1166
- // Load config (use passed config if provided, otherwise load from file)
1167
- const config = options.config || loadConfig(directory);
1168
-
1169
- // Combine sections paper.md
1170
- const buildOptions: CombineOptions = { ...options };
1171
- const paperPath = combineSections(directory, config, buildOptions);
1172
- forwardRefsResolved = buildOptions._forwardRefsResolved || 0;
1173
- const refsAutoInjected = buildOptions._refsAutoInjected || false;
1174
-
1175
- // Expand 'all' to all formats
1176
- if (formats.includes('all')) {
1177
- formats = ['pdf', 'docx', 'tex'];
1178
- }
1179
-
1180
- // Build and save image registry when DOCX is being built
1181
- // This allows import to restore proper image syntax from Word documents
1182
- if (formats.includes('docx')) {
1183
- const paperContent = fs.readFileSync(paperPath, 'utf-8');
1184
- const crossrefReg = buildRegistry(directory, config.sections);
1185
- const imageReg = buildImageRegistry(paperContent, crossrefReg as any);
1186
- if ((imageReg as any).figures?.length > 0) {
1187
- writeImageRegistry(directory, imageReg);
1188
- }
1189
- }
1190
-
1191
- const results: BuildResult[] = [];
1192
-
1193
- for (const format of formats) {
1194
- // Prepare format-specific version
1195
- const preparedPath = prepareForFormat(paperPath, format, config, options);
1196
-
1197
- // Run pandoc
1198
- const result = await runPandoc(preparedPath, format, config, options);
1199
- results.push({ format, ...result });
1200
-
1201
- // Clean up temp file
1202
- try {
1203
- fs.unlinkSync(preparedPath);
1204
- } catch {
1205
- // Ignore cleanup errors
1206
- }
1207
- }
1208
-
1209
- return { results, paperPath, warnings, forwardRefsResolved, refsAutoInjected };
1210
- }
1211
-
1212
- /**
1213
- * Get build status summary
1214
- */
1215
- export function formatBuildResults(results: BuildResult[]): string {
1216
- const lines: string[] = [];
1217
-
1218
- for (const r of results) {
1219
- if (r.success) {
1220
- lines.push(` ${r.format.toUpperCase()}: ${path.basename(r.outputPath!)}`);
1221
- } else {
1222
- lines.push(` ${r.format.toUpperCase()}: FAILED - ${r.error}`);
1223
- }
1224
- }
1225
-
1226
- return lines.join('\n');
1227
- }
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, resolveSupplementaryRefs } 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, JournalFormatting } from './types.js';
25
+ import { getJournalProfile } from './journals.js';
26
+ import { resolveCSL } from './csl.js';
27
+
28
+ // =============================================================================
29
+ // Constants
30
+ // =============================================================================
31
+
32
+ /** Supported output formats */
33
+ const SUPPORTED_FORMATS = ['pdf', 'docx', 'tex', 'beamer', 'pptx'] as const;
34
+
35
+ /** Maximum title length for output filename */
36
+ const MAX_TITLE_FILENAME_LENGTH = 50;
37
+
38
+ // =============================================================================
39
+ // Interfaces
40
+ // =============================================================================
41
+
42
+ export interface CrossrefConfig {
43
+ figureTitle?: string;
44
+ tableTitle?: string;
45
+ figPrefix?: string | string[];
46
+ tblPrefix?: string | string[];
47
+ secPrefix?: string | string[];
48
+ linkReferences?: boolean;
49
+ }
50
+
51
+ export interface PdfConfig {
52
+ template?: string | null;
53
+ headerIncludes?: string | null;
54
+ documentclass?: string;
55
+ fontsize?: string;
56
+ geometry?: string;
57
+ linestretch?: number;
58
+ numbersections?: boolean;
59
+ toc?: boolean;
60
+ /**
61
+ * LaTeX engine: pdflatex (default), xelatex, lualatex, tectonic, etc.
62
+ * xelatex/lualatex are required for native UTF-8 rendering of Latin-Extended
63
+ * diacritics (Czech/Polish/Croatian/Spanish author names, species epithets).
64
+ */
65
+ engine?: string;
66
+ /** Roman/serif main font (xelatex/lualatex only — uses fontspec). */
67
+ mainfont?: string;
68
+ /** Sans-serif font (xelatex/lualatex only). */
69
+ sansfont?: string;
70
+ /** Monospace font (xelatex/lualatex only). */
71
+ monofont?: string;
72
+ }
73
+
74
+ export interface DocxConfig {
75
+ reference?: string | null;
76
+ keepComments?: boolean;
77
+ toc?: boolean;
78
+ }
79
+
80
+ export interface TexConfig {
81
+ standalone?: boolean;
82
+ }
83
+
84
+ export interface BeamerConfig {
85
+ theme?: string;
86
+ colortheme?: string | null;
87
+ fonttheme?: string | null;
88
+ aspectratio?: string | null;
89
+ navigation?: string | null;
90
+ section?: boolean;
91
+ notes?: string | false;
92
+ fit_images?: boolean;
93
+ }
94
+
95
+ export interface PptxConfig {
96
+ theme?: string;
97
+ reference?: string | null;
98
+ media?: string | null;
99
+ colors?: {
100
+ default?: string;
101
+ title?: string;
102
+ };
103
+ buildup?: {
104
+ grey?: string;
105
+ accent?: string;
106
+ enabled?: boolean;
107
+ };
108
+ }
109
+
110
+ export interface TablesConfig {
111
+ nowrap?: string[];
112
+ }
113
+
114
+ export interface PostprocessConfig {
115
+ pdf?: string | null;
116
+ docx?: string | null;
117
+ tex?: string | null;
118
+ pptx?: string | null;
119
+ beamer?: string | null;
120
+ all?: string | null;
121
+ [key: string]: string | null | undefined;
122
+ }
123
+
124
+ export interface BuildConfig {
125
+ title: string;
126
+ authors: (string | Author)[];
127
+ affiliations: Record<string, string>;
128
+ sections: string[];
129
+ bibliography: string | null;
130
+ csl: string | null;
131
+ crossref: CrossrefConfig;
132
+ pdf: PdfConfig;
133
+ docx: DocxConfig;
134
+ tex: TexConfig;
135
+ beamer: BeamerConfig;
136
+ pptx: PptxConfig;
137
+ tables: TablesConfig;
138
+ postprocess: PostprocessConfig;
139
+ /**
140
+ * Directory (relative to the project) where final outputs land. Created on
141
+ * demand. Set to null/empty to keep outputs alongside paper.md (legacy
142
+ * behavior).
143
+ */
144
+ outputDir?: string | null;
145
+ _configPath?: string | null;
146
+ }
147
+
148
+ export interface BuildResult {
149
+ format: string;
150
+ success: boolean;
151
+ outputPath?: string;
152
+ error?: string;
153
+ }
154
+
155
+ interface BuildOptions {
156
+ verbose?: boolean;
157
+ config?: BuildConfig;
158
+ outputPath?: string;
159
+ crossref?: boolean;
160
+ _refsAutoInjected?: boolean;
161
+ _forwardRefsResolved?: number;
162
+ }
163
+
164
+ interface CombineOptions extends BuildOptions {
165
+ _refsAutoInjected?: boolean;
166
+ }
167
+
168
+ interface VariablesContext {
169
+ sectionContents: string[];
170
+ }
171
+
172
+ interface PandocResult {
173
+ outputPath: string;
174
+ success: boolean;
175
+ error?: string;
176
+ }
177
+
178
+ interface FullBuildResult {
179
+ results: BuildResult[];
180
+ paperPath: string;
181
+ warnings: string[];
182
+ forwardRefsResolved: number;
183
+ refsAutoInjected?: boolean;
184
+ }
185
+
186
+ interface DynamicRef {
187
+ type: string;
188
+ label: string;
189
+ match: string;
190
+ position: number;
191
+ }
192
+
193
+ interface Registry {
194
+ figures: Map<string, unknown>;
195
+ tables: Map<string, unknown>;
196
+ equations: Map<string, unknown>;
197
+ byNumber: {
198
+ fig?: Map<number, string>;
199
+ figS?: Map<number, string>;
200
+ tbl?: Map<number, string>;
201
+ tblS?: Map<number, string>;
202
+ eq?: Map<number, string>;
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Default rev.yaml configuration
208
+ */
209
+ export const DEFAULT_CONFIG: BuildConfig = {
210
+ title: 'Untitled Document',
211
+ authors: [],
212
+ affiliations: {},
213
+ sections: [],
214
+ bibliography: null,
215
+ csl: null,
216
+ crossref: {
217
+ figureTitle: 'Figure',
218
+ tableTitle: 'Table',
219
+ figPrefix: ['Fig.', 'Figs.'],
220
+ tblPrefix: ['Table', 'Tables'],
221
+ secPrefix: ['Section', 'Sections'],
222
+ linkReferences: true,
223
+ },
224
+ pdf: {
225
+ template: null,
226
+ documentclass: 'article',
227
+ fontsize: '12pt',
228
+ geometry: 'margin=1in',
229
+ linestretch: 1.5,
230
+ numbersections: false,
231
+ toc: false,
232
+ },
233
+ docx: {
234
+ reference: null,
235
+ keepComments: true,
236
+ toc: false,
237
+ },
238
+ tex: {
239
+ standalone: true,
240
+ },
241
+ // Slide formats
242
+ beamer: {
243
+ theme: 'default',
244
+ colortheme: null,
245
+ fonttheme: null,
246
+ aspectratio: null, // '169' for 16:9, '43' for 4:3
247
+ navigation: null, // 'horizontal', 'vertical', 'frame', 'empty'
248
+ section: true, // section divider slides
249
+ notes: 'show', // 'show' (presenter view), 'only' (notes only), 'hide', or false
250
+ fit_images: true, // scale images to fit within slide bounds
251
+ },
252
+ pptx: {
253
+ theme: 'default', // Built-in theme: default, dark, academic, minimal, corporate
254
+ reference: null, // Custom reference-doc (overrides theme)
255
+ media: null, // directory with logo images (e.g., logo-left.png, logo-right.png)
256
+ },
257
+ // Table formatting
258
+ tables: {
259
+ nowrap: [], // Column headers to apply nowrap formatting (converts Normal() $\mathcal{N}()$ etc.)
260
+ },
261
+ // Postprocess scripts
262
+ postprocess: {
263
+ pdf: null,
264
+ docx: null,
265
+ tex: null,
266
+ pptx: null,
267
+ beamer: null,
268
+ all: null, // Runs after any format
269
+ },
270
+ // Final outputs land here (created on demand). Set to null or '' to keep
271
+ // outputs in the project root.
272
+ outputDir: 'output',
273
+ };
274
+
275
+ // =============================================================================
276
+ // Public API
277
+ // =============================================================================
278
+
279
+ /**
280
+ * Merge journal formatting defaults into a config.
281
+ * Priority: DEFAULT_CONFIG < journal formatting < rev.yaml explicit settings
282
+ */
283
+ export function mergeJournalFormatting(config: BuildConfig, formatting: JournalFormatting, directory: string): BuildConfig {
284
+ const merged = { ...config };
285
+
286
+ // CSL: only apply if user hasn't set one
287
+ if (formatting.csl && !config.csl) {
288
+ const resolved = resolveCSL(formatting.csl, directory);
289
+ if (resolved) {
290
+ merged.csl = resolved;
291
+ }
292
+ // If not resolved locally, store the name — pandoc --citeproc
293
+ // can sometimes resolve it, and the user can fetch with rev profiles --fetch-csl
294
+ if (!resolved) {
295
+ merged.csl = formatting.csl;
296
+ }
297
+ }
298
+
299
+ // PDF settings: merge only unset fields
300
+ if (formatting.pdf) {
301
+ const userPdf = config.pdf || {};
302
+ const defaults = DEFAULT_CONFIG.pdf;
303
+ merged.pdf = { ...config.pdf };
304
+ for (const [key, value] of Object.entries(formatting.pdf)) {
305
+ const k = key as keyof PdfConfig;
306
+ // Apply journal value only if user config matches the default (i.e., wasn't explicitly set)
307
+ if (value !== undefined && JSON.stringify(userPdf[k]) === JSON.stringify(defaults[k])) {
308
+ (merged.pdf as Record<string, unknown>)[k] = value;
309
+ }
310
+ }
311
+ }
312
+
313
+ // DOCX settings: merge only unset fields
314
+ if (formatting.docx) {
315
+ const userDocx = config.docx || {};
316
+ const defaults = DEFAULT_CONFIG.docx;
317
+ merged.docx = { ...config.docx };
318
+ for (const [key, value] of Object.entries(formatting.docx)) {
319
+ const k = key as keyof DocxConfig;
320
+ if (value !== undefined && JSON.stringify(userDocx[k]) === JSON.stringify(defaults[k])) {
321
+ (merged.docx as Record<string, unknown>)[k] = value;
322
+ }
323
+ }
324
+ }
325
+
326
+ // Crossref settings: merge only unset fields
327
+ if (formatting.crossref) {
328
+ const userCrossref = config.crossref || {};
329
+ const defaults = DEFAULT_CONFIG.crossref;
330
+ merged.crossref = { ...config.crossref };
331
+ for (const [key, value] of Object.entries(formatting.crossref)) {
332
+ const k = key as keyof CrossrefConfig;
333
+ if (value !== undefined && JSON.stringify(userCrossref[k]) === JSON.stringify(defaults[k])) {
334
+ (merged.crossref as Record<string, unknown>)[k] = value;
335
+ }
336
+ }
337
+ }
338
+
339
+ return merged;
340
+ }
341
+
342
+ /**
343
+ * Load rev.yaml config from directory
344
+ * @param directory - Project directory path
345
+ * @returns Merged config with defaults
346
+ * @throws {TypeError} If directory is not a string
347
+ * @throws {Error} If rev.yaml exists but cannot be parsed
348
+ */
349
+ export function loadConfig(directory: string): BuildConfig {
350
+ if (typeof directory !== 'string') {
351
+ throw new TypeError(`directory must be a string, got ${typeof directory}`);
352
+ }
353
+
354
+ const configPath = path.join(directory, 'rev.yaml');
355
+
356
+ if (!fs.existsSync(configPath)) {
357
+ return { ...DEFAULT_CONFIG, _configPath: null };
358
+ }
359
+
360
+ try {
361
+ const content = fs.readFileSync(configPath, 'utf-8');
362
+ const userConfig = YAML.parse(content) || {};
363
+
364
+ // Deep merge with defaults
365
+ let config: BuildConfig = {
366
+ ...DEFAULT_CONFIG,
367
+ ...userConfig,
368
+ crossref: { ...DEFAULT_CONFIG.crossref, ...userConfig.crossref },
369
+ pdf: { ...DEFAULT_CONFIG.pdf, ...userConfig.pdf },
370
+ docx: { ...DEFAULT_CONFIG.docx, ...userConfig.docx },
371
+ tex: { ...DEFAULT_CONFIG.tex, ...userConfig.tex },
372
+ beamer: { ...DEFAULT_CONFIG.beamer, ...userConfig.beamer },
373
+ pptx: { ...DEFAULT_CONFIG.pptx, ...userConfig.pptx },
374
+ tables: { ...DEFAULT_CONFIG.tables, ...userConfig.tables },
375
+ postprocess: { ...DEFAULT_CONFIG.postprocess, ...userConfig.postprocess },
376
+ _configPath: configPath,
377
+ };
378
+
379
+ // Apply journal formatting defaults (between DEFAULT_CONFIG and user settings)
380
+ if (userConfig.journal) {
381
+ const profile = getJournalProfile(userConfig.journal);
382
+ if (profile?.formatting) {
383
+ config = mergeJournalFormatting(config, profile.formatting, directory);
384
+ }
385
+ }
386
+
387
+ return config;
388
+ } catch (err) {
389
+ const error = err as Error;
390
+ throw new Error(`Failed to parse rev.yaml: ${error.message}`);
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Find section files in directory
396
+ * @param directory - Project directory path
397
+ * @param configSections - Sections from rev.yaml (optional)
398
+ * @returns Ordered list of section file names
399
+ * @throws {TypeError} If directory is not a string
400
+ */
401
+ export function findSections(directory: string, configSections: string[] = []): string[] {
402
+ if (typeof directory !== 'string') {
403
+ throw new TypeError(`directory must be a string, got ${typeof directory}`);
404
+ }
405
+
406
+ // If sections specified in config, use that order
407
+ if (configSections.length > 0) {
408
+ const sections: string[] = [];
409
+ for (const section of configSections) {
410
+ const filePath = path.join(directory, section);
411
+ if (fs.existsSync(filePath)) {
412
+ sections.push(section);
413
+ } else {
414
+ console.warn(`Warning: Section file not found: ${section}`);
415
+ }
416
+ }
417
+ return sections;
418
+ }
419
+
420
+ // Try sections.yaml
421
+ const sectionsYamlPath = path.join(directory, 'sections.yaml');
422
+ if (fs.existsSync(sectionsYamlPath)) {
423
+ try {
424
+ const sectionsConfig = YAML.parse(fs.readFileSync(sectionsYamlPath, 'utf-8'));
425
+ if (sectionsConfig.sections) {
426
+ return Object.entries(sectionsConfig.sections)
427
+ .sort((a: [string, any], b: [string, any]) => (a[1].order ?? 999) - (b[1].order ?? 999))
428
+ .map(([file]) => file)
429
+ .filter((f) => fs.existsSync(path.join(directory, f)));
430
+ }
431
+ } catch (e) {
432
+ if (process.env.DEBUG) {
433
+ const error = e as Error;
434
+ console.warn('build: YAML parse error in sections.yaml:', error.message);
435
+ }
436
+ }
437
+ }
438
+
439
+ // Default: find all .md files except special ones
440
+ const exclude = ['paper.md', 'readme.md', 'claude.md'];
441
+ const files = fs.readdirSync(directory).filter((f) => {
442
+ if (!f.endsWith('.md')) return false;
443
+ if (exclude.includes(f.toLowerCase())) return false;
444
+ return true;
445
+ });
446
+
447
+ // Sort alphabetically as fallback
448
+ return files.sort();
449
+ }
450
+
451
+ /**
452
+ * Combine section files into paper.md
453
+ */
454
+ export function combineSections(directory: string, config: BuildConfig, options: CombineOptions = {}): string {
455
+ const sections = findSections(directory, config.sections);
456
+
457
+ if (sections.length === 0) {
458
+ throw new Error('No section files found. Create .md files or specify sections in rev.yaml');
459
+ }
460
+
461
+ const parts: string[] = [];
462
+
463
+ // Add YAML frontmatter
464
+ const frontmatter = buildFrontmatter(config);
465
+ parts.push('---');
466
+ parts.push(YAML.stringify(frontmatter).trim());
467
+ parts.push('---');
468
+ parts.push('');
469
+
470
+ // Read all section contents for variable processing
471
+ const sectionContents: string[] = [];
472
+
473
+ // Check if we need to auto-inject references before supplementary
474
+ // Pandoc places refs at the end by default, which breaks when supplementary follows
475
+ const hasRefsSection = sections.some(s =>
476
+ s.toLowerCase().includes('reference') || s.toLowerCase().includes('refs')
477
+ );
478
+ const suppIndex = sections.findIndex(s =>
479
+ s.toLowerCase().includes('supp') || s.toLowerCase().includes('appendix')
480
+ );
481
+ const hasBibliography = config.bibliography && fs.existsSync(path.join(directory, config.bibliography));
482
+
483
+ // Track if we find an explicit refs div in any section
484
+ let hasExplicitRefsDiv = false;
485
+
486
+ // Combine sections
487
+ for (let i = 0; i < sections.length; i++) {
488
+ const section = sections[i];
489
+ if (!section) continue;
490
+ const filePath = path.join(directory, section);
491
+ let content = fs.readFileSync(filePath, 'utf-8');
492
+
493
+ // Remove any existing frontmatter from section files
494
+ content = stripFrontmatter(content);
495
+ sectionContents.push(content);
496
+
497
+ // Check if this section has an explicit refs div
498
+ if (content.includes('::: {#refs}') || content.includes('::: {#refs}')) {
499
+ hasExplicitRefsDiv = true;
500
+ }
501
+
502
+ // Auto-inject references before supplementary if needed
503
+ if (i === suppIndex && hasBibliography && !hasRefsSection && !hasExplicitRefsDiv) {
504
+ parts.push('# References\n');
505
+ parts.push('::: {#refs}');
506
+ parts.push(':::');
507
+ parts.push('');
508
+ parts.push('');
509
+ options._refsAutoInjected = true;
510
+ }
511
+
512
+ parts.push(content.trim());
513
+ parts.push('');
514
+ parts.push(''); // Double newline between sections
515
+ }
516
+
517
+ let paperContent = parts.join('\n');
518
+
519
+ // Process template variables if any exist
520
+ if (hasVariables(paperContent)) {
521
+ paperContent = processVariables(paperContent, config as any, { sectionContents });
522
+ }
523
+
524
+ // Resolve forward references (refs that appear before their anchor definition)
525
+ // This fixes pandoc-crossref limitation with multi-file documents
526
+ if (hasPandocCrossref()) {
527
+ const registry = buildRegistry(directory, sections);
528
+ const { text, resolved } = resolveForwardRefs(paperContent, registry);
529
+ if (resolved.length > 0) {
530
+ paperContent = text;
531
+ // Store resolved count for optional reporting
532
+ options._forwardRefsResolved = resolved.length;
533
+ }
534
+
535
+ // Resolve supplementary references and strip their anchors.
536
+ // pandoc-crossref cannot produce "Figure S1" numbering — it numbers all
537
+ // figures sequentially. We resolve supplementary refs to plain text and
538
+ // remove the {#fig:...} attributes so crossref ignores them.
539
+ const supp = resolveSupplementaryRefs(paperContent, registry);
540
+ if (supp.resolved.length > 0) {
541
+ paperContent = supp.text;
542
+ }
543
+ }
544
+
545
+ const paperPath = path.join(directory, 'paper.md');
546
+
547
+ fs.writeFileSync(paperPath, paperContent, 'utf-8');
548
+
549
+ return paperPath;
550
+ }
551
+
552
+ /**
553
+ * Build YAML frontmatter from config
554
+ */
555
+ function buildFrontmatter(config: BuildConfig): Record<string, unknown> {
556
+ const fm: Record<string, unknown> = {};
557
+
558
+ if (config.title) fm.title = config.title;
559
+
560
+ // Skip author in frontmatter when using numbered affiliations —
561
+ // the author block is injected separately per format
562
+ if (config.authors && config.authors.length > 0 && !hasNumberedAffiliations(config)) {
563
+ fm.author = config.authors;
564
+ }
565
+
566
+ if (config.bibliography) {
567
+ fm.bibliography = config.bibliography;
568
+ }
569
+
570
+ if (config.csl) {
571
+ fm.csl = config.csl;
572
+ }
573
+
574
+ return fm;
575
+ }
576
+
577
+ /**
578
+ * Strip YAML frontmatter from content
579
+ */
580
+ function stripFrontmatter(content: string): string {
581
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
582
+ if (match) {
583
+ return content.slice(match[0].length);
584
+ }
585
+ return content;
586
+ }
587
+
588
+ /**
589
+ * Check if config uses numbered affiliation mode
590
+ * (authors have `affiliations` arrays and an affiliations map is defined)
591
+ */
592
+ function hasNumberedAffiliations(config: BuildConfig): boolean {
593
+ if (!config.affiliations || Object.keys(config.affiliations).length === 0) return false;
594
+ return config.authors.some(a => typeof a !== 'string' && a.affiliations && a.affiliations.length > 0);
595
+ }
596
+
597
+ /**
598
+ * Generate LaTeX author block using authblk package for numbered superscript affiliations.
599
+ * Returns LaTeX code to be injected via header-includes.
600
+ */
601
+ function generateLatexAuthorBlock(config: BuildConfig): string {
602
+ const lines: string[] = [];
603
+ lines.push('\\usepackage{authblk}');
604
+ lines.push('\\renewcommand\\Authfont{\\normalsize}');
605
+ lines.push('\\renewcommand\\Affilfont{\\small}');
606
+ lines.push('');
607
+
608
+ // Map affiliation keys to numbers
609
+ const affiliationKeys = Object.keys(config.affiliations);
610
+ const keyToNum = new Map<string, number>();
611
+ affiliationKeys.forEach((key, i) => keyToNum.set(key, i + 1));
612
+
613
+ // Authors
614
+ for (const author of config.authors) {
615
+ if (typeof author === 'string') {
616
+ lines.push(`\\author{${author}}`);
617
+ continue;
618
+ }
619
+ const marks = (author.affiliations || [])
620
+ .map(k => keyToNum.get(k))
621
+ .filter((n): n is number => n !== undefined);
622
+
623
+ const markStr = marks.length > 0 ? `[${marks.join(',')}]` : '';
624
+ let nameStr = author.name;
625
+ if (author.corresponding && author.email) {
626
+ nameStr += `\\thanks{Corresponding author: ${author.email}}`;
627
+ } else if (author.corresponding) {
628
+ nameStr += '\\thanks{Corresponding author}';
629
+ }
630
+ lines.push(`\\author${markStr}{${nameStr}}`);
631
+ }
632
+
633
+ // Affiliations
634
+ for (const [key, text] of Object.entries(config.affiliations)) {
635
+ const num = keyToNum.get(key);
636
+ if (num !== undefined) {
637
+ lines.push(`\\affil[${num}]{${text}}`);
638
+ }
639
+ }
640
+
641
+ return lines.join('\n');
642
+ }
643
+
644
+ /**
645
+ * Generate markdown author block for DOCX output with superscript affiliations.
646
+ * Returns markdown text to insert after the YAML frontmatter.
647
+ */
648
+ function generateMarkdownAuthorBlock(config: BuildConfig): string {
649
+ const lines: string[] = [];
650
+
651
+ // Map affiliation keys to numbers
652
+ const affiliationKeys = Object.keys(config.affiliations);
653
+ const keyToNum = new Map<string, number>();
654
+ affiliationKeys.forEach((key, i) => keyToNum.set(key, i + 1));
655
+
656
+ // Author line: Name^1,2^, Name^3^, ...
657
+ const authorParts: string[] = [];
658
+ for (const author of config.authors) {
659
+ if (typeof author === 'string') {
660
+ authorParts.push(author);
661
+ continue;
662
+ }
663
+ const marks = (author.affiliations || [])
664
+ .map(k => keyToNum.get(k))
665
+ .filter((n): n is number => n !== undefined);
666
+ let entry = author.name;
667
+ const superParts = marks.map(String);
668
+ if (author.corresponding) superParts.push('\\*');
669
+ if (superParts.length > 0) {
670
+ entry += `^${superParts.join(',')}^`;
671
+ }
672
+ authorParts.push(entry);
673
+ }
674
+ lines.push(authorParts.join(', '));
675
+ lines.push('');
676
+
677
+ // Affiliation lines: ^1^ Department of ...
678
+ for (const [key, text] of Object.entries(config.affiliations)) {
679
+ const num = keyToNum.get(key);
680
+ if (num !== undefined) {
681
+ lines.push(`^${num}^ ${text}`);
682
+ }
683
+ }
684
+
685
+ // Corresponding author footnote
686
+ const corresponding = config.authors.find(a => typeof a !== 'string' && a.corresponding) as Author | undefined;
687
+ if (corresponding?.email) {
688
+ lines.push('');
689
+ lines.push(`^\\*^ Corresponding author: ${corresponding.email}`);
690
+ }
691
+
692
+ lines.push('');
693
+ return lines.join('\n');
694
+ }
695
+
696
+ /**
697
+ * Process markdown tables to apply nowrap formatting to specified columns.
698
+ * Converts distribution notation (Normal, Student-t, Gamma) to LaTeX math.
699
+ * @param content - Markdown content
700
+ * @param tablesConfig - tables config from rev.yaml
701
+ * @param format - output format (pdf, docx, etc.)
702
+ * @returns processed content
703
+ */
704
+ export function processTablesForFormat(content: string, tablesConfig: TablesConfig, format: string): string {
705
+ // Only process for PDF/TeX output
706
+ if (format !== 'pdf' && format !== 'tex') {
707
+ return content;
708
+ }
709
+
710
+ // Check if we have nowrap columns configured
711
+ if (!tablesConfig?.nowrap?.length) {
712
+ return content;
713
+ }
714
+
715
+ const nowrapPatterns = tablesConfig.nowrap.map((p) => p.toLowerCase());
716
+
717
+ // Match pipe tables: header row, separator row, body rows
718
+ // Header: | Col1 | Col2 | Col3 |
719
+ // Separator: |:-----|:-----|:-----|
720
+ // Body: | val1 | val2 | val3 |
721
+ const tableRegex = /^(\|[^\n]+\|\r?\n\|[-:| ]+\|\r?\n)((?:\|[^\n]+\|\r?\n?)+)/gm;
722
+
723
+ return content.replace(tableRegex, (match, headerAndSep, body) => {
724
+ // Split header from separator
725
+ const lines = headerAndSep.split(/\r?\n/);
726
+ const headerLine = lines[0] ?? '';
727
+
728
+ // Parse header cells to find nowrap column indices
729
+ const headerCells = headerLine
730
+ .split('|')
731
+ .slice(1, -1)
732
+ .map((c: string) => c.trim().toLowerCase());
733
+
734
+ const nowrapCols: number[] = [];
735
+ headerCells.forEach((cell: string, i: number) => {
736
+ if (nowrapPatterns.some((p) => cell.includes(p))) {
737
+ nowrapCols.push(i);
738
+ }
739
+ });
740
+
741
+ // If no nowrap columns found in this table, return unchanged
742
+ if (nowrapCols.length === 0) {
743
+ return match;
744
+ }
745
+
746
+ // Process body rows
747
+ const bodyLines = body.split(/\r?\n/).filter((l: string) => l.trim());
748
+ const processedBody = bodyLines
749
+ .map((row: string) => {
750
+ // Split row into cells, keeping the pipe structure
751
+ const cells = row.split('|');
752
+ // cells[0] is empty (before first |), cells[last] is empty (after last |)
753
+
754
+ nowrapCols.forEach((colIdx) => {
755
+ const cellIdx = colIdx + 1; // Account for empty first element
756
+ if (cells[cellIdx] !== undefined) {
757
+ const cellContent = cells[cellIdx].trim();
758
+
759
+ // Skip if empty, already math, or already has LaTeX commands
760
+ if (!cellContent || cellContent.startsWith('$') || cellContent.startsWith('\\')) {
761
+ return;
762
+ }
763
+
764
+ // Convert distribution notation to LaTeX math
765
+ // Order matters: compound names (Half-Normal) must come before simple names (Normal)
766
+ let processed = cellContent;
767
+
768
+ // Half-Normal(x) → $\text{Half-Normal}(x)$ (must come before Normal)
769
+ processed = processed.replace(/Half-Normal\(([^)]+)\)/g, '$\\text{Half-Normal}($1)$');
770
+
771
+ // Normal(x, y) $\mathcal{N}(x, y)$
772
+ processed = processed.replace(/Normal\(([^)]+)\)/g, '$\\mathcal{N}($1)$');
773
+
774
+ // Student-t(df, loc, scale) → $t_{df}(loc, scale)$
775
+ processed = processed.replace(/Student-t\((\d+),\s*([^)]+)\)/g, '$t_{$1}($2)$');
776
+
777
+ // Gamma(a, b) → $\text{Gamma}(a, b)$
778
+ processed = processed.replace(/Gamma\(([^)]+)\)/g, '$\\text{Gamma}($1)$');
779
+
780
+ // Exponential(x) → $\text{Exp}(x)$
781
+ processed = processed.replace(/Exponential\(([^)]+)\)/g, '$\\text{Exp}($1)$');
782
+
783
+ // Update cell with padding
784
+ cells[cellIdx] = ` ${processed} `;
785
+ }
786
+ });
787
+
788
+ return cells.join('|');
789
+ })
790
+ .join('\n');
791
+
792
+ return headerAndSep + processedBody + '\n';
793
+ });
794
+ }
795
+
796
+ /**
797
+ * Apply format-specific transforms (table normalization, author blocks,
798
+ * crossref display conversion, slide syntax). Caller is responsible for
799
+ * stripping annotations beforehand — the dual-output paths keep comments
800
+ * in the markdown stream and need to apply these transforms separately
801
+ * from annotation handling.
802
+ *
803
+ * @param content - Markdown content (annotations already stripped as needed)
804
+ * @param format - Output format
805
+ * @param config - Build config
806
+ * @param registry - Crossref registry for the project
807
+ * @returns Transformed markdown
808
+ */
809
+ export function applyFormatTransforms(
810
+ content: string,
811
+ format: string,
812
+ config: BuildConfig,
813
+ registry: Registry
814
+ ): string {
815
+ if (format === 'pdf' || format === 'tex') {
816
+ content = processTablesForFormat(content, config.tables, format);
817
+
818
+ if (hasNumberedAffiliations(config)) {
819
+ const latexBlock = generateLatexAuthorBlock(config);
820
+ content = content.replace(/^(---\r?\n[\s\S]*?)(---\r?\n)/, (_match, yamlContent, closing) => {
821
+ return `${yamlContent}header-includes: |\n${latexBlock.split('\n').map(l => ' ' + l).join('\n')}\n${closing}`;
822
+ });
823
+ }
824
+ } else if (format === 'docx') {
825
+ content = convertDynamicRefsToDisplay(content, registry);
826
+
827
+ if (hasNumberedAffiliations(config)) {
828
+ const mdBlock = generateMarkdownAuthorBlock(config);
829
+ content = content.replace(/^(---\r?\n[\s\S]*?---\r?\n)/, `$1\n${mdBlock}\n`);
830
+ }
831
+ } else if (format === 'beamer' || format === 'pptx') {
832
+ if (hasSlideSyntax(content)) {
833
+ content = processSlideMarkdown(content, format);
834
+ }
835
+ }
836
+
837
+ return content;
838
+ }
839
+
840
+ /**
841
+ * Prepare paper.md for specific output format
842
+ */
843
+ export function prepareForFormat(
844
+ paperPath: string,
845
+ format: string,
846
+ config: BuildConfig,
847
+ _options: BuildOptions = {}
848
+ ): string {
849
+ const directory = path.dirname(paperPath);
850
+ let content = fs.readFileSync(paperPath, 'utf-8');
851
+
852
+ // Build crossref registry for reference conversion
853
+ // Pass sections from config to ensure correct file ordering
854
+ const registry = buildRegistry(directory, config.sections);
855
+
856
+ // Strip annotations per format
857
+ if (format === 'docx') {
858
+ content = stripAnnotations(content, { keepComments: config.docx.keepComments });
859
+ } else {
860
+ content = stripAnnotations(content);
861
+ }
862
+
863
+ // Apply shared format transforms
864
+ content = applyFormatTransforms(content, format, config, registry);
865
+
866
+ // Write to temporary file
867
+ const preparedPath = path.join(directory, `.paper-${format}.md`);
868
+ fs.writeFileSync(preparedPath, content, 'utf-8');
869
+
870
+ return preparedPath;
871
+ }
872
+
873
+ /**
874
+ * Convert @fig:label references to display format (Figure 1)
875
+ */
876
+ function convertDynamicRefsToDisplay(text: string, registry: Registry): string {
877
+ const refs = detectDynamicRefs(text);
878
+
879
+ // Process in reverse order to preserve positions
880
+ let result = text;
881
+ for (let i = refs.length - 1; i >= 0; i--) {
882
+ const ref = refs[i];
883
+ if (!ref) continue;
884
+ const display = labelToDisplay(ref.type, ref.label, registry as any);
885
+
886
+ if (display) {
887
+ result = result.slice(0, ref.position) + display + result.slice(ref.position + ref.match.length);
888
+ }
889
+ }
890
+
891
+ return result;
892
+ }
893
+
894
+ /**
895
+ * Build pandoc arguments for format
896
+ */
897
+ export function buildPandocArgs(format: string, config: BuildConfig, outputPath: string): string[] {
898
+ const args: string[] = [];
899
+
900
+ // Output format
901
+ if (format === 'tex') {
902
+ args.push('-t', 'latex');
903
+ if (config.tex.standalone) {
904
+ args.push('-s');
905
+ }
906
+ } else if (format === 'pdf') {
907
+ args.push('-t', 'pdf');
908
+ } else if (format === 'docx') {
909
+ args.push('-t', 'docx');
910
+ } else if (format === 'beamer') {
911
+ args.push('-t', 'beamer');
912
+ } else if (format === 'pptx') {
913
+ args.push('-t', 'pptx');
914
+ }
915
+
916
+ // Output file. runPandoc sets cwd to the project directory and passes a
917
+ // path relative to that cwd; passing it through here unchanged lets pandoc
918
+ // write to subdirectories like output/<title-slug>.<ext>.
919
+ args.push('-o', outputPath);
920
+
921
+ // Crossref filter (if available) - skip for slides
922
+ if (hasPandocCrossref() && format !== 'beamer' && format !== 'pptx') {
923
+ args.push('--filter', 'pandoc-crossref');
924
+ }
925
+
926
+ // Bibliography
927
+ if (config.bibliography) {
928
+ args.push('--citeproc');
929
+ }
930
+
931
+ // Format-specific options
932
+ if (format === 'pdf') {
933
+ if (config.pdf.template) {
934
+ args.push('--template', config.pdf.template);
935
+ }
936
+ if (config.pdf.engine) {
937
+ args.push(`--pdf-engine=${config.pdf.engine}`);
938
+ }
939
+ if (config.pdf.mainfont) {
940
+ args.push('-V', `mainfont=${config.pdf.mainfont}`);
941
+ }
942
+ if (config.pdf.sansfont) {
943
+ args.push('-V', `sansfont=${config.pdf.sansfont}`);
944
+ }
945
+ if (config.pdf.monofont) {
946
+ args.push('-V', `monofont=${config.pdf.monofont}`);
947
+ }
948
+ args.push('-V', `documentclass=${config.pdf.documentclass}`);
949
+ args.push('-V', `fontsize=${config.pdf.fontsize}`);
950
+ args.push('-V', `geometry:${config.pdf.geometry}`);
951
+ if (config.pdf.headerIncludes) {
952
+ args.push('-H', config.pdf.headerIncludes);
953
+ }
954
+ if (config.pdf.linestretch !== 1) {
955
+ args.push('-V', `linestretch=${config.pdf.linestretch}`);
956
+ }
957
+ if (config.pdf.numbersections) {
958
+ args.push('--number-sections');
959
+ }
960
+ if (config.pdf.toc) {
961
+ args.push('--toc');
962
+ }
963
+ } else if (format === 'docx') {
964
+ if (config.docx.reference) {
965
+ args.push('--reference-doc', config.docx.reference);
966
+ }
967
+ if (config.docx.toc) {
968
+ args.push('--toc');
969
+ }
970
+ } else if (format === 'beamer') {
971
+ // Beamer slide options
972
+ const beamer = config.beamer || {};
973
+ if (beamer.theme) {
974
+ args.push('-V', `theme=${beamer.theme}`);
975
+ }
976
+ if (beamer.colortheme) {
977
+ args.push('-V', `colortheme=${beamer.colortheme}`);
978
+ }
979
+ if (beamer.fonttheme) {
980
+ args.push('-V', `fonttheme=${beamer.fonttheme}`);
981
+ }
982
+ if (beamer.aspectratio) {
983
+ args.push('-V', `aspectratio=${beamer.aspectratio}`);
984
+ }
985
+ if (beamer.navigation) {
986
+ args.push('-V', `navigation=${beamer.navigation}`);
987
+ }
988
+ // Speaker notes - default to 'show' which creates presenter view PDF
989
+ // Options: 'show' (dual screen), 'only' (notes only), 'hide' (no notes), false (disabled)
990
+ const notesMode = beamer.notes !== undefined ? beamer.notes : 'show';
991
+ if (notesMode && notesMode !== 'hide') {
992
+ args.push('-V', `classoption=notes=${notesMode}`);
993
+ }
994
+ // Fit images within slide bounds (default: true)
995
+ if (beamer.fit_images !== false) {
996
+ const fitImagesHeader = `\\makeatletter
997
+ \\def\\maxwidth{\\ifdim\\Gin@nat@width>\\linewidth\\linewidth\\else\\Gin@nat@width\\fi}
998
+ \\def\\maxheight{\\ifdim\\Gin@nat@height>0.75\\textheight 0.75\\textheight\\else\\Gin@nat@height\\fi}
999
+ \\makeatother
1000
+ \\setkeys{Gin}{width=\\maxwidth,height=\\maxheight,keepaspectratio}`;
1001
+ args.push('-V', `header-includes=${fitImagesHeader}`);
1002
+ }
1003
+ // Slides need standalone
1004
+ args.push('-s');
1005
+ } else if (format === 'pptx') {
1006
+ // PowerPoint options - handled separately in preparePptxTemplate
1007
+ // Reference doc is set by caller after template generation
1008
+ }
1009
+
1010
+ return args;
1011
+ }
1012
+
1013
+ /**
1014
+ * Write crossref.yaml if needed
1015
+ */
1016
+ function ensureCrossrefConfig(directory: string, config: BuildConfig): void {
1017
+ const crossrefPath = path.join(directory, 'crossref.yaml');
1018
+
1019
+ if (!fs.existsSync(crossrefPath) && hasPandocCrossref()) {
1020
+ fs.writeFileSync(crossrefPath, YAML.stringify(config.crossref), 'utf-8');
1021
+ }
1022
+ }
1023
+
1024
+ /**
1025
+ * Get install instructions for missing dependency
1026
+ */
1027
+ function getInstallInstructions(tool: string): string {
1028
+ const instructions: Record<string, string> = {
1029
+ pandoc: 'https://pandoc.org/installing.html',
1030
+ latex: 'https://www.latex-project.org/get/',
1031
+ };
1032
+ return instructions[tool] || 'Check documentation';
1033
+ }
1034
+
1035
+ /**
1036
+ * Resolve the absolute directory where final outputs should land.
1037
+ * Honors config.outputDir; falls back to the project directory when null/empty.
1038
+ */
1039
+ export function resolveOutputDir(directory: string, config: BuildConfig): string {
1040
+ const out = config.outputDir;
1041
+ if (!out) return directory;
1042
+ return path.isAbsolute(out) ? out : path.join(directory, out);
1043
+ }
1044
+
1045
+ /**
1046
+ * Run pandoc build
1047
+ */
1048
+ export async function runPandoc(
1049
+ inputPath: string,
1050
+ format: string,
1051
+ config: BuildConfig,
1052
+ options: BuildOptions = {}
1053
+ ): Promise<PandocResult> {
1054
+ const directory = path.dirname(inputPath);
1055
+ const baseName = config.title
1056
+ ? config.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50)
1057
+ : 'paper';
1058
+
1059
+ // Map format to file extension
1060
+ const extMap: Record<string, string> = {
1061
+ tex: '.tex',
1062
+ pdf: '.pdf',
1063
+ docx: '.docx',
1064
+ beamer: '.pdf', // beamer outputs PDF
1065
+ pptx: '.pptx',
1066
+ };
1067
+ const ext = extMap[format] || '.pdf';
1068
+
1069
+ // For beamer, use -slides suffix to distinguish from regular PDF
1070
+ const suffix = format === 'beamer' ? '-slides' : '';
1071
+ // Allow custom output path via options. Auto-named outputs go through the
1072
+ // configured outputDir (default 'output/'); explicit paths are honored as-is
1073
+ // so callers can route temp/intermediate artefacts where they want.
1074
+ const outputPath = options.outputPath
1075
+ ? options.outputPath
1076
+ : path.join(resolveOutputDir(directory, config), `${baseName}${suffix}${ext}`);
1077
+
1078
+ if (!options.outputPath) {
1079
+ const outDir = path.dirname(outputPath);
1080
+ if (!fs.existsSync(outDir)) {
1081
+ fs.mkdirSync(outDir, { recursive: true });
1082
+ }
1083
+ }
1084
+
1085
+ // Ensure crossref.yaml exists
1086
+ ensureCrossrefConfig(directory, config);
1087
+
1088
+ // Pandoc runs with cwd = directory, so pass the output path relative to it.
1089
+ const args = buildPandocArgs(format, config, path.relative(directory, outputPath) || path.basename(outputPath));
1090
+
1091
+ // Handle PPTX reference template and themes
1092
+ let pptxMediaDir: string | null = null;
1093
+ if (format === 'pptx') {
1094
+ const pptx = config.pptx || {};
1095
+
1096
+ // Determine media directory (default: pptx/media or slides/media)
1097
+ let mediaDir = pptx.media;
1098
+ if (!mediaDir) {
1099
+ if (fs.existsSync(path.join(directory, 'pptx', 'media'))) {
1100
+ mediaDir = path.join(directory, 'pptx', 'media');
1101
+ } else if (fs.existsSync(path.join(directory, 'slides', 'media'))) {
1102
+ mediaDir = path.join(directory, 'slides', 'media');
1103
+ }
1104
+ } else if (!path.isAbsolute(mediaDir)) {
1105
+ mediaDir = path.join(directory, mediaDir);
1106
+ }
1107
+ pptxMediaDir = mediaDir || null;
1108
+
1109
+ // Determine reference doc: custom reference overrides theme
1110
+ let referenceDoc: string | null = null;
1111
+ if (pptx.reference && fs.existsSync(path.join(directory, pptx.reference))) {
1112
+ // Custom reference doc takes precedence
1113
+ referenceDoc = path.join(directory, pptx.reference);
1114
+ } else {
1115
+ // Use built-in theme (default: 'default')
1116
+ const themeName = pptx.theme || 'default';
1117
+ const themePath = getThemePath(themeName);
1118
+ if (themePath && fs.existsSync(themePath)) {
1119
+ referenceDoc = themePath;
1120
+ }
1121
+ }
1122
+
1123
+ if (referenceDoc) {
1124
+ args.push('--reference-doc', referenceDoc);
1125
+ }
1126
+
1127
+ // Add color filter for PPTX (handles [text]{color=#RRGGBB} syntax)
1128
+ const colorFilterPath = path.join(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1')), 'pptx-color-filter.lua');
1129
+ if (fs.existsSync(colorFilterPath)) {
1130
+ args.push('--lua-filter', colorFilterPath);
1131
+ }
1132
+ }
1133
+
1134
+ // Add crossref metadata file if exists (skip for slides - they don't use crossref)
1135
+ if (format !== 'beamer' && format !== 'pptx') {
1136
+ const crossrefPath = path.join(directory, 'crossref.yaml');
1137
+ if (fs.existsSync(crossrefPath) && hasPandocCrossref()) {
1138
+ // Use basename since we set cwd to directory
1139
+ args.push('--metadata-file', 'crossref.yaml');
1140
+ }
1141
+ }
1142
+
1143
+ // Input file (use basename since we set cwd to directory)
1144
+ args.push(path.basename(inputPath));
1145
+
1146
+ return new Promise((resolve) => {
1147
+ const pandoc: ChildProcess = spawn('pandoc', args, {
1148
+ cwd: directory,
1149
+ stdio: ['ignore', 'pipe', 'pipe'],
1150
+ });
1151
+
1152
+ let stderr = '';
1153
+ pandoc.stderr?.on('data', (data) => {
1154
+ stderr += data.toString();
1155
+ });
1156
+
1157
+ pandoc.on('close', async (code) => {
1158
+ if (code === 0) {
1159
+ // For PPTX, post-process to add slide numbers, buildup colors, and logos
1160
+ if (format === 'pptx') {
1161
+ try {
1162
+ // Inject slide numbers into content slides only
1163
+ await injectSlideNumbers(outputPath);
1164
+ } catch (e) {
1165
+ // Slide number injection failed but output was created
1166
+ }
1167
+ try {
1168
+ // Apply colors (default text color, title color, buildup greying)
1169
+ const pptxConfig = config.pptx || {};
1170
+ const colorsConfig = pptxConfig.colors || {};
1171
+ const buildupConfig = pptxConfig.buildup || {};
1172
+ // Merge colors and buildup config for applyBuildupColors
1173
+ const colorConfig = {
1174
+ default: colorsConfig.default,
1175
+ title: colorsConfig.title,
1176
+ grey: buildupConfig.grey,
1177
+ accent: buildupConfig.accent,
1178
+ enabled: buildupConfig.enabled
1179
+ };
1180
+ await applyBuildupColors(outputPath, colorConfig);
1181
+ } catch (e) {
1182
+ // Color application failed but output was created
1183
+ }
1184
+ // Inject logos into cover slide (if media dir configured)
1185
+ if (pptxMediaDir) {
1186
+ try {
1187
+ await injectMediaIntoPptx(outputPath, pptxMediaDir);
1188
+ } catch (e) {
1189
+ // Logo injection failed but output was created
1190
+ }
1191
+ }
1192
+ }
1193
+
1194
+ // Run user postprocess scripts
1195
+ const postResult = await runPostprocess(outputPath, format, config as unknown as Parameters<typeof runPostprocess>[2], options);
1196
+ if (!postResult.success && options.verbose) {
1197
+ console.error(`Postprocess warning: ${postResult.error}`);
1198
+ }
1199
+
1200
+ resolve({ outputPath, success: true });
1201
+ } else {
1202
+ resolve({ outputPath, success: false, error: stderr || `Exit code ${code}` });
1203
+ }
1204
+ });
1205
+
1206
+ pandoc.on('error', (err) => {
1207
+ resolve({ outputPath, success: false, error: err.message });
1208
+ });
1209
+ });
1210
+ }
1211
+
1212
+ /**
1213
+ * Full build pipeline
1214
+ */
1215
+ export async function build(
1216
+ directory: string,
1217
+ formats: string[] = ['pdf', 'docx'],
1218
+ options: BuildOptions = {}
1219
+ ): Promise<FullBuildResult> {
1220
+ const warnings: string[] = [];
1221
+ let forwardRefsResolved = 0;
1222
+
1223
+ // Check pandoc
1224
+ if (!hasPandoc()) {
1225
+ const instruction = getInstallInstructions('pandoc');
1226
+ throw new Error(`Pandoc not found. Install with: ${instruction}\nOr run: rev doctor`);
1227
+ }
1228
+
1229
+ // Check LaTeX if PDF is requested
1230
+ if ((formats.includes('pdf') || formats.includes('all')) && !hasLatex()) {
1231
+ warnings.push(`LaTeX not found - PDF generation may fail. Install with: ${getInstallInstructions('latex')}`);
1232
+ }
1233
+
1234
+ // Check pandoc-crossref
1235
+ if (!hasPandocCrossref()) {
1236
+ warnings.push('pandoc-crossref not found - figure/table numbering will not work');
1237
+ }
1238
+
1239
+ // Load config (use passed config if provided, otherwise load from file)
1240
+ const config = options.config || loadConfig(directory);
1241
+
1242
+ // Combine sections → paper.md
1243
+ const buildOptions: CombineOptions = { ...options };
1244
+ const paperPath = combineSections(directory, config, buildOptions);
1245
+ forwardRefsResolved = buildOptions._forwardRefsResolved || 0;
1246
+ const refsAutoInjected = buildOptions._refsAutoInjected || false;
1247
+
1248
+ // Expand 'all' to all formats
1249
+ if (formats.includes('all')) {
1250
+ formats = ['pdf', 'docx', 'tex'];
1251
+ }
1252
+
1253
+ // Build and save image registry when DOCX is being built
1254
+ // This allows import to restore proper image syntax from Word documents
1255
+ if (formats.includes('docx')) {
1256
+ const paperContent = fs.readFileSync(paperPath, 'utf-8');
1257
+ const crossrefReg = buildRegistry(directory, config.sections);
1258
+ const imageReg = buildImageRegistry(paperContent, crossrefReg as any);
1259
+ if ((imageReg as any).figures?.length > 0) {
1260
+ writeImageRegistry(directory, imageReg);
1261
+ }
1262
+ }
1263
+
1264
+ const results: BuildResult[] = [];
1265
+
1266
+ for (const format of formats) {
1267
+ // Prepare format-specific version
1268
+ const preparedPath = prepareForFormat(paperPath, format, config, options);
1269
+
1270
+ // Run pandoc
1271
+ const result = await runPandoc(preparedPath, format, config, options);
1272
+ results.push({ format, ...result });
1273
+
1274
+ // Clean up temp file
1275
+ try {
1276
+ fs.unlinkSync(preparedPath);
1277
+ } catch {
1278
+ // Ignore cleanup errors
1279
+ }
1280
+ }
1281
+
1282
+ return { results, paperPath, warnings, forwardRefsResolved, refsAutoInjected };
1283
+ }
1284
+
1285
+ /**
1286
+ * Get build status summary
1287
+ */
1288
+ export function formatBuildResults(results: BuildResult[]): string {
1289
+ const lines: string[] = [];
1290
+
1291
+ for (const r of results) {
1292
+ if (r.success) {
1293
+ lines.push(` ${r.format.toUpperCase()}: ${path.basename(r.outputPath!)}`);
1294
+ } else {
1295
+ lines.push(` ${r.format.toUpperCase()}: FAILED - ${r.error}`);
1296
+ }
1297
+ }
1298
+
1299
+ return lines.join('\n');
1300
+ }