docrev 0.9.13 → 0.9.15

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