docrev 0.11.1 → 0.11.2

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 (116) hide show
  1. package/.gitattributes +1 -1
  2. package/CHANGELOG.md +202 -197
  3. package/PLAN-tables-and-postprocess.md +850 -850
  4. package/README.md +433 -433
  5. package/bin/rev.js +11 -11
  6. package/bin/rev.ts +145 -145
  7. package/completions/rev.bash +127 -127
  8. package/completions/rev.ps1 +210 -210
  9. package/completions/rev.zsh +207 -207
  10. package/dist/lib/build.d.ts +28 -0
  11. package/dist/lib/build.d.ts.map +1 -1
  12. package/dist/lib/build.js +81 -4
  13. package/dist/lib/build.js.map +1 -1
  14. package/dist/lib/commands/utilities.js +164 -164
  15. package/dist/lib/commands/word-tools.js +8 -8
  16. package/dist/lib/grammar.js +3 -3
  17. package/dist/lib/macro-filter.lua +201 -201
  18. package/dist/lib/pdf-comments.js +44 -44
  19. package/dist/lib/plugins.js +57 -57
  20. package/dist/lib/pptx-color-filter.lua +37 -37
  21. package/dist/lib/pptx-themes.js +115 -115
  22. package/dist/lib/schema.d.ts.map +1 -1
  23. package/dist/lib/schema.js +8 -2
  24. package/dist/lib/schema.js.map +1 -1
  25. package/dist/lib/spelling.js +2 -2
  26. package/dist/lib/templates.js +387 -387
  27. package/dist/lib/themes.js +51 -51
  28. package/eslint.config.js +27 -27
  29. package/lib/anchor-match.ts +307 -307
  30. package/lib/annotations.ts +664 -664
  31. package/lib/build.ts +2047 -1956
  32. package/lib/citations.ts +160 -160
  33. package/lib/commands/build.ts +885 -885
  34. package/lib/commands/citations.ts +515 -515
  35. package/lib/commands/comments.ts +1050 -1050
  36. package/lib/commands/context.ts +176 -176
  37. package/lib/commands/core.ts +309 -309
  38. package/lib/commands/doi.ts +451 -451
  39. package/lib/commands/file-ops.ts +372 -372
  40. package/lib/commands/history.ts +320 -320
  41. package/lib/commands/index.ts +87 -87
  42. package/lib/commands/init.ts +259 -259
  43. package/lib/commands/merge-resolve.ts +378 -378
  44. package/lib/commands/preview.ts +178 -178
  45. package/lib/commands/project-info.ts +244 -244
  46. package/lib/commands/quality.ts +517 -517
  47. package/lib/commands/response.ts +454 -454
  48. package/lib/commands/section-boundaries.ts +82 -82
  49. package/lib/commands/sections.ts +451 -451
  50. package/lib/commands/sync.ts +689 -689
  51. package/lib/commands/text-ops.ts +449 -449
  52. package/lib/commands/utilities.ts +448 -448
  53. package/lib/commands/verify-anchors.ts +272 -272
  54. package/lib/commands/word-tools.ts +340 -340
  55. package/lib/comment-realign.ts +99 -99
  56. package/lib/config.ts +84 -84
  57. package/lib/crossref.ts +781 -781
  58. package/lib/csl.ts +191 -191
  59. package/lib/dependencies.ts +132 -132
  60. package/lib/diff-engine.ts +465 -465
  61. package/lib/doi-cache.ts +115 -115
  62. package/lib/doi.ts +879 -879
  63. package/lib/equations.ts +506 -506
  64. package/lib/errors.ts +350 -350
  65. package/lib/format.ts +541 -541
  66. package/lib/git.ts +326 -326
  67. package/lib/grammar.ts +303 -303
  68. package/lib/image-registry.ts +180 -180
  69. package/lib/import.ts +906 -906
  70. package/lib/journals.ts +543 -543
  71. package/lib/macro-filter.lua +201 -201
  72. package/lib/macros.ts +273 -273
  73. package/lib/merge.ts +633 -633
  74. package/lib/ooxml.ts +768 -768
  75. package/lib/orcid.ts +144 -144
  76. package/lib/pdf-comments.ts +263 -263
  77. package/lib/pdf-import.ts +524 -524
  78. package/lib/plugins.ts +362 -362
  79. package/lib/postprocess.ts +188 -188
  80. package/lib/pptx-color-filter.lua +37 -37
  81. package/lib/pptx-template.ts +469 -469
  82. package/lib/pptx-themes.ts +483 -483
  83. package/lib/protect-restore.ts +520 -520
  84. package/lib/rate-limiter.ts +127 -127
  85. package/lib/response.ts +197 -197
  86. package/lib/restore-references.ts +240 -240
  87. package/lib/review.ts +327 -327
  88. package/lib/schema.ts +494 -488
  89. package/lib/scientific-words.ts +73 -73
  90. package/lib/sections.ts +428 -428
  91. package/lib/slides.ts +756 -756
  92. package/lib/spelling.ts +334 -334
  93. package/lib/templates.ts +526 -526
  94. package/lib/themes.ts +742 -742
  95. package/lib/trackchanges.ts +166 -166
  96. package/lib/tui.ts +450 -450
  97. package/lib/types.ts +546 -546
  98. package/lib/undo.ts +250 -250
  99. package/lib/utils.ts +69 -69
  100. package/lib/variables.ts +179 -179
  101. package/lib/word-extraction.ts +525 -525
  102. package/lib/word.ts +526 -526
  103. package/lib/wordcomments.ts +789 -789
  104. package/package.json +1 -1
  105. package/scripts/postbuild.js +47 -47
  106. package/skill/REFERENCE.md +550 -550
  107. package/skill/SKILL.md +302 -302
  108. package/tsconfig.json +26 -26
  109. package/types/index.d.ts +531 -531
  110. package/issues.md +0 -180
  111. package/site/assets/extra.css +0 -208
  112. package/site/commands.html +0 -926
  113. package/site/configuration.html +0 -469
  114. package/site/index.html +0 -288
  115. package/site/troubleshooting.html +0 -461
  116. package/site/workflow.html +0 -518
package/lib/build.ts CHANGED
@@ -1,1956 +1,2047 @@
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 { fileURLToPath } from 'url';
14
- import { execSync, spawn, ChildProcess } from 'child_process';
15
- import YAML from 'yaml';
16
- import { stripAnnotations } from './annotations.js';
17
- import { criticToNativeTrackChanges, enableTrackRevisions, type NativeTrackChangeStats } from './trackchanges.js';
18
- import { prepareMarkdownWithMarkers, injectCommentsAtMarkers } from './wordcomments.js';
19
- import { buildRegistry, labelToDisplay, detectDynamicRefs, resolveForwardRefs, resolveSupplementaryRefs } from './crossref.js';
20
- import { processVariables, hasVariables } from './variables.js';
21
- import { processSlideMarkdown, hasSlideSyntax } from './slides.js';
22
- import { generatePptxTemplate, templateNeedsRegeneration, injectMediaIntoPptx, injectSlideNumbers, applyThemeFonts, applyCentering, applyBuildupColors } from './pptx-template.js';
23
- import { getThemePath, getThemeNames, PPTX_THEMES } from './pptx-themes.js';
24
- import { runPostprocess } from './postprocess.js';
25
- import { hasPandoc, hasPandocCrossref, hasLatex } from './dependencies.js';
26
- import { buildImageRegistry, writeImageRegistry } from './image-registry.js';
27
- import type { Author, JournalFormatting } from './types.js';
28
- import { getJournalProfile } from './journals.js';
29
- import { resolveCSL } from './csl.js';
30
- import {
31
- type MacroDef,
32
- mergeMacros,
33
- generateLatexPreamble,
34
- writeMacrosSidecar,
35
- getMacroFilterPath,
36
- } from './macros.js';
37
-
38
- // =============================================================================
39
- // Constants
40
- // =============================================================================
41
-
42
- /** Supported output formats */
43
- const SUPPORTED_FORMATS = ['pdf', 'docx', 'tex', 'beamer', 'pptx'] as const;
44
-
45
- /**
46
- * Maximum length for slugified-title output filenames. Only used when no
47
- * explicit `output:` filename is configured. Long titles are truncated at the
48
- * last `-` boundary at-or-before this length so words stay intact (the old
49
- * blind `.slice(0, 50)` cut mid-word).
50
- */
51
- const MAX_TITLE_FILENAME_LENGTH = 80;
52
-
53
- // =============================================================================
54
- // Interfaces
55
- // =============================================================================
56
-
57
- export interface CrossrefConfig {
58
- figureTitle?: string;
59
- tableTitle?: string;
60
- figPrefix?: string | string[];
61
- tblPrefix?: string | string[];
62
- secPrefix?: string | string[];
63
- linkReferences?: boolean;
64
- }
65
-
66
- export interface PdfConfig {
67
- template?: string | null;
68
- headerIncludes?: string | null;
69
- documentclass?: string;
70
- fontsize?: string;
71
- geometry?: string;
72
- linestretch?: number;
73
- numbersections?: boolean;
74
- toc?: boolean;
75
- /**
76
- * LaTeX engine: pdflatex (default), xelatex, lualatex, tectonic, etc.
77
- * xelatex/lualatex are required for native UTF-8 rendering of Latin-Extended
78
- * diacritics (Czech/Polish/Croatian/Spanish author names, species epithets).
79
- */
80
- engine?: string;
81
- /** Roman/serif main font (xelatex/lualatex only — uses fontspec). */
82
- mainfont?: string;
83
- /** Sans-serif font (xelatex/lualatex only). */
84
- sansfont?: string;
85
- /** Monospace font (xelatex/lualatex only). */
86
- monofont?: string;
87
- /** Extra pandoc args appended for this format (after top-level pandocArgs). */
88
- pandocArgs?: string[];
89
- }
90
-
91
- export interface DocxConfig {
92
- reference?: string | null;
93
- keepComments?: boolean;
94
- affiliationNewline?: boolean;
95
- toc?: boolean;
96
- pandocArgs?: string[];
97
- /**
98
- * Auto-translate the common-shape raw `\begin{figure}...\end{figure}` block
99
- * to portable `![caption](path){#fig:label width=N%}` markdown so figures
100
- * survive the docx build (pandoc otherwise drops raw LaTeX silently).
101
- * Default true. Set false to opt out — blocks then warn and are left alone.
102
- */
103
- translateRawFigures?: boolean;
104
- }
105
-
106
- export interface TexConfig {
107
- standalone?: boolean;
108
- pandocArgs?: string[];
109
- }
110
-
111
- export interface BeamerConfig {
112
- theme?: string;
113
- colortheme?: string | null;
114
- fonttheme?: string | null;
115
- aspectratio?: string | null;
116
- navigation?: string | null;
117
- section?: boolean;
118
- notes?: string | false;
119
- fit_images?: boolean;
120
- pandocArgs?: string[];
121
- }
122
-
123
- export interface PptxConfig {
124
- theme?: string;
125
- reference?: string | null;
126
- media?: string | null;
127
- colors?: {
128
- default?: string;
129
- title?: string;
130
- };
131
- buildup?: {
132
- grey?: string;
133
- accent?: string;
134
- enabled?: boolean;
135
- };
136
- pandocArgs?: string[];
137
- }
138
-
139
- export interface TablesConfig {
140
- nowrap?: string[];
141
- }
142
-
143
- export interface PostprocessConfig {
144
- pdf?: string | null;
145
- docx?: string | null;
146
- tex?: string | null;
147
- pptx?: string | null;
148
- beamer?: string | null;
149
- all?: string | null;
150
- [key: string]: string | null | undefined;
151
- }
152
-
153
- export interface BuildConfig {
154
- title: string;
155
- authors: (string | Author)[];
156
- affiliations: Record<string, string>;
157
- sections: string[];
158
- bibliography: string | null;
159
- csl: string | null;
160
- crossref: CrossrefConfig;
161
- pdf: PdfConfig;
162
- docx: DocxConfig;
163
- tex: TexConfig;
164
- beamer: BeamerConfig;
165
- pptx: PptxConfig;
166
- tables: TablesConfig;
167
- postprocess: PostprocessConfig;
168
- /**
169
- * User-declared placeholder macros. Merged with the built-in macros
170
- * (currently \tofill). Each entry overrides a built-in by name.
171
- *
172
- * See lib/macros.ts for the per-format rendering rules.
173
- */
174
- macros?: MacroDef[];
175
- /**
176
- * Directory (relative to the project) where final outputs land. Created on
177
- * demand. Set to null/empty to keep outputs alongside paper.md (legacy
178
- * behavior).
179
- */
180
- outputDir?: string | null;
181
- /**
182
- * Per-format output filenames. Keys are format names (pdf/docx/tex/beamer/
183
- * pptx); values are paths. Relative paths resolve under outputDir; absolute
184
- * paths are honored as-is. Extension is added if missing. CLI `-o` wins
185
- * over this map.
186
- */
187
- output?: Record<string, string>;
188
- /**
189
- * Extra pandoc args applied to every format. Format-specific args
190
- * (e.g. docx.pandocArgs) are appended *after* these, and CLI --pandoc-arg
191
- * values are appended last.
192
- */
193
- pandocArgs?: string[];
194
- _configPath?: string | null;
195
- }
196
-
197
- export interface BuildResult {
198
- format: string;
199
- success: boolean;
200
- outputPath?: string;
201
- error?: string;
202
- }
203
-
204
- interface BuildOptions {
205
- verbose?: boolean;
206
- config?: BuildConfig;
207
- /**
208
- * Internal: forces the exact output path. Used by dual-mode/temp builds that
209
- * route to specific temp files. Bypasses the `output:` resolver.
210
- */
211
- outputPath?: string;
212
- /**
213
- * CLI override (`-o, --output <path>`). Beats `config.output[format]` but
214
- * loses to `options.outputPath`. Relative paths resolve under outputDir;
215
- * absolute paths bypass outputDir.
216
- */
217
- output?: string;
218
- crossref?: boolean;
219
- /** Extra pandoc args from CLI (--pandoc-arg). Appended after config args. */
220
- pandocArgs?: string[];
221
- _refsAutoInjected?: boolean;
222
- _forwardRefsResolved?: number;
223
- }
224
-
225
- interface CombineOptions extends BuildOptions {
226
- _refsAutoInjected?: boolean;
227
- }
228
-
229
- interface VariablesContext {
230
- sectionContents: string[];
231
- }
232
-
233
- interface PandocResult {
234
- outputPath: string;
235
- success: boolean;
236
- error?: string;
237
- }
238
-
239
- interface FullBuildResult {
240
- results: BuildResult[];
241
- paperPath: string;
242
- warnings: string[];
243
- forwardRefsResolved: number;
244
- refsAutoInjected?: boolean;
245
- }
246
-
247
- interface DynamicRef {
248
- type: string;
249
- label: string;
250
- match: string;
251
- position: number;
252
- }
253
-
254
- interface Registry {
255
- figures: Map<string, unknown>;
256
- tables: Map<string, unknown>;
257
- equations: Map<string, unknown>;
258
- byNumber: {
259
- fig?: Map<number, string>;
260
- figS?: Map<number, string>;
261
- tbl?: Map<number, string>;
262
- tblS?: Map<number, string>;
263
- eq?: Map<number, string>;
264
- };
265
- }
266
-
267
- /**
268
- * Default rev.yaml configuration
269
- */
270
- export const DEFAULT_CONFIG: BuildConfig = {
271
- title: 'Untitled Document',
272
- authors: [],
273
- affiliations: {},
274
- sections: [],
275
- bibliography: null,
276
- csl: null,
277
- crossref: {
278
- figureTitle: 'Figure',
279
- tableTitle: 'Table',
280
- figPrefix: ['Fig.', 'Figs.'],
281
- tblPrefix: ['Table', 'Tables'],
282
- secPrefix: ['Section', 'Sections'],
283
- linkReferences: true,
284
- },
285
- pdf: {
286
- template: null,
287
- documentclass: 'article',
288
- fontsize: '12pt',
289
- geometry: 'margin=1in',
290
- linestretch: 1.5,
291
- numbersections: false,
292
- toc: false,
293
- },
294
- docx: {
295
- reference: null,
296
- keepComments: false,
297
- affiliationNewline: true,
298
- toc: false,
299
- translateRawFigures: true,
300
- },
301
- tex: {
302
- standalone: true,
303
- },
304
- // Slide formats
305
- beamer: {
306
- theme: 'default',
307
- colortheme: null,
308
- fonttheme: null,
309
- aspectratio: null, // '169' for 16:9, '43' for 4:3
310
- navigation: null, // 'horizontal', 'vertical', 'frame', 'empty'
311
- section: true, // section divider slides
312
- notes: 'show', // 'show' (presenter view), 'only' (notes only), 'hide', or false
313
- fit_images: true, // scale images to fit within slide bounds
314
- },
315
- pptx: {
316
- theme: 'default', // Built-in theme: default, dark, academic, minimal, corporate
317
- reference: null, // Custom reference-doc (overrides theme)
318
- media: null, // directory with logo images (e.g., logo-left.png, logo-right.png)
319
- },
320
- // Table formatting
321
- tables: {
322
- nowrap: [], // Column headers to apply nowrap formatting (converts Normal() → $\mathcal{N}()$ etc.)
323
- },
324
- // Postprocess scripts
325
- postprocess: {
326
- pdf: null,
327
- docx: null,
328
- tex: null,
329
- pptx: null,
330
- beamer: null,
331
- all: null, // Runs after any format
332
- },
333
- // Placeholder/highlight macros. Defaults are the built-ins from
334
- // lib/macros.ts; users append their own here.
335
- macros: [],
336
- // Final outputs land here (created on demand). Set to null or '' to keep
337
- // outputs in the project root.
338
- outputDir: 'output',
339
- };
340
-
341
- // =============================================================================
342
- // Public API
343
- // =============================================================================
344
-
345
- /**
346
- * Merge journal formatting defaults into a config.
347
- * Priority: DEFAULT_CONFIG < journal formatting < rev.yaml explicit settings
348
- */
349
- export function mergeJournalFormatting(config: BuildConfig, formatting: JournalFormatting, directory: string): BuildConfig {
350
- const merged = { ...config };
351
-
352
- // CSL: only apply if user hasn't set one
353
- if (formatting.csl && !config.csl) {
354
- const resolved = resolveCSL(formatting.csl, directory);
355
- if (resolved) {
356
- merged.csl = resolved;
357
- }
358
- // If not resolved locally, store the name pandoc --citeproc
359
- // can sometimes resolve it, and the user can fetch with rev profiles --fetch-csl
360
- if (!resolved) {
361
- merged.csl = formatting.csl;
362
- }
363
- }
364
-
365
- // PDF settings: merge only unset fields
366
- if (formatting.pdf) {
367
- const userPdf = config.pdf || {};
368
- const defaults = DEFAULT_CONFIG.pdf;
369
- merged.pdf = { ...config.pdf };
370
- for (const [key, value] of Object.entries(formatting.pdf)) {
371
- const k = key as keyof PdfConfig;
372
- // Apply journal value only if user config matches the default (i.e., wasn't explicitly set)
373
- if (value !== undefined && JSON.stringify(userPdf[k]) === JSON.stringify(defaults[k])) {
374
- (merged.pdf as Record<string, unknown>)[k] = value;
375
- }
376
- }
377
- }
378
-
379
- // DOCX settings: merge only unset fields
380
- if (formatting.docx) {
381
- const userDocx = config.docx || {};
382
- const defaults = DEFAULT_CONFIG.docx;
383
- merged.docx = { ...config.docx };
384
- for (const [key, value] of Object.entries(formatting.docx)) {
385
- const k = key as keyof DocxConfig;
386
- if (value !== undefined && JSON.stringify(userDocx[k]) === JSON.stringify(defaults[k])) {
387
- (merged.docx as Record<string, unknown>)[k] = value;
388
- }
389
- }
390
- }
391
-
392
- // Crossref settings: merge only unset fields
393
- if (formatting.crossref) {
394
- const userCrossref = config.crossref || {};
395
- const defaults = DEFAULT_CONFIG.crossref;
396
- merged.crossref = { ...config.crossref };
397
- for (const [key, value] of Object.entries(formatting.crossref)) {
398
- const k = key as keyof CrossrefConfig;
399
- if (value !== undefined && JSON.stringify(userCrossref[k]) === JSON.stringify(defaults[k])) {
400
- (merged.crossref as Record<string, unknown>)[k] = value;
401
- }
402
- }
403
- }
404
-
405
- return merged;
406
- }
407
-
408
- /**
409
- * In-place: copy `pandoc-args` → `pandocArgs` on an object (if not already set).
410
- * Idempotent. Coerces a single string into a one-element array.
411
- */
412
- function normalizePandocArgsKey(obj: Record<string, unknown>): void {
413
- if (!obj || typeof obj !== 'object') return;
414
- const hy = obj['pandoc-args'];
415
- if (hy === undefined) return;
416
- if (obj.pandocArgs === undefined) {
417
- obj.pandocArgs = Array.isArray(hy) ? hy : [hy];
418
- }
419
- delete obj['pandoc-args'];
420
- }
421
-
422
- /**
423
- * Load rev.yaml config from directory
424
- * @param directory - Project directory path
425
- * @returns Merged config with defaults
426
- * @throws {TypeError} If directory is not a string
427
- * @throws {Error} If rev.yaml exists but cannot be parsed
428
- */
429
- export function loadConfig(directory: string): BuildConfig {
430
- if (typeof directory !== 'string') {
431
- throw new TypeError(`directory must be a string, got ${typeof directory}`);
432
- }
433
-
434
- const configPath = path.join(directory, 'rev.yaml');
435
-
436
- if (!fs.existsSync(configPath)) {
437
- return { ...DEFAULT_CONFIG, _configPath: null };
438
- }
439
-
440
- try {
441
- const content = fs.readFileSync(configPath, 'utf-8');
442
- const userConfig = YAML.parse(content) || {};
443
-
444
- // Accept hyphenated `pandoc-args` (the form pandoc itself uses) in addition
445
- // to camelCase `pandocArgs`. Hyphenated is what we document; camelCase is
446
- // accepted for users who already prefer that convention.
447
- normalizePandocArgsKey(userConfig);
448
- for (const fmt of ['pdf', 'docx', 'tex', 'beamer', 'pptx'] as const) {
449
- if (userConfig[fmt] && typeof userConfig[fmt] === 'object') {
450
- normalizePandocArgsKey(userConfig[fmt]);
451
- }
452
- }
453
-
454
- // Deep merge with defaults
455
- let config: BuildConfig = {
456
- ...DEFAULT_CONFIG,
457
- ...userConfig,
458
- crossref: { ...DEFAULT_CONFIG.crossref, ...userConfig.crossref },
459
- pdf: { ...DEFAULT_CONFIG.pdf, ...userConfig.pdf },
460
- docx: { ...DEFAULT_CONFIG.docx, ...userConfig.docx },
461
- tex: { ...DEFAULT_CONFIG.tex, ...userConfig.tex },
462
- beamer: { ...DEFAULT_CONFIG.beamer, ...userConfig.beamer },
463
- pptx: { ...DEFAULT_CONFIG.pptx, ...userConfig.pptx },
464
- tables: { ...DEFAULT_CONFIG.tables, ...userConfig.tables },
465
- postprocess: { ...DEFAULT_CONFIG.postprocess, ...userConfig.postprocess },
466
- _configPath: configPath,
467
- };
468
-
469
- // Apply journal formatting defaults (between DEFAULT_CONFIG and user settings)
470
- if (userConfig.journal) {
471
- const profile = getJournalProfile(userConfig.journal);
472
- if (profile?.formatting) {
473
- config = mergeJournalFormatting(config, profile.formatting, directory);
474
- }
475
- }
476
-
477
- return config;
478
- } catch (err) {
479
- const error = err as Error;
480
- throw new Error(`Failed to parse rev.yaml: ${error.message}`);
481
- }
482
- }
483
-
484
- /**
485
- * Find section files in directory
486
- * @param directory - Project directory path
487
- * @param configSections - Sections from rev.yaml (optional)
488
- * @returns Ordered list of section file names
489
- * @throws {TypeError} If directory is not a string
490
- */
491
- export function findSections(directory: string, configSections: string[] = []): string[] {
492
- if (typeof directory !== 'string') {
493
- throw new TypeError(`directory must be a string, got ${typeof directory}`);
494
- }
495
-
496
- // If sections specified in config, use that order
497
- if (configSections.length > 0) {
498
- const sections: string[] = [];
499
- for (const section of configSections) {
500
- const filePath = path.join(directory, section);
501
- if (fs.existsSync(filePath)) {
502
- sections.push(section);
503
- } else {
504
- console.warn(`Warning: Section file not found: ${section}`);
505
- }
506
- }
507
- return sections;
508
- }
509
-
510
- // Try sections.yaml
511
- const sectionsYamlPath = path.join(directory, 'sections.yaml');
512
- if (fs.existsSync(sectionsYamlPath)) {
513
- try {
514
- const sectionsConfig = YAML.parse(fs.readFileSync(sectionsYamlPath, 'utf-8'));
515
- if (sectionsConfig.sections) {
516
- return Object.entries(sectionsConfig.sections)
517
- .sort((a: [string, any], b: [string, any]) => (a[1].order ?? 999) - (b[1].order ?? 999))
518
- .map(([file]) => file)
519
- .filter((f) => fs.existsSync(path.join(directory, f)));
520
- }
521
- } catch (e) {
522
- if (process.env.DEBUG) {
523
- const error = e as Error;
524
- console.warn('build: YAML parse error in sections.yaml:', error.message);
525
- }
526
- }
527
- }
528
-
529
- // Default: find all .md files except special ones
530
- const exclude = ['paper.md', 'readme.md', 'claude.md'];
531
- const files = fs.readdirSync(directory).filter((f) => {
532
- if (!f.endsWith('.md')) return false;
533
- if (exclude.includes(f.toLowerCase())) return false;
534
- return true;
535
- });
536
-
537
- // Sort alphabetically as fallback
538
- return files.sort();
539
- }
540
-
541
- /**
542
- * Combine section files into paper.md
543
- */
544
- export function combineSections(directory: string, config: BuildConfig, options: CombineOptions = {}): string {
545
- const sections = findSections(directory, config.sections);
546
-
547
- if (sections.length === 0) {
548
- throw new Error('No section files found. Create .md files or specify sections in rev.yaml');
549
- }
550
-
551
- const parts: string[] = [];
552
-
553
- // Add YAML frontmatter
554
- const frontmatter = buildFrontmatter(config);
555
- parts.push('---');
556
- parts.push(YAML.stringify(frontmatter).trim());
557
- parts.push('---');
558
- parts.push('');
559
-
560
- // Read all section contents for variable processing
561
- const sectionContents: string[] = [];
562
-
563
- // Check if we need to auto-inject references before supplementary
564
- // Pandoc places refs at the end by default, which breaks when supplementary follows
565
- const hasRefsSection = sections.some(s =>
566
- s.toLowerCase().includes('reference') || s.toLowerCase().includes('refs')
567
- );
568
- const suppIndex = sections.findIndex(s =>
569
- s.toLowerCase().includes('supp') || s.toLowerCase().includes('appendix')
570
- );
571
- const hasBibliography = config.bibliography && fs.existsSync(path.join(directory, config.bibliography));
572
-
573
- // Track if we find an explicit refs div in any section
574
- let hasExplicitRefsDiv = false;
575
-
576
- // Combine sections
577
- for (let i = 0; i < sections.length; i++) {
578
- const section = sections[i];
579
- if (!section) continue;
580
- const filePath = path.join(directory, section);
581
- let content = fs.readFileSync(filePath, 'utf-8');
582
-
583
- // Remove any existing frontmatter from section files
584
- content = stripFrontmatter(content);
585
- sectionContents.push(content);
586
-
587
- // Check if this section has an explicit refs div
588
- if (content.includes('::: {#refs}') || content.includes('::: {#refs}')) {
589
- hasExplicitRefsDiv = true;
590
- }
591
-
592
- // Auto-inject references before supplementary if needed
593
- if (i === suppIndex && hasBibliography && !hasRefsSection && !hasExplicitRefsDiv) {
594
- parts.push('# References\n');
595
- parts.push('::: {#refs}');
596
- parts.push(':::');
597
- parts.push('');
598
- parts.push('');
599
- options._refsAutoInjected = true;
600
- }
601
-
602
- parts.push(content.trim());
603
- parts.push('');
604
- parts.push(''); // Double newline between sections
605
- }
606
-
607
- let paperContent = parts.join('\n');
608
-
609
- // Process template variables if any exist
610
- if (hasVariables(paperContent)) {
611
- paperContent = processVariables(paperContent, config as any, { sectionContents });
612
- }
613
-
614
- // Resolve forward references (refs that appear before their anchor definition)
615
- // This fixes pandoc-crossref limitation with multi-file documents
616
- if (hasPandocCrossref()) {
617
- const registry = buildRegistry(directory, sections);
618
- const { text, resolved } = resolveForwardRefs(paperContent, registry);
619
- if (resolved.length > 0) {
620
- paperContent = text;
621
- // Store resolved count for optional reporting
622
- options._forwardRefsResolved = resolved.length;
623
- }
624
-
625
- // Resolve supplementary references and strip their anchors.
626
- // pandoc-crossref cannot produce "Figure S1" numbering — it numbers all
627
- // figures sequentially. We resolve supplementary refs to plain text and
628
- // remove the {#fig:...} attributes so crossref ignores them.
629
- const supp = resolveSupplementaryRefs(paperContent, registry);
630
- if (supp.resolved.length > 0) {
631
- paperContent = supp.text;
632
- }
633
- }
634
-
635
- const paperPath = path.join(directory, 'paper.md');
636
-
637
- fs.writeFileSync(paperPath, paperContent, 'utf-8');
638
-
639
- return paperPath;
640
- }
641
-
642
- /**
643
- * Build YAML frontmatter from config
644
- */
645
- function buildFrontmatter(config: BuildConfig): Record<string, unknown> {
646
- const fm: Record<string, unknown> = {};
647
-
648
- if (config.title) fm.title = config.title;
649
-
650
- // Skip author in frontmatter when using numbered affiliations —
651
- // the author block is injected separately per format
652
- if (config.authors && config.authors.length > 0 && !hasNumberedAffiliations(config)) {
653
- fm.author = config.authors;
654
- }
655
-
656
- if (config.bibliography) {
657
- fm.bibliography = config.bibliography;
658
- }
659
-
660
- if (config.csl) {
661
- fm.csl = config.csl;
662
- }
663
-
664
- return fm;
665
- }
666
-
667
- /**
668
- * Strip YAML frontmatter from content
669
- */
670
- function stripFrontmatter(content: string): string {
671
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
672
- if (match) {
673
- return content.slice(match[0].length);
674
- }
675
- return content;
676
- }
677
-
678
- /**
679
- * Check if config uses numbered affiliation mode
680
- * (authors have `affiliations` arrays and an affiliations map is defined)
681
- */
682
- function hasNumberedAffiliations(config: BuildConfig): boolean {
683
- if (!config.affiliations || Object.keys(config.affiliations).length === 0) return false;
684
- return config.authors.some(a => typeof a !== 'string' && a.affiliations && a.affiliations.length > 0);
685
- }
686
-
687
- /**
688
- * Generate LaTeX author block using authblk package for numbered superscript affiliations.
689
- * Returns LaTeX code to be injected via header-includes.
690
- */
691
- function generateLatexAuthorBlock(config: BuildConfig): string {
692
- const lines: string[] = [];
693
- lines.push('\\usepackage{authblk}');
694
- lines.push('\\renewcommand\\Authfont{\\normalsize}');
695
- lines.push('\\renewcommand\\Affilfont{\\small}');
696
- lines.push('');
697
-
698
- // Map affiliation keys to numbers
699
- const affiliationKeys = Object.keys(config.affiliations);
700
- const keyToNum = new Map<string, number>();
701
- affiliationKeys.forEach((key, i) => keyToNum.set(key, i + 1));
702
-
703
- // Authors
704
- for (const author of config.authors) {
705
- if (typeof author === 'string') {
706
- lines.push(`\\author{${author}}`);
707
- continue;
708
- }
709
- const marks = (author.affiliations || [])
710
- .map(k => keyToNum.get(k))
711
- .filter((n): n is number => n !== undefined);
712
-
713
- const markStr = marks.length > 0 ? `[${marks.join(',')}]` : '';
714
- let nameStr = author.name;
715
- if (author.corresponding && author.email) {
716
- nameStr += `\\thanks{Corresponding author: ${author.email}}`;
717
- } else if (author.corresponding) {
718
- nameStr += '\\thanks{Corresponding author}';
719
- }
720
- lines.push(`\\author${markStr}{${nameStr}}`);
721
- }
722
-
723
- // Affiliations
724
- for (const [key, text] of Object.entries(config.affiliations)) {
725
- const num = keyToNum.get(key);
726
- if (num !== undefined) {
727
- lines.push(`\\affil[${num}]{${text}}`);
728
- }
729
- }
730
-
731
- return lines.join('\n');
732
- }
733
-
734
- /**
735
- * Generate markdown author block for DOCX output with superscript affiliations.
736
- * Returns markdown text to insert after the YAML frontmatter.
737
- */
738
- function generateMarkdownAuthorBlock(config: BuildConfig): string {
739
- const lines: string[] = [];
740
-
741
- // Map affiliation keys to numbers
742
- const affiliationKeys = Object.keys(config.affiliations);
743
- const keyToNum = new Map<string, number>();
744
- affiliationKeys.forEach((key, i) => keyToNum.set(key, i + 1));
745
-
746
- // Author line: Name^1,2^, Name^3^, ...
747
- const authorParts: string[] = [];
748
- for (const author of config.authors) {
749
- if (typeof author === 'string') {
750
- authorParts.push(author);
751
- continue;
752
- }
753
- const marks = (author.affiliations || [])
754
- .map(k => keyToNum.get(k))
755
- .filter((n): n is number => n !== undefined);
756
- let entry = author.name;
757
- const superParts = marks.map(String);
758
- if (author.corresponding) superParts.push('\\*');
759
- if (superParts.length > 0) {
760
- entry += `^${superParts.join(',')}^`;
761
- }
762
- authorParts.push(entry);
763
- }
764
- lines.push(authorParts.join(', '));
765
- lines.push('');
766
-
767
- // Affiliation lines: ^1^ Department of ...
768
- const affiliationEntries = Object.entries(config.affiliations);
769
- const useLineBreaks = config.docx.affiliationNewline !== false;
770
- affiliationEntries.forEach(([key, text], idx) => {
771
- const num = keyToNum.get(key);
772
- if (num !== undefined) {
773
- const isLast = idx === affiliationEntries.length - 1;
774
- const suffix = useLineBreaks && !isLast ? '\\' : '';
775
- lines.push(`^${num}^ ${text}${suffix}`);
776
- }
777
- });
778
-
779
- // Corresponding author footnote
780
- const corresponding = config.authors.find(a => typeof a !== 'string' && a.corresponding) as Author | undefined;
781
- if (corresponding?.email) {
782
- lines.push('');
783
- lines.push(`^\\*^ Corresponding author: ${corresponding.email}`);
784
- }
785
-
786
- lines.push('');
787
- return lines.join('\n');
788
- }
789
-
790
- /**
791
- * Process markdown tables to apply nowrap formatting to specified columns.
792
- * Converts distribution notation (Normal, Student-t, Gamma) to LaTeX math.
793
- * @param content - Markdown content
794
- * @param tablesConfig - tables config from rev.yaml
795
- * @param format - output format (pdf, docx, etc.)
796
- * @returns processed content
797
- */
798
- export function processTablesForFormat(content: string, tablesConfig: TablesConfig, format: string): string {
799
- // Only process for PDF/TeX output
800
- if (format !== 'pdf' && format !== 'tex') {
801
- return content;
802
- }
803
-
804
- // Check if we have nowrap columns configured
805
- if (!tablesConfig?.nowrap?.length) {
806
- return content;
807
- }
808
-
809
- const nowrapPatterns = tablesConfig.nowrap.map((p) => p.toLowerCase());
810
-
811
- // Match pipe tables: header row, separator row, body rows
812
- // Header: | Col1 | Col2 | Col3 |
813
- // Separator: |:-----|:-----|:-----|
814
- // Body: | val1 | val2 | val3 |
815
- const tableRegex = /^(\|[^\n]+\|\r?\n\|[-:| ]+\|\r?\n)((?:\|[^\n]+\|\r?\n?)+)/gm;
816
-
817
- return content.replace(tableRegex, (match, headerAndSep, body) => {
818
- // Split header from separator
819
- const lines = headerAndSep.split(/\r?\n/);
820
- const headerLine = lines[0] ?? '';
821
-
822
- // Parse header cells to find nowrap column indices
823
- const headerCells = headerLine
824
- .split('|')
825
- .slice(1, -1)
826
- .map((c: string) => c.trim().toLowerCase());
827
-
828
- const nowrapCols: number[] = [];
829
- headerCells.forEach((cell: string, i: number) => {
830
- if (nowrapPatterns.some((p) => cell.includes(p))) {
831
- nowrapCols.push(i);
832
- }
833
- });
834
-
835
- // If no nowrap columns found in this table, return unchanged
836
- if (nowrapCols.length === 0) {
837
- return match;
838
- }
839
-
840
- // Process body rows
841
- const bodyLines = body.split(/\r?\n/).filter((l: string) => l.trim());
842
- const processedBody = bodyLines
843
- .map((row: string) => {
844
- // Split row into cells, keeping the pipe structure
845
- const cells = row.split('|');
846
- // cells[0] is empty (before first |), cells[last] is empty (after last |)
847
-
848
- nowrapCols.forEach((colIdx) => {
849
- const cellIdx = colIdx + 1; // Account for empty first element
850
- if (cells[cellIdx] !== undefined) {
851
- const cellContent = cells[cellIdx].trim();
852
-
853
- // Skip if empty, already math, or already has LaTeX commands
854
- if (!cellContent || cellContent.startsWith('$') || cellContent.startsWith('\\')) {
855
- return;
856
- }
857
-
858
- // Convert distribution notation to LaTeX math
859
- // Order matters: compound names (Half-Normal) must come before simple names (Normal)
860
- let processed = cellContent;
861
-
862
- // Half-Normal(x) $\text{Half-Normal}(x)$ (must come before Normal)
863
- processed = processed.replace(/Half-Normal\(([^)]+)\)/g, '$\\text{Half-Normal}($1)$');
864
-
865
- // Normal(x, y) → $\mathcal{N}(x, y)$
866
- processed = processed.replace(/Normal\(([^)]+)\)/g, '$\\mathcal{N}($1)$');
867
-
868
- // Student-t(df, loc, scale) $t_{df}(loc, scale)$
869
- processed = processed.replace(/Student-t\((\d+),\s*([^)]+)\)/g, '$t_{$1}($2)$');
870
-
871
- // Gamma(a, b) → $\text{Gamma}(a, b)$
872
- processed = processed.replace(/Gamma\(([^)]+)\)/g, '$\\text{Gamma}($1)$');
873
-
874
- // Exponential(x) → $\text{Exp}(x)$
875
- processed = processed.replace(/Exponential\(([^)]+)\)/g, '$\\text{Exp}($1)$');
876
-
877
- // Update cell with padding
878
- cells[cellIdx] = ` ${processed} `;
879
- }
880
- });
881
-
882
- return cells.join('|');
883
- })
884
- .join('\n');
885
-
886
- return headerAndSep + processedBody + '\n';
887
- });
888
- }
889
-
890
- /**
891
- * Apply format-specific transforms (table normalization, author blocks,
892
- * crossref display conversion, slide syntax). Caller is responsible for
893
- * stripping annotations beforehand — the dual-output paths keep comments
894
- * in the markdown stream and need to apply these transforms separately
895
- * from annotation handling.
896
- *
897
- * @param content - Markdown content (annotations already stripped as needed)
898
- * @param format - Output format
899
- * @param config - Build config
900
- * @param registry - Crossref registry for the project
901
- * @returns Transformed markdown
902
- */
903
- export function applyFormatTransforms(
904
- content: string,
905
- format: string,
906
- config: BuildConfig,
907
- registry: Registry
908
- ): string {
909
- if (format === 'pdf' || format === 'tex') {
910
- content = processTablesForFormat(content, config.tables, format);
911
-
912
- if (hasNumberedAffiliations(config)) {
913
- const latexBlock = generateLatexAuthorBlock(config);
914
- content = content.replace(/^(---\r?\n[\s\S]*?)(---\r?\n)/, (_match, yamlContent, closing) => {
915
- return `${yamlContent}header-includes: |\n${latexBlock.split('\n').map(l => ' ' + l).join('\n')}\n${closing}`;
916
- });
917
- }
918
- } else if (format === 'docx') {
919
- content = convertDynamicRefsToDisplay(content, registry);
920
-
921
- // Pandoc strips raw LaTeX in docx output. Translate the common
922
- // `\begin{figure}...\end{figure}` shape to portable markdown so figures
923
- // actually appear; exotic blocks are left alone (warned about in build()).
924
- if (config.docx?.translateRawFigures !== false) {
925
- const { translated } = translateRawLatexFigures(content);
926
- content = translated;
927
- }
928
-
929
- if (hasNumberedAffiliations(config)) {
930
- const mdBlock = generateMarkdownAuthorBlock(config);
931
- content = content.replace(/^(---\r?\n[\s\S]*?---\r?\n)/, `$1\n${mdBlock}\n`);
932
- }
933
- } else if (format === 'beamer' || format === 'pptx') {
934
- if (hasSlideSyntax(content)) {
935
- content = processSlideMarkdown(content, format);
936
- }
937
- }
938
-
939
- return content;
940
- }
941
-
942
- /**
943
- * Prepare paper.md for specific output format
944
- */
945
- export function prepareForFormat(
946
- paperPath: string,
947
- format: string,
948
- config: BuildConfig,
949
- _options: BuildOptions = {}
950
- ): string {
951
- const directory = path.dirname(paperPath);
952
- let content = fs.readFileSync(paperPath, 'utf-8');
953
-
954
- // Build crossref registry for reference conversion
955
- // Pass sections from config to ensure correct file ordering
956
- const registry = buildRegistry(directory, config.sections);
957
-
958
- // Strip annotations per format
959
- if (format === 'docx') {
960
- content = stripAnnotations(content, { keepComments: config.docx.keepComments });
961
- } else {
962
- content = stripAnnotations(content);
963
- }
964
-
965
- // Apply shared format transforms
966
- content = applyFormatTransforms(content, format, config, registry);
967
-
968
- // Write to temporary file
969
- const preparedPath = path.join(directory, `.paper-${format}.md`);
970
- fs.writeFileSync(preparedPath, content, 'utf-8');
971
-
972
- return preparedPath;
973
- }
974
-
975
- // =============================================================================
976
- // Reviewed DOCX (track changes + threaded comments in one file)
977
- // =============================================================================
978
-
979
- export interface ReviewedDocxOptions {
980
- /** Final output path for the reviewed docx. */
981
- outputPath: string;
982
- /** Author name attached to the tracked revisions. */
983
- author?: string;
984
- /**
985
- * Thread `{>>...<<}` comments into the same file (parent + reply via
986
- * commentsExtended.xml). When false, comments are stripped and only tracked
987
- * changes are emitted.
988
- */
989
- includeComments?: boolean;
990
- /**
991
- * Optional reference docx: realign comment anchors against its text before
992
- * injecting, matching the `--reference` behaviour of `--dual`. Only used
993
- * when includeComments is true.
994
- */
995
- referencePath?: string | null;
996
- /** Extra pandoc args (CLI --pandoc-arg), applied to the single pandoc pass. */
997
- pandocArgs?: string[];
998
- verbose?: boolean;
999
- }
1000
-
1001
- export interface ReviewedDocxResult {
1002
- success: boolean;
1003
- error?: string;
1004
- outputPath: string;
1005
- stats: NativeTrackChangeStats;
1006
- /** Number of parent comments threaded (0 when includeComments is false). */
1007
- commentCount: number;
1008
- replyCount: number;
1009
- /** Comments that could not be anchored (markers not found in the docx). */
1010
- skippedComments: number;
1011
- /** Realigned comment count when a reference docx was supplied. */
1012
- realigned?: number;
1013
- }
1014
-
1015
- /**
1016
- * Build a single reviewed docx containing pandoc-native tracked changes and,
1017
- * optionally, threaded Word comments.
1018
- *
1019
- * One pandoc pass through the full rev filter chain (crossref, citeproc,
1020
- * reference-doc, macros) emits the `w:ins`/`w:del` revisions from native
1021
- * `.insertion`/`.deletion` spans; the same file is then post-processed to
1022
- * thread `{>>...<<}` comments (parent + reply). This is the `--dual
1023
- * --show-changes` artifact: my edits as tracked changes *and* the reviewer's
1024
- * comments with my replies, in the one file a supervisor opens and hands back.
1025
- *
1026
- * @param directory - Project directory
1027
- * @param paperPath - Combined paper.md (from combineSections)
1028
- * @param config - Build config
1029
- * @param options - Output path, author, comment handling
1030
- */
1031
- export async function buildReviewedDocx(
1032
- directory: string,
1033
- paperPath: string,
1034
- config: BuildConfig,
1035
- options: ReviewedDocxOptions
1036
- ): Promise<ReviewedDocxResult> {
1037
- const {
1038
- outputPath,
1039
- author = 'Author',
1040
- includeComments = false,
1041
- referencePath = null,
1042
- pandocArgs,
1043
- verbose,
1044
- } = options;
1045
-
1046
- const emptyStats: NativeTrackChangeStats = { insertions: 0, deletions: 0, substitutions: 0 };
1047
-
1048
- let markdown = fs.readFileSync(paperPath, 'utf-8');
1049
- let realigned: number | undefined;
1050
-
1051
- // Realign comment anchors from a reference docx (parity with --dual).
1052
- if (includeComments && referencePath && fs.existsSync(referencePath)) {
1053
- try {
1054
- const { realignMarkdown } = await import('./comment-realign.js');
1055
- const res = await realignMarkdown(referencePath, markdown);
1056
- if (res.success) {
1057
- markdown = res.markdown;
1058
- realigned = res.insertions;
1059
- }
1060
- } catch {
1061
- // Non-fatal fall back to the anchors already in the markdown.
1062
- }
1063
- }
1064
-
1065
- // 1. CriticMarkup ins/del/subst → pandoc-native track-change spans.
1066
- const { text: withSpans, stats } = criticToNativeTrackChanges(markdown, { author });
1067
-
1068
- // 2. Strip remaining annotations (highlights, and comments unless threading
1069
- // them). The native spans are inert to stripAnnotations — it only touches
1070
- // CriticMarkup and `.mark` spans, which are gone or preserved as needed.
1071
- let content = stripAnnotations(withSpans, { keepComments: includeComments });
1072
-
1073
- // 3. Shared DOCX transforms (author block, @fig: → "Figure 1", raw figures).
1074
- const registry = buildRegistry(directory, config.sections);
1075
- content = applyFormatTransforms(content, 'docx', config, registry);
1076
-
1077
- // 4. Comment markers (parents get ⟦CMS⟧ ranges; replies drop out for later
1078
- // threading). No-op when not including comments.
1079
- let comments: ReturnType<typeof prepareMarkdownWithMarkers>['comments'] = [];
1080
- if (includeComments) {
1081
- const prepared = prepareMarkdownWithMarkers(content);
1082
- content = prepared.markedMarkdown;
1083
- comments = prepared.comments;
1084
- }
1085
-
1086
- const preparedPath = path.join(directory, '.paper-reviewed.md');
1087
- const tempDocx = path.join(directory, '.paper-reviewed.docx');
1088
-
1089
- try {
1090
- fs.writeFileSync(preparedPath, content, 'utf-8');
1091
-
1092
- // 5. Single pandoc pass — emits native tracked changes with full filter chain.
1093
- const pandocResult = await runPandoc(preparedPath, 'docx', config, {
1094
- outputPath: tempDocx,
1095
- pandocArgs,
1096
- verbose,
1097
- });
1098
- if (!pandocResult.success) {
1099
- return {
1100
- success: false,
1101
- error: pandocResult.error,
1102
- outputPath,
1103
- stats: emptyStats,
1104
- commentCount: 0,
1105
- replyCount: 0,
1106
- skippedComments: 0,
1107
- realigned,
1108
- };
1109
- }
1110
-
1111
- const hasTrackChanges = stats.insertions + stats.deletions + stats.substitutions > 0;
1112
- if (hasTrackChanges) enableTrackRevisions(tempDocx);
1113
-
1114
- // 6. Thread comments into the same file (or just move it into place).
1115
- if (comments.length > 0) {
1116
- const injection = await injectCommentsAtMarkers(tempDocx, comments, outputPath);
1117
- if (!injection.success) {
1118
- return {
1119
- success: false,
1120
- error: injection.error,
1121
- outputPath,
1122
- stats,
1123
- commentCount: 0,
1124
- replyCount: 0,
1125
- skippedComments: 0,
1126
- realigned,
1127
- };
1128
- }
1129
- return {
1130
- success: true,
1131
- outputPath,
1132
- stats,
1133
- commentCount: injection.commentCount,
1134
- replyCount: injection.replyCount ?? 0,
1135
- skippedComments: injection.skippedComments,
1136
- realigned,
1137
- };
1138
- }
1139
-
1140
- fs.copyFileSync(tempDocx, outputPath);
1141
- return {
1142
- success: true,
1143
- outputPath,
1144
- stats,
1145
- commentCount: 0,
1146
- replyCount: 0,
1147
- skippedComments: 0,
1148
- realigned,
1149
- };
1150
- } finally {
1151
- if (!process.env.DEBUG) {
1152
- for (const tmp of [preparedPath, tempDocx]) {
1153
- try { fs.unlinkSync(tmp); } catch { /* best-effort */ }
1154
- }
1155
- }
1156
- }
1157
- }
1158
-
1159
- /**
1160
- * Convert @fig:label references to display format (Figure 1)
1161
- */
1162
- function convertDynamicRefsToDisplay(text: string, registry: Registry): string {
1163
- const refs = detectDynamicRefs(text);
1164
-
1165
- // Process in reverse order to preserve positions
1166
- let result = text;
1167
- for (let i = refs.length - 1; i >= 0; i--) {
1168
- const ref = refs[i];
1169
- if (!ref) continue;
1170
- const display = labelToDisplay(ref.type, ref.label, registry as any);
1171
-
1172
- if (display) {
1173
- result = result.slice(0, ref.position) + display + result.slice(ref.position + ref.match.length);
1174
- }
1175
- }
1176
-
1177
- return result;
1178
- }
1179
-
1180
- // =============================================================================
1181
- // Raw LaTeX figure detection / translation (docx)
1182
- // =============================================================================
1183
-
1184
- /**
1185
- * A raw LaTeX `\begin{figure}...\end{figure}` block found in source markdown.
1186
- * `exotic` blocks contain features we don't auto-translate (multiple
1187
- * `\includegraphics`, `\subfloat`, `\rotatebox`, unrecognised width units);
1188
- * pandoc strips raw LaTeX silently in docx output, so users get warned about
1189
- * anything that won't be translated.
1190
- */
1191
- export interface RawLatexFigure {
1192
- file?: string;
1193
- line: number;
1194
- block: string;
1195
- exotic: boolean;
1196
- }
1197
-
1198
- /** Match `\begin{figure}` / `\begin{figure*}` `\end{figure}` blocks. */
1199
- function makeRawFigureRegex(): RegExp {
1200
- return /\\begin\{figure\*?\}(?:\[[^\]]*\])?[\s\S]*?\\end\{figure\*?\}/g;
1201
- }
1202
-
1203
- /**
1204
- * Convert a LaTeX width spec to a markdown image attribute value.
1205
- * - `0.8\textwidth` → `80%`
1206
- * - `\linewidth` → `100%`
1207
- * - `8cm`, `2in`, `12pt` kept verbatim
1208
- * Returns null for anything we don't translate (block stays "exotic").
1209
- */
1210
- function convertLatexWidth(raw: string): string | null {
1211
- const trimmed = raw.trim();
1212
- // Coefficient × relative length
1213
- const rel = trimmed.match(/^([\d.]+)\s*\\(textwidth|linewidth|columnwidth)$/);
1214
- if (rel) {
1215
- const pct = Math.round(parseFloat(rel[1]!) * 100);
1216
- if (!isFinite(pct) || pct <= 0) return null;
1217
- return `${pct}%`;
1218
- }
1219
- // Bare relative length
1220
- if (/^\\(textwidth|linewidth|columnwidth)$/.test(trimmed)) return '100%';
1221
- // Absolute units
1222
- if (/^[\d.]+\s*(cm|mm|in|pt|px|em|ex)$/.test(trimmed)) return trimmed.replace(/\s+/g, '');
1223
- return null;
1224
- }
1225
-
1226
- /** Extract a balanced `{...}` argument that follows `command` in `text`. */
1227
- function extractBracedArg(text: string, command: string): string | null {
1228
- const idx = text.indexOf(command);
1229
- if (idx === -1) return null;
1230
- let i = idx + command.length;
1231
- while (i < text.length && /\s/.test(text[i]!)) i++;
1232
- if (text[i] !== '{') return null;
1233
- i++;
1234
- const start = i;
1235
- let depth = 1;
1236
- while (i < text.length) {
1237
- const ch = text[i]!;
1238
- if (ch === '\\' && i + 1 < text.length) { i += 2; continue; }
1239
- if (ch === '{') depth++;
1240
- else if (ch === '}') {
1241
- depth--;
1242
- if (depth === 0) return text.slice(start, i);
1243
- }
1244
- i++;
1245
- }
1246
- return null;
1247
- }
1248
-
1249
- /** True if a `\begin{figure}` block contains features we don't auto-translate. */
1250
- function isExoticFigureBlock(block: string): boolean {
1251
- if (/\\subfloat\b/.test(block)) return true;
1252
- if (/\\rotatebox\b/.test(block)) return true;
1253
- const includes = (block.match(/\\includegraphics\b/g) || []).length;
1254
- if (includes !== 1) return true;
1255
- const m = block.match(/\\includegraphics\s*(?:\[([^\]]*)\])?\s*\{([^}]+)\}/);
1256
- if (!m) return true;
1257
- const opts = m[1] || '';
1258
- const widthMatch = opts.match(/(?:^|,)\s*width\s*=\s*([^,]+)/);
1259
- if (widthMatch && !convertLatexWidth(widthMatch[1]!)) return true;
1260
- return false;
1261
- }
1262
-
1263
- /**
1264
- * Find raw LaTeX figure blocks containing `\includegraphics` in markdown.
1265
- * `file`, if given, is attached to each result. `line` is 1-based within the
1266
- * supplied content (the line where `\begin{figure}` sits).
1267
- */
1268
- export function detectRawLatexFigures(content: string, file?: string): RawLatexFigure[] {
1269
- const figures: RawLatexFigure[] = [];
1270
- const re = makeRawFigureRegex();
1271
- let m: RegExpExecArray | null;
1272
- while ((m = re.exec(content)) !== null) {
1273
- const block = m[0];
1274
- if (!block.includes('\\includegraphics')) continue;
1275
- const line = content.slice(0, m.index).split(/\r?\n/).length;
1276
- figures.push({ file, line, block, exotic: isExoticFigureBlock(block) });
1277
- }
1278
- return figures;
1279
- }
1280
-
1281
- /**
1282
- * Translate the 80% case: single `\includegraphics` figure with optional
1283
- * `\caption{...}` and `\label{...}`, wrapped in `\begin{figure}...\end{figure}`,
1284
- * to portable `![caption](path){#fig:label width=N%}` markdown. Exotic blocks
1285
- * (see `isExoticFigureBlock`) are left untouched.
1286
- */
1287
- export function translateRawLatexFigures(content: string): { translated: string; translatedCount: number } {
1288
- let translatedCount = 0;
1289
- const re = makeRawFigureRegex();
1290
- const translated = content.replace(re, (block) => {
1291
- if (!block.includes('\\includegraphics')) return block;
1292
- if (isExoticFigureBlock(block)) return block;
1293
-
1294
- const inc = block.match(/\\includegraphics\s*(?:\[([^\]]*)\])?\s*\{([^}]+)\}/);
1295
- if (!inc) return block;
1296
- const optsStr = inc[1] || '';
1297
- const imgPath = inc[2]!.trim();
1298
-
1299
- let width: string | undefined;
1300
- const widthMatch = optsStr.match(/(?:^|,)\s*width\s*=\s*([^,]+)/);
1301
- if (widthMatch) {
1302
- const w = convertLatexWidth(widthMatch[1]!);
1303
- if (!w) return block; // already filtered by isExoticFigureBlock, defensive
1304
- width = w;
1305
- }
1306
-
1307
- const caption = (extractBracedArg(block, '\\caption') ?? '').trim();
1308
- const labelRaw = extractBracedArg(block, '\\label');
1309
-
1310
- const attrs: string[] = [];
1311
- if (labelRaw) {
1312
- const label = labelRaw.trim();
1313
- const labelWithPrefix = /^[a-z]+:/i.test(label) ? label : `fig:${label}`;
1314
- attrs.push(`#${labelWithPrefix}`);
1315
- }
1316
- if (width) attrs.push(`width=${width}`);
1317
-
1318
- translatedCount++;
1319
- const attrStr = attrs.length > 0 ? ` {${attrs.join(' ')}}` : '';
1320
- return `![${caption}](${imgPath})${attrStr}`;
1321
- });
1322
- return { translated, translatedCount };
1323
- }
1324
-
1325
- /**
1326
- * Format the warning surfaced for raw LaTeX figure blocks that won't render
1327
- * in docx. `translateEnabled` reflects whether auto-translate ran (true = the
1328
- * listed blocks are exotic leftovers; false = no translation was attempted).
1329
- */
1330
- function formatRawLatexFigureWarning(figs: RawLatexFigure[], translateEnabled: boolean): string {
1331
- const reason = translateEnabled ? 'too complex to auto-translate' : 'translateRawFigures: false';
1332
- const lines: string[] = [
1333
- `${figs.length} raw LaTeX figure block(s) won't render in docx (${reason}).`,
1334
- ];
1335
- for (const f of figs) {
1336
- const loc = f.file ? `${f.file}:${f.line}` : `line ${f.line}`;
1337
- const pathMatch = f.block.match(/\\includegraphics\s*(?:\[[^\]]*\])?\s*\{([^}]+)\}/);
1338
- const pathInfo = pathMatch ? ` ${pathMatch[1]!.trim()}` : '';
1339
- lines.push(` ${loc}${pathInfo}`);
1340
- }
1341
- lines.push(' Hint: use ![caption](path){#fig:label width=80%} for format-portable figures,');
1342
- lines.push(' or pass --pandoc-arg=--lua-filter=<your.lua> to translate them yourself.');
1343
- return lines.join('\n');
1344
- }
1345
-
1346
- /**
1347
- * Walk section files and gather a warning for any raw LaTeX figure blocks that
1348
- * won't survive the docx build. Returns null when there's nothing to warn about.
1349
- */
1350
- export function collectRawLatexFigureWarning(directory: string, config: BuildConfig): string | null {
1351
- const translateEnabled = config.docx?.translateRawFigures !== false;
1352
- const all: RawLatexFigure[] = [];
1353
- for (const section of findSections(directory, config.sections)) {
1354
- const sectionPath = path.join(directory, section);
1355
- if (!fs.existsSync(sectionPath)) continue;
1356
- try {
1357
- const content = fs.readFileSync(sectionPath, 'utf-8');
1358
- const figs = detectRawLatexFigures(content, section);
1359
- for (const f of figs) {
1360
- // When auto-translate is on, non-exotic blocks get rewritten cleanly —
1361
- // only the exotic leftovers need warning. When opted out, everything
1362
- // is at risk and we warn about every block.
1363
- if (translateEnabled && !f.exotic) continue;
1364
- all.push(f);
1365
- }
1366
- } catch {
1367
- // ignore unreadable sections
1368
- }
1369
- }
1370
- if (all.length === 0) return null;
1371
- return formatRawLatexFigureWarning(all, translateEnabled);
1372
- }
1373
-
1374
- /**
1375
- * Build pandoc arguments for format.
1376
- *
1377
- * Returns only the built-in args derived from config. Passthrough args
1378
- * (config.pandocArgs, config[format].pandocArgs, CLI --pandoc-arg) are
1379
- * appended later in runPandoc so they win against pptx/crossref defaults
1380
- * added there.
1381
- */
1382
- export function buildPandocArgs(format: string, config: BuildConfig, outputPath: string): string[] {
1383
- const args: string[] = [];
1384
-
1385
- // Output format
1386
- if (format === 'tex') {
1387
- args.push('-t', 'latex');
1388
- if (config.tex.standalone) {
1389
- args.push('-s');
1390
- }
1391
- } else if (format === 'pdf') {
1392
- args.push('-t', 'pdf');
1393
- } else if (format === 'docx') {
1394
- args.push('-t', 'docx');
1395
- } else if (format === 'beamer') {
1396
- args.push('-t', 'beamer');
1397
- } else if (format === 'pptx') {
1398
- args.push('-t', 'pptx');
1399
- }
1400
-
1401
- // Output file. runPandoc sets cwd to the project directory and passes a
1402
- // path relative to that cwd; passing it through here unchanged lets pandoc
1403
- // write to subdirectories like output/<title-slug>.<ext>.
1404
- args.push('-o', outputPath);
1405
-
1406
- // Crossref filter (if available) - skip for slides
1407
- if (hasPandocCrossref() && format !== 'beamer' && format !== 'pptx') {
1408
- args.push('--filter', 'pandoc-crossref');
1409
- }
1410
-
1411
- // Bibliography
1412
- if (config.bibliography) {
1413
- args.push('--citeproc');
1414
- }
1415
-
1416
- // Format-specific options
1417
- if (format === 'pdf') {
1418
- if (config.pdf.template) {
1419
- args.push('--template', config.pdf.template);
1420
- }
1421
- if (config.pdf.engine) {
1422
- args.push(`--pdf-engine=${config.pdf.engine}`);
1423
- }
1424
- if (config.pdf.mainfont) {
1425
- args.push('-V', `mainfont=${config.pdf.mainfont}`);
1426
- }
1427
- if (config.pdf.sansfont) {
1428
- args.push('-V', `sansfont=${config.pdf.sansfont}`);
1429
- }
1430
- if (config.pdf.monofont) {
1431
- args.push('-V', `monofont=${config.pdf.monofont}`);
1432
- }
1433
- args.push('-V', `documentclass=${config.pdf.documentclass}`);
1434
- args.push('-V', `fontsize=${config.pdf.fontsize}`);
1435
- args.push('-V', `geometry:${config.pdf.geometry}`);
1436
- if (config.pdf.headerIncludes) {
1437
- args.push('-H', config.pdf.headerIncludes);
1438
- }
1439
- if (config.pdf.linestretch !== 1) {
1440
- args.push('-V', `linestretch=${config.pdf.linestretch}`);
1441
- }
1442
- if (config.pdf.numbersections) {
1443
- args.push('--number-sections');
1444
- }
1445
- if (config.pdf.toc) {
1446
- args.push('--toc');
1447
- }
1448
- } else if (format === 'docx') {
1449
- if (config.docx.reference) {
1450
- args.push('--reference-doc', config.docx.reference);
1451
- }
1452
- if (config.docx.toc) {
1453
- args.push('--toc');
1454
- }
1455
- } else if (format === 'beamer') {
1456
- // Beamer slide options
1457
- const beamer = config.beamer || {};
1458
- if (beamer.theme) {
1459
- args.push('-V', `theme=${beamer.theme}`);
1460
- }
1461
- if (beamer.colortheme) {
1462
- args.push('-V', `colortheme=${beamer.colortheme}`);
1463
- }
1464
- if (beamer.fonttheme) {
1465
- args.push('-V', `fonttheme=${beamer.fonttheme}`);
1466
- }
1467
- if (beamer.aspectratio) {
1468
- args.push('-V', `aspectratio=${beamer.aspectratio}`);
1469
- }
1470
- if (beamer.navigation) {
1471
- args.push('-V', `navigation=${beamer.navigation}`);
1472
- }
1473
- // Speaker notes - default to 'show' which creates presenter view PDF
1474
- // Options: 'show' (dual screen), 'only' (notes only), 'hide' (no notes), false (disabled)
1475
- const notesMode = beamer.notes !== undefined ? beamer.notes : 'show';
1476
- if (notesMode && notesMode !== 'hide') {
1477
- args.push('-V', `classoption=notes=${notesMode}`);
1478
- }
1479
- // Fit images within slide bounds (default: true)
1480
- if (beamer.fit_images !== false) {
1481
- const fitImagesHeader = `\\makeatletter
1482
- \\def\\maxwidth{\\ifdim\\Gin@nat@width>\\linewidth\\linewidth\\else\\Gin@nat@width\\fi}
1483
- \\def\\maxheight{\\ifdim\\Gin@nat@height>0.75\\textheight 0.75\\textheight\\else\\Gin@nat@height\\fi}
1484
- \\makeatother
1485
- \\setkeys{Gin}{width=\\maxwidth,height=\\maxheight,keepaspectratio}`;
1486
- args.push('-V', `header-includes=${fitImagesHeader}`);
1487
- }
1488
- // Slides need standalone
1489
- args.push('-s');
1490
- } else if (format === 'pptx') {
1491
- // PowerPoint options - handled separately in preparePptxTemplate
1492
- // Reference doc is set by caller after template generation
1493
- }
1494
-
1495
- return args;
1496
- }
1497
-
1498
- /**
1499
- * Collect passthrough pandoc args for a format in the canonical order:
1500
- * top-level config → format-specific config → CLI extras. Later wins for
1501
- * repeated flags.
1502
- */
1503
- export function collectPandocPassthroughArgs(
1504
- format: string,
1505
- config: BuildConfig,
1506
- extraArgs: string[] = []
1507
- ): string[] {
1508
- const out: string[] = [];
1509
- if (config.pandocArgs && config.pandocArgs.length > 0) {
1510
- out.push(...config.pandocArgs);
1511
- }
1512
- const formatConfig = (config as unknown as Record<string, { pandocArgs?: string[] } | undefined>)[format];
1513
- if (formatConfig?.pandocArgs && formatConfig.pandocArgs.length > 0) {
1514
- out.push(...formatConfig.pandocArgs);
1515
- }
1516
- if (extraArgs.length > 0) {
1517
- out.push(...extraArgs);
1518
- }
1519
- return out;
1520
- }
1521
-
1522
- /**
1523
- * Write crossref.yaml if needed
1524
- */
1525
- function ensureCrossrefConfig(directory: string, config: BuildConfig): void {
1526
- const crossrefPath = path.join(directory, 'crossref.yaml');
1527
-
1528
- if (!fs.existsSync(crossrefPath) && hasPandocCrossref()) {
1529
- fs.writeFileSync(crossrefPath, YAML.stringify(config.crossref), 'utf-8');
1530
- }
1531
- }
1532
-
1533
- /**
1534
- * Get install instructions for missing dependency
1535
- */
1536
- function getInstallInstructions(tool: string): string {
1537
- const instructions: Record<string, string> = {
1538
- pandoc: 'https://pandoc.org/installing.html',
1539
- latex: 'https://www.latex-project.org/get/',
1540
- };
1541
- return instructions[tool] || 'Check documentation';
1542
- }
1543
-
1544
- /**
1545
- * Resolve the absolute directory where final outputs should land.
1546
- * Honors config.outputDir; falls back to the project directory when null/empty.
1547
- */
1548
- export function resolveOutputDir(directory: string, config: BuildConfig): string {
1549
- const out = config.outputDir;
1550
- if (!out) return directory;
1551
- return path.isAbsolute(out) ? out : path.join(directory, out);
1552
- }
1553
-
1554
- /** File extension (with leading dot) for each supported pandoc format. */
1555
- const FORMAT_EXTENSIONS: Record<string, string> = {
1556
- tex: '.tex',
1557
- pdf: '.pdf',
1558
- docx: '.docx',
1559
- beamer: '.pdf',
1560
- pptx: '.pptx',
1561
- };
1562
-
1563
- /** Get file extension for a format, defaulting to `.pdf`. */
1564
- export function getFormatExtension(format: string): string {
1565
- return FORMAT_EXTENSIONS[format] ?? '.pdf';
1566
- }
1567
-
1568
- /**
1569
- * Slugify a title for use as a default output filename. Lowercases, replaces
1570
- * non-alphanumeric runs with `-`, and truncates at the last `-` boundary
1571
- * at-or-before MAX_TITLE_FILENAME_LENGTH so words stay whole (the old blind
1572
- * `.slice` cut mid-word).
1573
- */
1574
- export function slugifyTitle(title: string): string {
1575
- if (!title) return 'paper';
1576
- const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1577
- if (!slug) return 'paper';
1578
- if (slug.length <= MAX_TITLE_FILENAME_LENGTH) return slug;
1579
- const cut = slug.slice(0, MAX_TITLE_FILENAME_LENGTH);
1580
- const lastDash = cut.lastIndexOf('-');
1581
- // Only truncate at a hyphen if it leaves a reasonable amount of content.
1582
- // Otherwise hard-cut (handles degenerate titles with no spaces at all).
1583
- if (lastDash >= MAX_TITLE_FILENAME_LENGTH / 2) {
1584
- return slug.slice(0, lastDash);
1585
- }
1586
- return cut;
1587
- }
1588
-
1589
- /**
1590
- * Ensure `name` ends with `ext` (case-insensitive). If the user already supplied
1591
- * the correct extension, return unchanged; if they supplied none or a different
1592
- * one, append the format's canonical extension.
1593
- *
1594
- * Different-extension case (e.g. `output.docx` when building tex): we append
1595
- * rather than replace, since stripping looks like an unsafe guess. The result
1596
- * `output.docx.tex` is loud enough to flag the misconfiguration.
1597
- */
1598
- function ensureExtension(name: string, ext: string): string {
1599
- if (name.toLowerCase().endsWith(ext.toLowerCase())) return name;
1600
- return name + ext;
1601
- }
1602
-
1603
- /**
1604
- * Resolve the final output path for a build.
1605
- *
1606
- * Priority: `options.outputPath` (internal force) > `cliOverride` (-o flag) >
1607
- * `config.output[format]` > slugified title fallback.
1608
- *
1609
- * Relative paths from `cliOverride`/`config.output` resolve under outputDir;
1610
- * absolute paths bypass outputDir. The fallback path always lives under
1611
- * outputDir.
1612
- *
1613
- * @param suffix - Appended before the extension (e.g. "-changes", "-slides").
1614
- * Suppressed when user supplied an explicit name via CLI or
1615
- * config — they pick their own suffix.
1616
- */
1617
- export function resolveOutputPath(
1618
- directory: string,
1619
- config: BuildConfig,
1620
- format: string,
1621
- options: { cliOverride?: string; suffix?: string } = {}
1622
- ): string {
1623
- const { cliOverride, suffix = '' } = options;
1624
- const ext = getFormatExtension(format);
1625
-
1626
- const explicit = cliOverride ?? config.output?.[format];
1627
- if (explicit) {
1628
- const baseDir = path.isAbsolute(explicit)
1629
- ? path.dirname(explicit)
1630
- : resolveOutputDir(directory, config);
1631
- const baseName = path.basename(explicit);
1632
- const stem = baseName.replace(/\.[^./\\]+$/, '');
1633
- return path.join(baseDir, ensureExtension(`${stem}${suffix}`, ext));
1634
- }
1635
-
1636
- const slug = slugifyTitle(config.title);
1637
- return path.join(resolveOutputDir(directory, config), `${slug}${suffix}${ext}`);
1638
- }
1639
-
1640
- /**
1641
- * Run pandoc build
1642
- */
1643
- export async function runPandoc(
1644
- inputPath: string,
1645
- format: string,
1646
- config: BuildConfig,
1647
- options: BuildOptions = {}
1648
- ): Promise<PandocResult> {
1649
- const directory = path.dirname(inputPath);
1650
-
1651
- // outputPath (internal force) wins over the resolver. For beamer, we keep
1652
- // the `-slides` suffix on the slug fallback to distinguish from a regular
1653
- // PDF build; when the user supplies an explicit name, they pick their own.
1654
- const suffix = format === 'beamer' ? '-slides' : '';
1655
- const outputPath = options.outputPath
1656
- ?? resolveOutputPath(directory, config, format, {
1657
- cliOverride: options.output,
1658
- suffix,
1659
- });
1660
-
1661
- if (!options.outputPath) {
1662
- const outDir = path.dirname(outputPath);
1663
- if (!fs.existsSync(outDir)) {
1664
- fs.mkdirSync(outDir, { recursive: true });
1665
- }
1666
- }
1667
-
1668
- // Ensure crossref.yaml exists
1669
- ensureCrossrefConfig(directory, config);
1670
-
1671
- // Pandoc runs with cwd = directory, so pass the output path relative to it.
1672
- const args = buildPandocArgs(format, config, path.relative(directory, outputPath) || path.basename(outputPath));
1673
-
1674
- // Handle PPTX reference template and themes
1675
- let pptxMediaDir: string | null = null;
1676
- if (format === 'pptx') {
1677
- const pptx = config.pptx || {};
1678
-
1679
- // Determine media directory (default: pptx/media or slides/media)
1680
- let mediaDir = pptx.media;
1681
- if (!mediaDir) {
1682
- if (fs.existsSync(path.join(directory, 'pptx', 'media'))) {
1683
- mediaDir = path.join(directory, 'pptx', 'media');
1684
- } else if (fs.existsSync(path.join(directory, 'slides', 'media'))) {
1685
- mediaDir = path.join(directory, 'slides', 'media');
1686
- }
1687
- } else if (!path.isAbsolute(mediaDir)) {
1688
- mediaDir = path.join(directory, mediaDir);
1689
- }
1690
- pptxMediaDir = mediaDir || null;
1691
-
1692
- // Determine reference doc: custom reference overrides theme
1693
- let referenceDoc: string | null = null;
1694
- if (pptx.reference && fs.existsSync(path.join(directory, pptx.reference))) {
1695
- // Custom reference doc takes precedence
1696
- referenceDoc = path.join(directory, pptx.reference);
1697
- } else {
1698
- // Use built-in theme (default: 'default')
1699
- const themeName = pptx.theme || 'default';
1700
- const themePath = getThemePath(themeName);
1701
- if (themePath && fs.existsSync(themePath)) {
1702
- referenceDoc = themePath;
1703
- }
1704
- }
1705
-
1706
- if (referenceDoc) {
1707
- args.push('--reference-doc', referenceDoc);
1708
- }
1709
-
1710
- // Add color filter for PPTX (handles [text]{color=#RRGGBB} syntax).
1711
- // fileURLToPath handles Windows paths with spaces — the old
1712
- // `new URL(...).pathname` returned URL-encoded `%20` and fs.existsSync
1713
- // silently failed.
1714
- const colorFilterPath = path.join(
1715
- path.dirname(fileURLToPath(import.meta.url)),
1716
- 'pptx-color-filter.lua'
1717
- );
1718
- if (fs.existsSync(colorFilterPath)) {
1719
- args.push('--lua-filter', colorFilterPath);
1720
- }
1721
- }
1722
-
1723
- // Wire placeholder macros (built-in \tofill plus user-declared entries).
1724
- // - docx/html: lua filter expands \name{X} to format-specific raw runs.
1725
- // - pdf/tex/beamer: inject a \providecommand preamble so LaTeX renders it
1726
- // directly. `\providecommand` is non-clobbering, so a user who already
1727
- // has `\providecommand{\tofill}{...}` in their own header keeps theirs.
1728
- //
1729
- // Sidecar path is passed to the lua filter via DOCREV_MACROS_FILE in the
1730
- // child env (not pandoc metadata) because pandoc walks RawInline/RawBlock
1731
- // BEFORE Meta — by the time a Meta handler could read the path, the inline
1732
- // expansion has already happened.
1733
- const macroTempFiles: string[] = [];
1734
- let macroEnvFile: string | null = null;
1735
- const macros = mergeMacros((config as { macros?: unknown }).macros);
1736
- if (macros.length > 0) {
1737
- if (format === 'docx' || format === 'html' || format === 'html5' || format === 'html4') {
1738
- const sidecarPath = writeMacrosSidecar(directory, macros);
1739
- macroTempFiles.push(sidecarPath);
1740
- macroEnvFile = sidecarPath;
1741
- const filterPath = getMacroFilterPath();
1742
- if (fs.existsSync(filterPath)) {
1743
- args.push('--lua-filter', filterPath);
1744
- }
1745
- } else if (format === 'pdf' || format === 'tex' || format === 'beamer') {
1746
- const preamble = generateLatexPreamble(macros);
1747
- const preamblePath = path.join(directory, '.macros.tex');
1748
- fs.writeFileSync(preamblePath, preamble, 'utf-8');
1749
- macroTempFiles.push(preamblePath);
1750
- args.push('-H', path.basename(preamblePath));
1751
- }
1752
- }
1753
-
1754
- // Add crossref metadata file if exists (skip for slides - they don't use crossref)
1755
- if (format !== 'beamer' && format !== 'pptx') {
1756
- const crossrefPath = path.join(directory, 'crossref.yaml');
1757
- if (fs.existsSync(crossrefPath) && hasPandocCrossref()) {
1758
- // Use basename since we set cwd to directory
1759
- args.push('--metadata-file', 'crossref.yaml');
1760
- }
1761
- }
1762
-
1763
- // Passthrough args go last so they win against built-in defaults.
1764
- args.push(...collectPandocPassthroughArgs(format, config, options.pandocArgs));
1765
-
1766
- // Input file (use basename since we set cwd to directory)
1767
- args.push(path.basename(inputPath));
1768
-
1769
- if (options.verbose) {
1770
- const quoted = args.map(a => /[\s"'$`]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a).join(' ');
1771
- console.error(`[pandoc ${format}] (cwd: ${directory})`);
1772
- console.error(` pandoc ${quoted}`);
1773
- }
1774
-
1775
- return new Promise((resolve) => {
1776
- const pandocEnv: NodeJS.ProcessEnv = { ...process.env };
1777
- if (macroEnvFile) {
1778
- pandocEnv.DOCREV_MACROS_FILE = macroEnvFile;
1779
- }
1780
- const pandoc: ChildProcess = spawn('pandoc', args, {
1781
- cwd: directory,
1782
- stdio: ['ignore', 'pipe', 'pipe'],
1783
- env: pandocEnv,
1784
- });
1785
-
1786
- let stderr = '';
1787
- pandoc.stderr?.on('data', (data) => {
1788
- stderr += data.toString();
1789
- });
1790
-
1791
- const cleanupMacroTempFiles = (): void => {
1792
- for (const tmp of macroTempFiles) {
1793
- try {
1794
- fs.unlinkSync(tmp);
1795
- } catch {
1796
- // ignore — best-effort cleanup
1797
- }
1798
- }
1799
- };
1800
-
1801
- pandoc.on('close', async (code) => {
1802
- cleanupMacroTempFiles();
1803
- if (code === 0) {
1804
- // For PPTX, post-process to add slide numbers, buildup colors, and logos.
1805
- // These steps enhance an already-written deck; a failure degrades the
1806
- // output rather than aborting it, so each is reported as a warning
1807
- // instead of silently swallowed otherwise the user gets a deck
1808
- // missing slide numbers / colors / logos with no indication why.
1809
- if (format === 'pptx') {
1810
- const warn = (step: string, e: unknown) =>
1811
- console.warn(`Warning: PPTX ${step} failed; deck written without it (${(e as Error).message})`);
1812
- try {
1813
- await injectSlideNumbers(outputPath);
1814
- } catch (e) {
1815
- warn('slide numbering', e);
1816
- }
1817
- try {
1818
- // Apply colors (default text color, title color, buildup greying)
1819
- const pptxConfig = config.pptx || {};
1820
- const colorsConfig = pptxConfig.colors || {};
1821
- const buildupConfig = pptxConfig.buildup || {};
1822
- const colorConfig = {
1823
- default: colorsConfig.default,
1824
- title: colorsConfig.title,
1825
- grey: buildupConfig.grey,
1826
- accent: buildupConfig.accent,
1827
- enabled: buildupConfig.enabled
1828
- };
1829
- await applyBuildupColors(outputPath, colorConfig);
1830
- } catch (e) {
1831
- warn('color application', e);
1832
- }
1833
- // Inject logos into cover slide (if media dir configured)
1834
- if (pptxMediaDir) {
1835
- try {
1836
- await injectMediaIntoPptx(outputPath, pptxMediaDir);
1837
- } catch (e) {
1838
- warn('logo injection', e);
1839
- }
1840
- }
1841
- }
1842
-
1843
- // Run user postprocess scripts
1844
- const postResult = await runPostprocess(outputPath, format, config as unknown as Parameters<typeof runPostprocess>[2], options);
1845
- if (!postResult.success && options.verbose) {
1846
- console.error(`Postprocess warning: ${postResult.error}`);
1847
- }
1848
-
1849
- resolve({ outputPath, success: true });
1850
- } else {
1851
- resolve({ outputPath, success: false, error: stderr || `Exit code ${code}` });
1852
- }
1853
- });
1854
-
1855
- pandoc.on('error', (err) => {
1856
- cleanupMacroTempFiles();
1857
- resolve({ outputPath, success: false, error: err.message });
1858
- });
1859
- });
1860
- }
1861
-
1862
- /**
1863
- * Full build pipeline
1864
- */
1865
- export async function build(
1866
- directory: string,
1867
- formats: string[] = ['pdf', 'docx'],
1868
- options: BuildOptions = {}
1869
- ): Promise<FullBuildResult> {
1870
- const warnings: string[] = [];
1871
- let forwardRefsResolved = 0;
1872
-
1873
- // Check pandoc
1874
- if (!hasPandoc()) {
1875
- const instruction = getInstallInstructions('pandoc');
1876
- throw new Error(`Pandoc not found. Install with: ${instruction}\nOr run: rev doctor`);
1877
- }
1878
-
1879
- // Check LaTeX if PDF is requested
1880
- if ((formats.includes('pdf') || formats.includes('all')) && !hasLatex()) {
1881
- warnings.push(`LaTeX not found - PDF generation may fail. Install with: ${getInstallInstructions('latex')}`);
1882
- }
1883
-
1884
- // Check pandoc-crossref
1885
- if (!hasPandocCrossref()) {
1886
- warnings.push('pandoc-crossref not found - figure/table numbering will not work');
1887
- }
1888
-
1889
- // Load config (use passed config if provided, otherwise load from file)
1890
- const config = options.config || loadConfig(directory);
1891
-
1892
- // Combine sections paper.md
1893
- const buildOptions: CombineOptions = { ...options };
1894
- const paperPath = combineSections(directory, config, buildOptions);
1895
- forwardRefsResolved = buildOptions._forwardRefsResolved || 0;
1896
- const refsAutoInjected = buildOptions._refsAutoInjected || false;
1897
-
1898
- // Expand 'all' to all formats
1899
- if (formats.includes('all')) {
1900
- formats = ['pdf', 'docx', 'tex'];
1901
- }
1902
-
1903
- // Build and save image registry when DOCX is being built
1904
- // This allows import to restore proper image syntax from Word documents
1905
- if (formats.includes('docx')) {
1906
- const paperContent = fs.readFileSync(paperPath, 'utf-8');
1907
- const crossrefReg = buildRegistry(directory, config.sections);
1908
- const imageReg = buildImageRegistry(paperContent, crossrefReg as any);
1909
- if ((imageReg as any).figures?.length > 0) {
1910
- writeImageRegistry(directory, imageReg);
1911
- }
1912
-
1913
- // Warn about raw LaTeX figure blocks that won't render in docx (pandoc
1914
- // drops them silently). With auto-translate on (default), this surfaces
1915
- // only the exotic leftovers; with it off, every block.
1916
- const rawFigWarning = collectRawLatexFigureWarning(directory, config);
1917
- if (rawFigWarning) warnings.push(rawFigWarning);
1918
- }
1919
-
1920
- const results: BuildResult[] = [];
1921
-
1922
- for (const format of formats) {
1923
- // Prepare format-specific version
1924
- const preparedPath = prepareForFormat(paperPath, format, config, options);
1925
-
1926
- // Run pandoc
1927
- const result = await runPandoc(preparedPath, format, config, options);
1928
- results.push({ format, ...result });
1929
-
1930
- // Clean up temp file
1931
- try {
1932
- fs.unlinkSync(preparedPath);
1933
- } catch {
1934
- // Ignore cleanup errors
1935
- }
1936
- }
1937
-
1938
- return { results, paperPath, warnings, forwardRefsResolved, refsAutoInjected };
1939
- }
1940
-
1941
- /**
1942
- * Get build status summary
1943
- */
1944
- export function formatBuildResults(results: BuildResult[]): string {
1945
- const lines: string[] = [];
1946
-
1947
- for (const r of results) {
1948
- if (r.success) {
1949
- lines.push(` ${r.format.toUpperCase()}: ${path.basename(r.outputPath!)}`);
1950
- } else {
1951
- lines.push(` ${r.format.toUpperCase()}: FAILED - ${r.error}`);
1952
- }
1953
- }
1954
-
1955
- return lines.join('\n');
1956
- }
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 { fileURLToPath } from 'url';
14
+ import { execSync, spawn, ChildProcess } from 'child_process';
15
+ import YAML from 'yaml';
16
+ import { stripAnnotations } from './annotations.js';
17
+ import { criticToNativeTrackChanges, enableTrackRevisions, type NativeTrackChangeStats } from './trackchanges.js';
18
+ import { prepareMarkdownWithMarkers, injectCommentsAtMarkers } from './wordcomments.js';
19
+ import { buildRegistry, labelToDisplay, detectDynamicRefs, resolveForwardRefs, resolveSupplementaryRefs } from './crossref.js';
20
+ import { processVariables, hasVariables } from './variables.js';
21
+ import { processSlideMarkdown, hasSlideSyntax } from './slides.js';
22
+ import { generatePptxTemplate, templateNeedsRegeneration, injectMediaIntoPptx, injectSlideNumbers, applyThemeFonts, applyCentering, applyBuildupColors } from './pptx-template.js';
23
+ import { getThemePath, getThemeNames, PPTX_THEMES } from './pptx-themes.js';
24
+ import { runPostprocess } from './postprocess.js';
25
+ import { hasPandoc, hasPandocCrossref, hasLatex } from './dependencies.js';
26
+ import { buildImageRegistry, writeImageRegistry } from './image-registry.js';
27
+ import type { Author, JournalFormatting } from './types.js';
28
+ import { getJournalProfile } from './journals.js';
29
+ import { resolveCSL } from './csl.js';
30
+ import {
31
+ type MacroDef,
32
+ mergeMacros,
33
+ generateLatexPreamble,
34
+ writeMacrosSidecar,
35
+ getMacroFilterPath,
36
+ } from './macros.js';
37
+
38
+ // =============================================================================
39
+ // Constants
40
+ // =============================================================================
41
+
42
+ /** Supported output formats */
43
+ const SUPPORTED_FORMATS = ['pdf', 'docx', 'tex', 'beamer', 'pptx'] as const;
44
+
45
+ /**
46
+ * Maximum length for slugified-title output filenames. Only used when no
47
+ * explicit `output:` filename is configured. Long titles are truncated at the
48
+ * last `-` boundary at-or-before this length so words stay intact (the old
49
+ * blind `.slice(0, 50)` cut mid-word).
50
+ */
51
+ const MAX_TITLE_FILENAME_LENGTH = 80;
52
+
53
+ // =============================================================================
54
+ // Interfaces
55
+ // =============================================================================
56
+
57
+ export interface CrossrefConfig {
58
+ figureTitle?: string;
59
+ tableTitle?: string;
60
+ figPrefix?: string | string[];
61
+ tblPrefix?: string | string[];
62
+ secPrefix?: string | string[];
63
+ linkReferences?: boolean;
64
+ }
65
+
66
+ export interface PdfConfig {
67
+ template?: string | null;
68
+ headerIncludes?: string | null;
69
+ /**
70
+ * Path (relative to the project dir, or absolute) to a LaTeX preamble file
71
+ * injected via pandoc's `-H`. Use for custom fonts, `fancyhdr` running
72
+ * headers, `lineno` line numbers, etc. A missing file is reported, not
73
+ * silently dropped.
74
+ */
75
+ header?: string | null;
76
+ /** Second LaTeX preamble file, injected via `-H` after {@link header}. */
77
+ footer?: string | null;
78
+ documentclass?: string;
79
+ fontsize?: string;
80
+ geometry?: string;
81
+ linestretch?: number;
82
+ numbersections?: boolean;
83
+ toc?: boolean;
84
+ /**
85
+ * LaTeX engine: pdflatex (default), xelatex, lualatex, tectonic, etc.
86
+ * xelatex/lualatex are required for native UTF-8 rendering of Latin-Extended
87
+ * diacritics (Czech/Polish/Croatian/Spanish author names, species epithets).
88
+ */
89
+ engine?: string;
90
+ /** Roman/serif main font (xelatex/lualatex only — uses fontspec). */
91
+ mainfont?: string;
92
+ /** Sans-serif font (xelatex/lualatex only). */
93
+ sansfont?: string;
94
+ /** Monospace font (xelatex/lualatex only). */
95
+ monofont?: string;
96
+ /** Extra pandoc args appended for this format (after top-level pandocArgs). */
97
+ pandocArgs?: string[];
98
+ }
99
+
100
+ export interface DocxConfig {
101
+ reference?: string | null;
102
+ keepComments?: boolean;
103
+ affiliationNewline?: boolean;
104
+ toc?: boolean;
105
+ pandocArgs?: string[];
106
+ /**
107
+ * Auto-translate the common-shape raw `\begin{figure}...\end{figure}` block
108
+ * to portable `![caption](path){#fig:label width=N%}` markdown so figures
109
+ * survive the docx build (pandoc otherwise drops raw LaTeX silently).
110
+ * Default true. Set false to opt out — blocks then warn and are left alone.
111
+ */
112
+ translateRawFigures?: boolean;
113
+ }
114
+
115
+ export interface TexConfig {
116
+ standalone?: boolean;
117
+ pandocArgs?: string[];
118
+ }
119
+
120
+ export interface BeamerConfig {
121
+ theme?: string;
122
+ colortheme?: string | null;
123
+ fonttheme?: string | null;
124
+ aspectratio?: string | null;
125
+ navigation?: string | null;
126
+ section?: boolean;
127
+ notes?: string | false;
128
+ fit_images?: boolean;
129
+ pandocArgs?: string[];
130
+ }
131
+
132
+ export interface PptxConfig {
133
+ theme?: string;
134
+ reference?: string | null;
135
+ media?: string | null;
136
+ colors?: {
137
+ default?: string;
138
+ title?: string;
139
+ };
140
+ buildup?: {
141
+ grey?: string;
142
+ accent?: string;
143
+ enabled?: boolean;
144
+ };
145
+ pandocArgs?: string[];
146
+ }
147
+
148
+ export interface TablesConfig {
149
+ nowrap?: string[];
150
+ }
151
+
152
+ export interface PostprocessConfig {
153
+ pdf?: string | null;
154
+ docx?: string | null;
155
+ tex?: string | null;
156
+ pptx?: string | null;
157
+ beamer?: string | null;
158
+ all?: string | null;
159
+ [key: string]: string | null | undefined;
160
+ }
161
+
162
+ export interface BuildConfig {
163
+ title: string;
164
+ authors: (string | Author)[];
165
+ affiliations: Record<string, string>;
166
+ sections: string[];
167
+ bibliography: string | null;
168
+ csl: string | null;
169
+ crossref: CrossrefConfig;
170
+ pdf: PdfConfig;
171
+ docx: DocxConfig;
172
+ tex: TexConfig;
173
+ beamer: BeamerConfig;
174
+ pptx: PptxConfig;
175
+ tables: TablesConfig;
176
+ postprocess: PostprocessConfig;
177
+ /**
178
+ * User-declared placeholder macros. Merged with the built-in macros
179
+ * (currently \tofill). Each entry overrides a built-in by name.
180
+ *
181
+ * See lib/macros.ts for the per-format rendering rules.
182
+ */
183
+ macros?: MacroDef[];
184
+ /**
185
+ * Directory (relative to the project) where final outputs land. Created on
186
+ * demand. Set to null/empty to keep outputs alongside paper.md (legacy
187
+ * behavior).
188
+ */
189
+ outputDir?: string | null;
190
+ /**
191
+ * Per-format output filenames. Keys are format names (pdf/docx/tex/beamer/
192
+ * pptx); values are paths. Relative paths resolve under outputDir; absolute
193
+ * paths are honored as-is. Extension is added if missing. CLI `-o` wins
194
+ * over this map.
195
+ */
196
+ output?: Record<string, string>;
197
+ /**
198
+ * Extra pandoc args applied to every format. Format-specific args
199
+ * (e.g. docx.pandocArgs) are appended *after* these, and CLI --pandoc-arg
200
+ * values are appended last.
201
+ */
202
+ pandocArgs?: string[];
203
+ _configPath?: string | null;
204
+ }
205
+
206
+ export interface BuildResult {
207
+ format: string;
208
+ success: boolean;
209
+ outputPath?: string;
210
+ error?: string;
211
+ }
212
+
213
+ interface BuildOptions {
214
+ verbose?: boolean;
215
+ config?: BuildConfig;
216
+ /**
217
+ * Internal: forces the exact output path. Used by dual-mode/temp builds that
218
+ * route to specific temp files. Bypasses the `output:` resolver.
219
+ */
220
+ outputPath?: string;
221
+ /**
222
+ * CLI override (`-o, --output <path>`). Beats `config.output[format]` but
223
+ * loses to `options.outputPath`. Relative paths resolve under outputDir;
224
+ * absolute paths bypass outputDir.
225
+ */
226
+ output?: string;
227
+ crossref?: boolean;
228
+ /** Extra pandoc args from CLI (--pandoc-arg). Appended after config args. */
229
+ pandocArgs?: string[];
230
+ _refsAutoInjected?: boolean;
231
+ _forwardRefsResolved?: number;
232
+ }
233
+
234
+ interface CombineOptions extends BuildOptions {
235
+ _refsAutoInjected?: boolean;
236
+ }
237
+
238
+ interface VariablesContext {
239
+ sectionContents: string[];
240
+ }
241
+
242
+ interface PandocResult {
243
+ outputPath: string;
244
+ success: boolean;
245
+ error?: string;
246
+ }
247
+
248
+ interface FullBuildResult {
249
+ results: BuildResult[];
250
+ paperPath: string;
251
+ warnings: string[];
252
+ forwardRefsResolved: number;
253
+ refsAutoInjected?: boolean;
254
+ }
255
+
256
+ interface DynamicRef {
257
+ type: string;
258
+ label: string;
259
+ match: string;
260
+ position: number;
261
+ }
262
+
263
+ interface Registry {
264
+ figures: Map<string, unknown>;
265
+ tables: Map<string, unknown>;
266
+ equations: Map<string, unknown>;
267
+ byNumber: {
268
+ fig?: Map<number, string>;
269
+ figS?: Map<number, string>;
270
+ tbl?: Map<number, string>;
271
+ tblS?: Map<number, string>;
272
+ eq?: Map<number, string>;
273
+ };
274
+ }
275
+
276
+ /**
277
+ * Default rev.yaml configuration
278
+ */
279
+ export const DEFAULT_CONFIG: BuildConfig = {
280
+ title: 'Untitled Document',
281
+ authors: [],
282
+ affiliations: {},
283
+ sections: [],
284
+ bibliography: null,
285
+ csl: null,
286
+ crossref: {
287
+ figureTitle: 'Figure',
288
+ tableTitle: 'Table',
289
+ figPrefix: ['Fig.', 'Figs.'],
290
+ tblPrefix: ['Table', 'Tables'],
291
+ secPrefix: ['Section', 'Sections'],
292
+ linkReferences: true,
293
+ },
294
+ pdf: {
295
+ template: null,
296
+ documentclass: 'article',
297
+ fontsize: '12pt',
298
+ geometry: 'margin=1in',
299
+ linestretch: 1.5,
300
+ numbersections: false,
301
+ toc: false,
302
+ },
303
+ docx: {
304
+ reference: null,
305
+ keepComments: false,
306
+ affiliationNewline: true,
307
+ toc: false,
308
+ translateRawFigures: true,
309
+ },
310
+ tex: {
311
+ standalone: true,
312
+ },
313
+ // Slide formats
314
+ beamer: {
315
+ theme: 'default',
316
+ colortheme: null,
317
+ fonttheme: null,
318
+ aspectratio: null, // '169' for 16:9, '43' for 4:3
319
+ navigation: null, // 'horizontal', 'vertical', 'frame', 'empty'
320
+ section: true, // section divider slides
321
+ notes: 'show', // 'show' (presenter view), 'only' (notes only), 'hide', or false
322
+ fit_images: true, // scale images to fit within slide bounds
323
+ },
324
+ pptx: {
325
+ theme: 'default', // Built-in theme: default, dark, academic, minimal, corporate
326
+ reference: null, // Custom reference-doc (overrides theme)
327
+ media: null, // directory with logo images (e.g., logo-left.png, logo-right.png)
328
+ },
329
+ // Table formatting
330
+ tables: {
331
+ nowrap: [], // Column headers to apply nowrap formatting (converts Normal() → $\mathcal{N}()$ etc.)
332
+ },
333
+ // Postprocess scripts
334
+ postprocess: {
335
+ pdf: null,
336
+ docx: null,
337
+ tex: null,
338
+ pptx: null,
339
+ beamer: null,
340
+ all: null, // Runs after any format
341
+ },
342
+ // Placeholder/highlight macros. Defaults are the built-ins from
343
+ // lib/macros.ts; users append their own here.
344
+ macros: [],
345
+ // Final outputs land here (created on demand). Set to null or '' to keep
346
+ // outputs in the project root.
347
+ outputDir: 'output',
348
+ };
349
+
350
+ // =============================================================================
351
+ // Public API
352
+ // =============================================================================
353
+
354
+ /**
355
+ * Merge journal formatting defaults into a config.
356
+ * Priority: DEFAULT_CONFIG < journal formatting < rev.yaml explicit settings
357
+ */
358
+ export function mergeJournalFormatting(config: BuildConfig, formatting: JournalFormatting, directory: string): BuildConfig {
359
+ const merged = { ...config };
360
+
361
+ // CSL: only apply if user hasn't set one
362
+ if (formatting.csl && !config.csl) {
363
+ const resolved = resolveCSL(formatting.csl, directory);
364
+ if (resolved) {
365
+ merged.csl = resolved;
366
+ }
367
+ // If not resolved locally, store the name — pandoc --citeproc
368
+ // can sometimes resolve it, and the user can fetch with rev profiles --fetch-csl
369
+ if (!resolved) {
370
+ merged.csl = formatting.csl;
371
+ }
372
+ }
373
+
374
+ // PDF settings: merge only unset fields
375
+ if (formatting.pdf) {
376
+ const userPdf = config.pdf || {};
377
+ const defaults = DEFAULT_CONFIG.pdf;
378
+ merged.pdf = { ...config.pdf };
379
+ for (const [key, value] of Object.entries(formatting.pdf)) {
380
+ const k = key as keyof PdfConfig;
381
+ // Apply journal value only if user config matches the default (i.e., wasn't explicitly set)
382
+ if (value !== undefined && JSON.stringify(userPdf[k]) === JSON.stringify(defaults[k])) {
383
+ (merged.pdf as Record<string, unknown>)[k] = value;
384
+ }
385
+ }
386
+ }
387
+
388
+ // DOCX settings: merge only unset fields
389
+ if (formatting.docx) {
390
+ const userDocx = config.docx || {};
391
+ const defaults = DEFAULT_CONFIG.docx;
392
+ merged.docx = { ...config.docx };
393
+ for (const [key, value] of Object.entries(formatting.docx)) {
394
+ const k = key as keyof DocxConfig;
395
+ if (value !== undefined && JSON.stringify(userDocx[k]) === JSON.stringify(defaults[k])) {
396
+ (merged.docx as Record<string, unknown>)[k] = value;
397
+ }
398
+ }
399
+ }
400
+
401
+ // Crossref settings: merge only unset fields
402
+ if (formatting.crossref) {
403
+ const userCrossref = config.crossref || {};
404
+ const defaults = DEFAULT_CONFIG.crossref;
405
+ merged.crossref = { ...config.crossref };
406
+ for (const [key, value] of Object.entries(formatting.crossref)) {
407
+ const k = key as keyof CrossrefConfig;
408
+ if (value !== undefined && JSON.stringify(userCrossref[k]) === JSON.stringify(defaults[k])) {
409
+ (merged.crossref as Record<string, unknown>)[k] = value;
410
+ }
411
+ }
412
+ }
413
+
414
+ return merged;
415
+ }
416
+
417
+ /**
418
+ * In-place: copy `pandoc-args` → `pandocArgs` on an object (if not already set).
419
+ * Idempotent. Coerces a single string into a one-element array.
420
+ */
421
+ function normalizePandocArgsKey(obj: Record<string, unknown>): void {
422
+ if (!obj || typeof obj !== 'object') return;
423
+ const hy = obj['pandoc-args'];
424
+ if (hy === undefined) return;
425
+ if (obj.pandocArgs === undefined) {
426
+ obj.pandocArgs = Array.isArray(hy) ? hy : [hy];
427
+ }
428
+ delete obj['pandoc-args'];
429
+ }
430
+
431
+ /**
432
+ * Load rev.yaml config from directory
433
+ * @param directory - Project directory path
434
+ * @returns Merged config with defaults
435
+ * @throws {TypeError} If directory is not a string
436
+ * @throws {Error} If rev.yaml exists but cannot be parsed
437
+ */
438
+ export function loadConfig(directory: string): BuildConfig {
439
+ if (typeof directory !== 'string') {
440
+ throw new TypeError(`directory must be a string, got ${typeof directory}`);
441
+ }
442
+
443
+ const configPath = path.join(directory, 'rev.yaml');
444
+
445
+ if (!fs.existsSync(configPath)) {
446
+ return { ...DEFAULT_CONFIG, _configPath: null };
447
+ }
448
+
449
+ try {
450
+ const content = fs.readFileSync(configPath, 'utf-8');
451
+ const userConfig = YAML.parse(content) || {};
452
+
453
+ // Accept hyphenated `pandoc-args` (the form pandoc itself uses) in addition
454
+ // to camelCase `pandocArgs`. Hyphenated is what we document; camelCase is
455
+ // accepted for users who already prefer that convention.
456
+ normalizePandocArgsKey(userConfig);
457
+ for (const fmt of ['pdf', 'docx', 'tex', 'beamer', 'pptx'] as const) {
458
+ if (userConfig[fmt] && typeof userConfig[fmt] === 'object') {
459
+ normalizePandocArgsKey(userConfig[fmt]);
460
+ }
461
+ }
462
+
463
+ // Deep merge with defaults
464
+ let config: BuildConfig = {
465
+ ...DEFAULT_CONFIG,
466
+ ...userConfig,
467
+ crossref: { ...DEFAULT_CONFIG.crossref, ...userConfig.crossref },
468
+ pdf: { ...DEFAULT_CONFIG.pdf, ...userConfig.pdf },
469
+ docx: { ...DEFAULT_CONFIG.docx, ...userConfig.docx },
470
+ tex: { ...DEFAULT_CONFIG.tex, ...userConfig.tex },
471
+ beamer: { ...DEFAULT_CONFIG.beamer, ...userConfig.beamer },
472
+ pptx: { ...DEFAULT_CONFIG.pptx, ...userConfig.pptx },
473
+ tables: { ...DEFAULT_CONFIG.tables, ...userConfig.tables },
474
+ postprocess: { ...DEFAULT_CONFIG.postprocess, ...userConfig.postprocess },
475
+ _configPath: configPath,
476
+ };
477
+
478
+ // Apply journal formatting defaults (between DEFAULT_CONFIG and user settings)
479
+ if (userConfig.journal) {
480
+ const profile = getJournalProfile(userConfig.journal);
481
+ if (profile?.formatting) {
482
+ config = mergeJournalFormatting(config, profile.formatting, directory);
483
+ }
484
+ }
485
+
486
+ return config;
487
+ } catch (err) {
488
+ const error = err as Error;
489
+ throw new Error(`Failed to parse rev.yaml: ${error.message}`);
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Find section files in directory
495
+ * @param directory - Project directory path
496
+ * @param configSections - Sections from rev.yaml (optional)
497
+ * @returns Ordered list of section file names
498
+ * @throws {TypeError} If directory is not a string
499
+ */
500
+ export function findSections(directory: string, configSections: string[] = []): string[] {
501
+ if (typeof directory !== 'string') {
502
+ throw new TypeError(`directory must be a string, got ${typeof directory}`);
503
+ }
504
+
505
+ // If sections specified in config, use that order
506
+ if (configSections.length > 0) {
507
+ const sections: string[] = [];
508
+ for (const section of configSections) {
509
+ const filePath = path.join(directory, section);
510
+ if (fs.existsSync(filePath)) {
511
+ sections.push(section);
512
+ } else {
513
+ console.warn(`Warning: Section file not found: ${section}`);
514
+ }
515
+ }
516
+ return sections;
517
+ }
518
+
519
+ // Try sections.yaml
520
+ const sectionsYamlPath = path.join(directory, 'sections.yaml');
521
+ if (fs.existsSync(sectionsYamlPath)) {
522
+ try {
523
+ const sectionsConfig = YAML.parse(fs.readFileSync(sectionsYamlPath, 'utf-8'));
524
+ if (sectionsConfig.sections) {
525
+ return Object.entries(sectionsConfig.sections)
526
+ .sort((a: [string, any], b: [string, any]) => (a[1].order ?? 999) - (b[1].order ?? 999))
527
+ .map(([file]) => file)
528
+ .filter((f) => fs.existsSync(path.join(directory, f)));
529
+ }
530
+ } catch (e) {
531
+ if (process.env.DEBUG) {
532
+ const error = e as Error;
533
+ console.warn('build: YAML parse error in sections.yaml:', error.message);
534
+ }
535
+ }
536
+ }
537
+
538
+ // Default: find all .md files except special ones
539
+ const exclude = ['paper.md', 'readme.md', 'claude.md'];
540
+ const files = fs.readdirSync(directory).filter((f) => {
541
+ if (!f.endsWith('.md')) return false;
542
+ if (exclude.includes(f.toLowerCase())) return false;
543
+ return true;
544
+ });
545
+
546
+ // Sort alphabetically as fallback
547
+ return files.sort();
548
+ }
549
+
550
+ /**
551
+ * Combine section files into paper.md
552
+ */
553
+ export function combineSections(directory: string, config: BuildConfig, options: CombineOptions = {}): string {
554
+ const sections = findSections(directory, config.sections);
555
+
556
+ if (sections.length === 0) {
557
+ throw new Error('No section files found. Create .md files or specify sections in rev.yaml');
558
+ }
559
+
560
+ const parts: string[] = [];
561
+
562
+ // Add YAML frontmatter
563
+ const frontmatter = buildFrontmatter(config);
564
+ parts.push('---');
565
+ parts.push(YAML.stringify(frontmatter).trim());
566
+ parts.push('---');
567
+ parts.push('');
568
+
569
+ // Read all section contents for variable processing
570
+ const sectionContents: string[] = [];
571
+
572
+ // Check if we need to auto-inject references before supplementary
573
+ // Pandoc places refs at the end by default, which breaks when supplementary follows
574
+ const hasRefsSection = sections.some(s =>
575
+ s.toLowerCase().includes('reference') || s.toLowerCase().includes('refs')
576
+ );
577
+ const suppIndex = sections.findIndex(s =>
578
+ s.toLowerCase().includes('supp') || s.toLowerCase().includes('appendix')
579
+ );
580
+ const hasBibliography = config.bibliography && fs.existsSync(path.join(directory, config.bibliography));
581
+
582
+ // Track if we find an explicit refs div in any section
583
+ let hasExplicitRefsDiv = false;
584
+
585
+ // Combine sections
586
+ for (let i = 0; i < sections.length; i++) {
587
+ const section = sections[i];
588
+ if (!section) continue;
589
+ const filePath = path.join(directory, section);
590
+ let content = fs.readFileSync(filePath, 'utf-8');
591
+
592
+ // Remove any existing frontmatter from section files
593
+ content = stripFrontmatter(content);
594
+ sectionContents.push(content);
595
+
596
+ // Check if this section has an explicit refs div
597
+ if (content.includes('::: {#refs}') || content.includes('::: {#refs}')) {
598
+ hasExplicitRefsDiv = true;
599
+ }
600
+
601
+ // Auto-inject references before supplementary if needed
602
+ if (i === suppIndex && hasBibliography && !hasRefsSection && !hasExplicitRefsDiv) {
603
+ parts.push('# References\n');
604
+ parts.push('::: {#refs}');
605
+ parts.push(':::');
606
+ parts.push('');
607
+ parts.push('');
608
+ options._refsAutoInjected = true;
609
+ }
610
+
611
+ parts.push(content.trim());
612
+ parts.push('');
613
+ parts.push(''); // Double newline between sections
614
+ }
615
+
616
+ let paperContent = parts.join('\n');
617
+
618
+ // Process template variables if any exist
619
+ if (hasVariables(paperContent)) {
620
+ paperContent = processVariables(paperContent, config as any, { sectionContents });
621
+ }
622
+
623
+ // Resolve forward references (refs that appear before their anchor definition)
624
+ // This fixes pandoc-crossref limitation with multi-file documents
625
+ if (hasPandocCrossref()) {
626
+ const registry = buildRegistry(directory, sections);
627
+ const { text, resolved } = resolveForwardRefs(paperContent, registry);
628
+ if (resolved.length > 0) {
629
+ paperContent = text;
630
+ // Store resolved count for optional reporting
631
+ options._forwardRefsResolved = resolved.length;
632
+ }
633
+
634
+ // Resolve supplementary references and strip their anchors.
635
+ // pandoc-crossref cannot produce "Figure S1" numbering — it numbers all
636
+ // figures sequentially. We resolve supplementary refs to plain text and
637
+ // remove the {#fig:...} attributes so crossref ignores them.
638
+ const supp = resolveSupplementaryRefs(paperContent, registry);
639
+ if (supp.resolved.length > 0) {
640
+ paperContent = supp.text;
641
+ }
642
+ }
643
+
644
+ const paperPath = path.join(directory, 'paper.md');
645
+
646
+ fs.writeFileSync(paperPath, paperContent, 'utf-8');
647
+
648
+ return paperPath;
649
+ }
650
+
651
+ /**
652
+ * Build YAML frontmatter from config
653
+ */
654
+ function buildFrontmatter(config: BuildConfig): Record<string, unknown> {
655
+ const fm: Record<string, unknown> = {};
656
+
657
+ if (config.title) fm.title = config.title;
658
+
659
+ // Skip author in frontmatter when using numbered affiliations —
660
+ // the author block is injected separately per format
661
+ if (config.authors && config.authors.length > 0 && !hasNumberedAffiliations(config)) {
662
+ fm.author = config.authors;
663
+ }
664
+
665
+ if (config.bibliography) {
666
+ fm.bibliography = config.bibliography;
667
+ }
668
+
669
+ if (config.csl) {
670
+ fm.csl = config.csl;
671
+ }
672
+
673
+ return fm;
674
+ }
675
+
676
+ /**
677
+ * Strip YAML frontmatter from content
678
+ */
679
+ function stripFrontmatter(content: string): string {
680
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
681
+ if (match) {
682
+ return content.slice(match[0].length);
683
+ }
684
+ return content;
685
+ }
686
+
687
+ /**
688
+ * Check if config uses numbered affiliation mode
689
+ * (authors have `affiliations` arrays and an affiliations map is defined)
690
+ */
691
+ function hasNumberedAffiliations(config: BuildConfig): boolean {
692
+ if (!config.affiliations || Object.keys(config.affiliations).length === 0) return false;
693
+ return config.authors.some(a => typeof a !== 'string' && a.affiliations && a.affiliations.length > 0);
694
+ }
695
+
696
+ /**
697
+ * Generate LaTeX author block using authblk package for numbered superscript affiliations.
698
+ * Returns LaTeX code to be injected via header-includes.
699
+ */
700
+ function generateLatexAuthorBlock(config: BuildConfig): string {
701
+ const lines: string[] = [];
702
+ lines.push('\\usepackage{authblk}');
703
+ lines.push('\\renewcommand\\Authfont{\\normalsize}');
704
+ lines.push('\\renewcommand\\Affilfont{\\small}');
705
+ lines.push('');
706
+
707
+ // Map affiliation keys to numbers
708
+ const affiliationKeys = Object.keys(config.affiliations);
709
+ const keyToNum = new Map<string, number>();
710
+ affiliationKeys.forEach((key, i) => keyToNum.set(key, i + 1));
711
+
712
+ // Authors
713
+ for (const author of config.authors) {
714
+ if (typeof author === 'string') {
715
+ lines.push(`\\author{${author}}`);
716
+ continue;
717
+ }
718
+ const marks = (author.affiliations || [])
719
+ .map(k => keyToNum.get(k))
720
+ .filter((n): n is number => n !== undefined);
721
+
722
+ const markStr = marks.length > 0 ? `[${marks.join(',')}]` : '';
723
+ let nameStr = author.name;
724
+ if (author.corresponding && author.email) {
725
+ nameStr += `\\thanks{Corresponding author: ${author.email}}`;
726
+ } else if (author.corresponding) {
727
+ nameStr += '\\thanks{Corresponding author}';
728
+ }
729
+ lines.push(`\\author${markStr}{${nameStr}}`);
730
+ }
731
+
732
+ // Affiliations
733
+ for (const [key, text] of Object.entries(config.affiliations)) {
734
+ const num = keyToNum.get(key);
735
+ if (num !== undefined) {
736
+ lines.push(`\\affil[${num}]{${text}}`);
737
+ }
738
+ }
739
+
740
+ return lines.join('\n');
741
+ }
742
+
743
+ /**
744
+ * Generate markdown author block for DOCX output with superscript affiliations.
745
+ * Returns markdown text to insert after the YAML frontmatter.
746
+ */
747
+ function generateMarkdownAuthorBlock(config: BuildConfig): string {
748
+ const lines: string[] = [];
749
+
750
+ // Map affiliation keys to numbers
751
+ const affiliationKeys = Object.keys(config.affiliations);
752
+ const keyToNum = new Map<string, number>();
753
+ affiliationKeys.forEach((key, i) => keyToNum.set(key, i + 1));
754
+
755
+ // Author line: Name^1,2^, Name^3^, ...
756
+ const authorParts: string[] = [];
757
+ for (const author of config.authors) {
758
+ if (typeof author === 'string') {
759
+ authorParts.push(author);
760
+ continue;
761
+ }
762
+ const marks = (author.affiliations || [])
763
+ .map(k => keyToNum.get(k))
764
+ .filter((n): n is number => n !== undefined);
765
+ let entry = author.name;
766
+ const superParts = marks.map(String);
767
+ if (author.corresponding) superParts.push('\\*');
768
+ if (superParts.length > 0) {
769
+ entry += `^${superParts.join(',')}^`;
770
+ }
771
+ authorParts.push(entry);
772
+ }
773
+ lines.push(authorParts.join(', '));
774
+ lines.push('');
775
+
776
+ // Affiliation lines: ^1^ Department of ...
777
+ const affiliationEntries = Object.entries(config.affiliations);
778
+ const useLineBreaks = config.docx.affiliationNewline !== false;
779
+ affiliationEntries.forEach(([key, text], idx) => {
780
+ const num = keyToNum.get(key);
781
+ if (num !== undefined) {
782
+ const isLast = idx === affiliationEntries.length - 1;
783
+ const suffix = useLineBreaks && !isLast ? '\\' : '';
784
+ lines.push(`^${num}^ ${text}${suffix}`);
785
+ }
786
+ });
787
+
788
+ // Corresponding author footnote
789
+ const corresponding = config.authors.find(a => typeof a !== 'string' && a.corresponding) as Author | undefined;
790
+ if (corresponding?.email) {
791
+ lines.push('');
792
+ lines.push(`^\\*^ Corresponding author: ${corresponding.email}`);
793
+ }
794
+
795
+ lines.push('');
796
+ return lines.join('\n');
797
+ }
798
+
799
+ /**
800
+ * Process markdown tables to apply nowrap formatting to specified columns.
801
+ * Converts distribution notation (Normal, Student-t, Gamma) to LaTeX math.
802
+ * @param content - Markdown content
803
+ * @param tablesConfig - tables config from rev.yaml
804
+ * @param format - output format (pdf, docx, etc.)
805
+ * @returns processed content
806
+ */
807
+ export function processTablesForFormat(content: string, tablesConfig: TablesConfig, format: string): string {
808
+ // Only process for PDF/TeX output
809
+ if (format !== 'pdf' && format !== 'tex') {
810
+ return content;
811
+ }
812
+
813
+ // Check if we have nowrap columns configured
814
+ if (!tablesConfig?.nowrap?.length) {
815
+ return content;
816
+ }
817
+
818
+ const nowrapPatterns = tablesConfig.nowrap.map((p) => p.toLowerCase());
819
+
820
+ // Match pipe tables: header row, separator row, body rows
821
+ // Header: | Col1 | Col2 | Col3 |
822
+ // Separator: |:-----|:-----|:-----|
823
+ // Body: | val1 | val2 | val3 |
824
+ const tableRegex = /^(\|[^\n]+\|\r?\n\|[-:| ]+\|\r?\n)((?:\|[^\n]+\|\r?\n?)+)/gm;
825
+
826
+ return content.replace(tableRegex, (match, headerAndSep, body) => {
827
+ // Split header from separator
828
+ const lines = headerAndSep.split(/\r?\n/);
829
+ const headerLine = lines[0] ?? '';
830
+
831
+ // Parse header cells to find nowrap column indices
832
+ const headerCells = headerLine
833
+ .split('|')
834
+ .slice(1, -1)
835
+ .map((c: string) => c.trim().toLowerCase());
836
+
837
+ const nowrapCols: number[] = [];
838
+ headerCells.forEach((cell: string, i: number) => {
839
+ if (nowrapPatterns.some((p) => cell.includes(p))) {
840
+ nowrapCols.push(i);
841
+ }
842
+ });
843
+
844
+ // If no nowrap columns found in this table, return unchanged
845
+ if (nowrapCols.length === 0) {
846
+ return match;
847
+ }
848
+
849
+ // Process body rows
850
+ const bodyLines = body.split(/\r?\n/).filter((l: string) => l.trim());
851
+ const processedBody = bodyLines
852
+ .map((row: string) => {
853
+ // Split row into cells, keeping the pipe structure
854
+ const cells = row.split('|');
855
+ // cells[0] is empty (before first |), cells[last] is empty (after last |)
856
+
857
+ nowrapCols.forEach((colIdx) => {
858
+ const cellIdx = colIdx + 1; // Account for empty first element
859
+ if (cells[cellIdx] !== undefined) {
860
+ const cellContent = cells[cellIdx].trim();
861
+
862
+ // Skip if empty, already math, or already has LaTeX commands
863
+ if (!cellContent || cellContent.startsWith('$') || cellContent.startsWith('\\')) {
864
+ return;
865
+ }
866
+
867
+ // Convert distribution notation to LaTeX math
868
+ // Order matters: compound names (Half-Normal) must come before simple names (Normal)
869
+ let processed = cellContent;
870
+
871
+ // Half-Normal(x) → $\text{Half-Normal}(x)$ (must come before Normal)
872
+ processed = processed.replace(/Half-Normal\(([^)]+)\)/g, '$\\text{Half-Normal}($1)$');
873
+
874
+ // Normal(x, y) → $\mathcal{N}(x, y)$
875
+ processed = processed.replace(/Normal\(([^)]+)\)/g, '$\\mathcal{N}($1)$');
876
+
877
+ // Student-t(df, loc, scale) → $t_{df}(loc, scale)$
878
+ processed = processed.replace(/Student-t\((\d+),\s*([^)]+)\)/g, '$t_{$1}($2)$');
879
+
880
+ // Gamma(a, b) → $\text{Gamma}(a, b)$
881
+ processed = processed.replace(/Gamma\(([^)]+)\)/g, '$\\text{Gamma}($1)$');
882
+
883
+ // Exponential(x) → $\text{Exp}(x)$
884
+ processed = processed.replace(/Exponential\(([^)]+)\)/g, '$\\text{Exp}($1)$');
885
+
886
+ // Update cell with padding
887
+ cells[cellIdx] = ` ${processed} `;
888
+ }
889
+ });
890
+
891
+ return cells.join('|');
892
+ })
893
+ .join('\n');
894
+
895
+ return headerAndSep + processedBody + '\n';
896
+ });
897
+ }
898
+
899
+ /**
900
+ * Apply format-specific transforms (table normalization, author blocks,
901
+ * crossref display conversion, slide syntax). Caller is responsible for
902
+ * stripping annotations beforehand — the dual-output paths keep comments
903
+ * in the markdown stream and need to apply these transforms separately
904
+ * from annotation handling.
905
+ *
906
+ * @param content - Markdown content (annotations already stripped as needed)
907
+ * @param format - Output format
908
+ * @param config - Build config
909
+ * @param registry - Crossref registry for the project
910
+ * @returns Transformed markdown
911
+ */
912
+ export function applyFormatTransforms(
913
+ content: string,
914
+ format: string,
915
+ config: BuildConfig,
916
+ registry: Registry
917
+ ): string {
918
+ if (format === 'pdf' || format === 'tex') {
919
+ content = processTablesForFormat(content, config.tables, format);
920
+
921
+ if (hasNumberedAffiliations(config)) {
922
+ const latexBlock = generateLatexAuthorBlock(config);
923
+ content = content.replace(/^(---\r?\n[\s\S]*?)(---\r?\n)/, (_match, yamlContent, closing) => {
924
+ return `${yamlContent}header-includes: |\n${latexBlock.split('\n').map(l => ' ' + l).join('\n')}\n${closing}`;
925
+ });
926
+ }
927
+ } else if (format === 'docx') {
928
+ content = convertDynamicRefsToDisplay(content, registry);
929
+
930
+ // Pandoc strips raw LaTeX in docx output. Translate the common
931
+ // `\begin{figure}...\end{figure}` shape to portable markdown so figures
932
+ // actually appear; exotic blocks are left alone (warned about in build()).
933
+ if (config.docx?.translateRawFigures !== false) {
934
+ const { translated } = translateRawLatexFigures(content);
935
+ content = translated;
936
+ }
937
+
938
+ if (hasNumberedAffiliations(config)) {
939
+ const mdBlock = generateMarkdownAuthorBlock(config);
940
+ content = content.replace(/^(---\r?\n[\s\S]*?---\r?\n)/, `$1\n${mdBlock}\n`);
941
+ }
942
+ } else if (format === 'beamer' || format === 'pptx') {
943
+ if (hasSlideSyntax(content)) {
944
+ content = processSlideMarkdown(content, format);
945
+ }
946
+ }
947
+
948
+ return content;
949
+ }
950
+
951
+ /**
952
+ * Prepare paper.md for specific output format
953
+ */
954
+ export function prepareForFormat(
955
+ paperPath: string,
956
+ format: string,
957
+ config: BuildConfig,
958
+ _options: BuildOptions = {}
959
+ ): string {
960
+ const directory = path.dirname(paperPath);
961
+ let content = fs.readFileSync(paperPath, 'utf-8');
962
+
963
+ // Build crossref registry for reference conversion
964
+ // Pass sections from config to ensure correct file ordering
965
+ const registry = buildRegistry(directory, config.sections);
966
+
967
+ // Strip annotations per format
968
+ if (format === 'docx') {
969
+ content = stripAnnotations(content, { keepComments: config.docx.keepComments });
970
+ } else {
971
+ content = stripAnnotations(content);
972
+ }
973
+
974
+ // Apply shared format transforms
975
+ content = applyFormatTransforms(content, format, config, registry);
976
+
977
+ // Write to temporary file
978
+ const preparedPath = path.join(directory, `.paper-${format}.md`);
979
+ fs.writeFileSync(preparedPath, content, 'utf-8');
980
+
981
+ return preparedPath;
982
+ }
983
+
984
+ // =============================================================================
985
+ // Reviewed DOCX (track changes + threaded comments in one file)
986
+ // =============================================================================
987
+
988
+ export interface ReviewedDocxOptions {
989
+ /** Final output path for the reviewed docx. */
990
+ outputPath: string;
991
+ /** Author name attached to the tracked revisions. */
992
+ author?: string;
993
+ /**
994
+ * Thread `{>>...<<}` comments into the same file (parent + reply via
995
+ * commentsExtended.xml). When false, comments are stripped and only tracked
996
+ * changes are emitted.
997
+ */
998
+ includeComments?: boolean;
999
+ /**
1000
+ * Optional reference docx: realign comment anchors against its text before
1001
+ * injecting, matching the `--reference` behaviour of `--dual`. Only used
1002
+ * when includeComments is true.
1003
+ */
1004
+ referencePath?: string | null;
1005
+ /** Extra pandoc args (CLI --pandoc-arg), applied to the single pandoc pass. */
1006
+ pandocArgs?: string[];
1007
+ verbose?: boolean;
1008
+ }
1009
+
1010
+ export interface ReviewedDocxResult {
1011
+ success: boolean;
1012
+ error?: string;
1013
+ outputPath: string;
1014
+ stats: NativeTrackChangeStats;
1015
+ /** Number of parent comments threaded (0 when includeComments is false). */
1016
+ commentCount: number;
1017
+ replyCount: number;
1018
+ /** Comments that could not be anchored (markers not found in the docx). */
1019
+ skippedComments: number;
1020
+ /** Realigned comment count when a reference docx was supplied. */
1021
+ realigned?: number;
1022
+ }
1023
+
1024
+ /**
1025
+ * Build a single reviewed docx containing pandoc-native tracked changes and,
1026
+ * optionally, threaded Word comments.
1027
+ *
1028
+ * One pandoc pass through the full rev filter chain (crossref, citeproc,
1029
+ * reference-doc, macros) emits the `w:ins`/`w:del` revisions from native
1030
+ * `.insertion`/`.deletion` spans; the same file is then post-processed to
1031
+ * thread `{>>...<<}` comments (parent + reply). This is the `--dual
1032
+ * --show-changes` artifact: my edits as tracked changes *and* the reviewer's
1033
+ * comments with my replies, in the one file a supervisor opens and hands back.
1034
+ *
1035
+ * @param directory - Project directory
1036
+ * @param paperPath - Combined paper.md (from combineSections)
1037
+ * @param config - Build config
1038
+ * @param options - Output path, author, comment handling
1039
+ */
1040
+ export async function buildReviewedDocx(
1041
+ directory: string,
1042
+ paperPath: string,
1043
+ config: BuildConfig,
1044
+ options: ReviewedDocxOptions
1045
+ ): Promise<ReviewedDocxResult> {
1046
+ const {
1047
+ outputPath,
1048
+ author = 'Author',
1049
+ includeComments = false,
1050
+ referencePath = null,
1051
+ pandocArgs,
1052
+ verbose,
1053
+ } = options;
1054
+
1055
+ const emptyStats: NativeTrackChangeStats = { insertions: 0, deletions: 0, substitutions: 0 };
1056
+
1057
+ let markdown = fs.readFileSync(paperPath, 'utf-8');
1058
+ let realigned: number | undefined;
1059
+
1060
+ // Realign comment anchors from a reference docx (parity with --dual).
1061
+ if (includeComments && referencePath && fs.existsSync(referencePath)) {
1062
+ try {
1063
+ const { realignMarkdown } = await import('./comment-realign.js');
1064
+ const res = await realignMarkdown(referencePath, markdown);
1065
+ if (res.success) {
1066
+ markdown = res.markdown;
1067
+ realigned = res.insertions;
1068
+ }
1069
+ } catch {
1070
+ // Non-fatal fall back to the anchors already in the markdown.
1071
+ }
1072
+ }
1073
+
1074
+ // 1. CriticMarkup ins/del/subst → pandoc-native track-change spans.
1075
+ const { text: withSpans, stats } = criticToNativeTrackChanges(markdown, { author });
1076
+
1077
+ // 2. Strip remaining annotations (highlights, and comments unless threading
1078
+ // them). The native spans are inert to stripAnnotations — it only touches
1079
+ // CriticMarkup and `.mark` spans, which are gone or preserved as needed.
1080
+ let content = stripAnnotations(withSpans, { keepComments: includeComments });
1081
+
1082
+ // 3. Shared DOCX transforms (author block, @fig: → "Figure 1", raw figures).
1083
+ const registry = buildRegistry(directory, config.sections);
1084
+ content = applyFormatTransforms(content, 'docx', config, registry);
1085
+
1086
+ // 4. Comment markers (parents get ⟦CMS⟧ ranges; replies drop out for later
1087
+ // threading). No-op when not including comments.
1088
+ let comments: ReturnType<typeof prepareMarkdownWithMarkers>['comments'] = [];
1089
+ if (includeComments) {
1090
+ const prepared = prepareMarkdownWithMarkers(content);
1091
+ content = prepared.markedMarkdown;
1092
+ comments = prepared.comments;
1093
+ }
1094
+
1095
+ const preparedPath = path.join(directory, '.paper-reviewed.md');
1096
+ const tempDocx = path.join(directory, '.paper-reviewed.docx');
1097
+
1098
+ try {
1099
+ fs.writeFileSync(preparedPath, content, 'utf-8');
1100
+
1101
+ // 5. Single pandoc pass — emits native tracked changes with full filter chain.
1102
+ const pandocResult = await runPandoc(preparedPath, 'docx', config, {
1103
+ outputPath: tempDocx,
1104
+ pandocArgs,
1105
+ verbose,
1106
+ });
1107
+ if (!pandocResult.success) {
1108
+ return {
1109
+ success: false,
1110
+ error: pandocResult.error,
1111
+ outputPath,
1112
+ stats: emptyStats,
1113
+ commentCount: 0,
1114
+ replyCount: 0,
1115
+ skippedComments: 0,
1116
+ realigned,
1117
+ };
1118
+ }
1119
+
1120
+ const hasTrackChanges = stats.insertions + stats.deletions + stats.substitutions > 0;
1121
+ if (hasTrackChanges) enableTrackRevisions(tempDocx);
1122
+
1123
+ // 6. Thread comments into the same file (or just move it into place).
1124
+ if (comments.length > 0) {
1125
+ const injection = await injectCommentsAtMarkers(tempDocx, comments, outputPath);
1126
+ if (!injection.success) {
1127
+ return {
1128
+ success: false,
1129
+ error: injection.error,
1130
+ outputPath,
1131
+ stats,
1132
+ commentCount: 0,
1133
+ replyCount: 0,
1134
+ skippedComments: 0,
1135
+ realigned,
1136
+ };
1137
+ }
1138
+ return {
1139
+ success: true,
1140
+ outputPath,
1141
+ stats,
1142
+ commentCount: injection.commentCount,
1143
+ replyCount: injection.replyCount ?? 0,
1144
+ skippedComments: injection.skippedComments,
1145
+ realigned,
1146
+ };
1147
+ }
1148
+
1149
+ fs.copyFileSync(tempDocx, outputPath);
1150
+ return {
1151
+ success: true,
1152
+ outputPath,
1153
+ stats,
1154
+ commentCount: 0,
1155
+ replyCount: 0,
1156
+ skippedComments: 0,
1157
+ realigned,
1158
+ };
1159
+ } finally {
1160
+ if (!process.env.DEBUG) {
1161
+ for (const tmp of [preparedPath, tempDocx]) {
1162
+ try { fs.unlinkSync(tmp); } catch { /* best-effort */ }
1163
+ }
1164
+ }
1165
+ }
1166
+ }
1167
+
1168
+ /**
1169
+ * Convert @fig:label references to display format (Figure 1)
1170
+ */
1171
+ function convertDynamicRefsToDisplay(text: string, registry: Registry): string {
1172
+ const refs = detectDynamicRefs(text);
1173
+
1174
+ // Process in reverse order to preserve positions
1175
+ let result = text;
1176
+ for (let i = refs.length - 1; i >= 0; i--) {
1177
+ const ref = refs[i];
1178
+ if (!ref) continue;
1179
+ const display = labelToDisplay(ref.type, ref.label, registry as any);
1180
+
1181
+ if (display) {
1182
+ result = result.slice(0, ref.position) + display + result.slice(ref.position + ref.match.length);
1183
+ }
1184
+ }
1185
+
1186
+ return result;
1187
+ }
1188
+
1189
+ // =============================================================================
1190
+ // Raw LaTeX figure detection / translation (docx)
1191
+ // =============================================================================
1192
+
1193
+ /**
1194
+ * A raw LaTeX `\begin{figure}...\end{figure}` block found in source markdown.
1195
+ * `exotic` blocks contain features we don't auto-translate (multiple
1196
+ * `\includegraphics`, `\subfloat`, `\rotatebox`, unrecognised width units);
1197
+ * pandoc strips raw LaTeX silently in docx output, so users get warned about
1198
+ * anything that won't be translated.
1199
+ */
1200
+ export interface RawLatexFigure {
1201
+ file?: string;
1202
+ line: number;
1203
+ block: string;
1204
+ exotic: boolean;
1205
+ }
1206
+
1207
+ /** Match `\begin{figure}` / `\begin{figure*}` … `\end{figure}` blocks. */
1208
+ function makeRawFigureRegex(): RegExp {
1209
+ return /\\begin\{figure\*?\}(?:\[[^\]]*\])?[\s\S]*?\\end\{figure\*?\}/g;
1210
+ }
1211
+
1212
+ /**
1213
+ * Convert a LaTeX width spec to a markdown image attribute value.
1214
+ * - `0.8\textwidth` → `80%`
1215
+ * - `\linewidth` `100%`
1216
+ * - `8cm`, `2in`, `12pt` kept verbatim
1217
+ * Returns null for anything we don't translate (block stays "exotic").
1218
+ */
1219
+ function convertLatexWidth(raw: string): string | null {
1220
+ const trimmed = raw.trim();
1221
+ // Coefficient × relative length
1222
+ const rel = trimmed.match(/^([\d.]+)\s*\\(textwidth|linewidth|columnwidth)$/);
1223
+ if (rel) {
1224
+ const pct = Math.round(parseFloat(rel[1]!) * 100);
1225
+ if (!isFinite(pct) || pct <= 0) return null;
1226
+ return `${pct}%`;
1227
+ }
1228
+ // Bare relative length
1229
+ if (/^\\(textwidth|linewidth|columnwidth)$/.test(trimmed)) return '100%';
1230
+ // Absolute units
1231
+ if (/^[\d.]+\s*(cm|mm|in|pt|px|em|ex)$/.test(trimmed)) return trimmed.replace(/\s+/g, '');
1232
+ return null;
1233
+ }
1234
+
1235
+ /** Extract a balanced `{...}` argument that follows `command` in `text`. */
1236
+ function extractBracedArg(text: string, command: string): string | null {
1237
+ const idx = text.indexOf(command);
1238
+ if (idx === -1) return null;
1239
+ let i = idx + command.length;
1240
+ while (i < text.length && /\s/.test(text[i]!)) i++;
1241
+ if (text[i] !== '{') return null;
1242
+ i++;
1243
+ const start = i;
1244
+ let depth = 1;
1245
+ while (i < text.length) {
1246
+ const ch = text[i]!;
1247
+ if (ch === '\\' && i + 1 < text.length) { i += 2; continue; }
1248
+ if (ch === '{') depth++;
1249
+ else if (ch === '}') {
1250
+ depth--;
1251
+ if (depth === 0) return text.slice(start, i);
1252
+ }
1253
+ i++;
1254
+ }
1255
+ return null;
1256
+ }
1257
+
1258
+ /** True if a `\begin{figure}` block contains features we don't auto-translate. */
1259
+ function isExoticFigureBlock(block: string): boolean {
1260
+ if (/\\subfloat\b/.test(block)) return true;
1261
+ if (/\\rotatebox\b/.test(block)) return true;
1262
+ const includes = (block.match(/\\includegraphics\b/g) || []).length;
1263
+ if (includes !== 1) return true;
1264
+ const m = block.match(/\\includegraphics\s*(?:\[([^\]]*)\])?\s*\{([^}]+)\}/);
1265
+ if (!m) return true;
1266
+ const opts = m[1] || '';
1267
+ const widthMatch = opts.match(/(?:^|,)\s*width\s*=\s*([^,]+)/);
1268
+ if (widthMatch && !convertLatexWidth(widthMatch[1]!)) return true;
1269
+ return false;
1270
+ }
1271
+
1272
+ /**
1273
+ * Find raw LaTeX figure blocks containing `\includegraphics` in markdown.
1274
+ * `file`, if given, is attached to each result. `line` is 1-based within the
1275
+ * supplied content (the line where `\begin{figure}` sits).
1276
+ */
1277
+ export function detectRawLatexFigures(content: string, file?: string): RawLatexFigure[] {
1278
+ const figures: RawLatexFigure[] = [];
1279
+ const re = makeRawFigureRegex();
1280
+ let m: RegExpExecArray | null;
1281
+ while ((m = re.exec(content)) !== null) {
1282
+ const block = m[0];
1283
+ if (!block.includes('\\includegraphics')) continue;
1284
+ const line = content.slice(0, m.index).split(/\r?\n/).length;
1285
+ figures.push({ file, line, block, exotic: isExoticFigureBlock(block) });
1286
+ }
1287
+ return figures;
1288
+ }
1289
+
1290
+ /**
1291
+ * Translate the 80% case: single `\includegraphics` figure with optional
1292
+ * `\caption{...}` and `\label{...}`, wrapped in `\begin{figure}...\end{figure}`,
1293
+ * to portable `![caption](path){#fig:label width=N%}` markdown. Exotic blocks
1294
+ * (see `isExoticFigureBlock`) are left untouched.
1295
+ */
1296
+ export function translateRawLatexFigures(content: string): { translated: string; translatedCount: number } {
1297
+ let translatedCount = 0;
1298
+ const re = makeRawFigureRegex();
1299
+ const translated = content.replace(re, (block) => {
1300
+ if (!block.includes('\\includegraphics')) return block;
1301
+ if (isExoticFigureBlock(block)) return block;
1302
+
1303
+ const inc = block.match(/\\includegraphics\s*(?:\[([^\]]*)\])?\s*\{([^}]+)\}/);
1304
+ if (!inc) return block;
1305
+ const optsStr = inc[1] || '';
1306
+ const imgPath = inc[2]!.trim();
1307
+
1308
+ let width: string | undefined;
1309
+ const widthMatch = optsStr.match(/(?:^|,)\s*width\s*=\s*([^,]+)/);
1310
+ if (widthMatch) {
1311
+ const w = convertLatexWidth(widthMatch[1]!);
1312
+ if (!w) return block; // already filtered by isExoticFigureBlock, defensive
1313
+ width = w;
1314
+ }
1315
+
1316
+ const caption = (extractBracedArg(block, '\\caption') ?? '').trim();
1317
+ const labelRaw = extractBracedArg(block, '\\label');
1318
+
1319
+ const attrs: string[] = [];
1320
+ if (labelRaw) {
1321
+ const label = labelRaw.trim();
1322
+ const labelWithPrefix = /^[a-z]+:/i.test(label) ? label : `fig:${label}`;
1323
+ attrs.push(`#${labelWithPrefix}`);
1324
+ }
1325
+ if (width) attrs.push(`width=${width}`);
1326
+
1327
+ translatedCount++;
1328
+ const attrStr = attrs.length > 0 ? ` {${attrs.join(' ')}}` : '';
1329
+ return `![${caption}](${imgPath})${attrStr}`;
1330
+ });
1331
+ return { translated, translatedCount };
1332
+ }
1333
+
1334
+ /**
1335
+ * Format the warning surfaced for raw LaTeX figure blocks that won't render
1336
+ * in docx. `translateEnabled` reflects whether auto-translate ran (true = the
1337
+ * listed blocks are exotic leftovers; false = no translation was attempted).
1338
+ */
1339
+ function formatRawLatexFigureWarning(figs: RawLatexFigure[], translateEnabled: boolean): string {
1340
+ const reason = translateEnabled ? 'too complex to auto-translate' : 'translateRawFigures: false';
1341
+ const lines: string[] = [
1342
+ `${figs.length} raw LaTeX figure block(s) won't render in docx (${reason}).`,
1343
+ ];
1344
+ for (const f of figs) {
1345
+ const loc = f.file ? `${f.file}:${f.line}` : `line ${f.line}`;
1346
+ const pathMatch = f.block.match(/\\includegraphics\s*(?:\[[^\]]*\])?\s*\{([^}]+)\}/);
1347
+ const pathInfo = pathMatch ? ` ${pathMatch[1]!.trim()}` : '';
1348
+ lines.push(` ${loc}${pathInfo}`);
1349
+ }
1350
+ lines.push(' Hint: use ![caption](path){#fig:label width=80%} for format-portable figures,');
1351
+ lines.push(' or pass --pandoc-arg=--lua-filter=<your.lua> to translate them yourself.');
1352
+ return lines.join('\n');
1353
+ }
1354
+
1355
+ /**
1356
+ * Walk section files and gather a warning for any raw LaTeX figure blocks that
1357
+ * won't survive the docx build. Returns null when there's nothing to warn about.
1358
+ */
1359
+ export function collectRawLatexFigureWarning(directory: string, config: BuildConfig): string | null {
1360
+ const translateEnabled = config.docx?.translateRawFigures !== false;
1361
+ const all: RawLatexFigure[] = [];
1362
+ for (const section of findSections(directory, config.sections)) {
1363
+ const sectionPath = path.join(directory, section);
1364
+ if (!fs.existsSync(sectionPath)) continue;
1365
+ try {
1366
+ const content = fs.readFileSync(sectionPath, 'utf-8');
1367
+ const figs = detectRawLatexFigures(content, section);
1368
+ for (const f of figs) {
1369
+ // When auto-translate is on, non-exotic blocks get rewritten cleanly —
1370
+ // only the exotic leftovers need warning. When opted out, everything
1371
+ // is at risk and we warn about every block.
1372
+ if (translateEnabled && !f.exotic) continue;
1373
+ all.push(f);
1374
+ }
1375
+ } catch {
1376
+ // ignore unreadable sections
1377
+ }
1378
+ }
1379
+ if (all.length === 0) return null;
1380
+ return formatRawLatexFigureWarning(all, translateEnabled);
1381
+ }
1382
+
1383
+ /**
1384
+ * Collect an inline `header-includes` LaTeX string from the config. Pandoc's
1385
+ * native metadata key is `header-includes`; accept it at the top level (where
1386
+ * pandoc documents it) and under `pdf`. String or list of strings; entries are
1387
+ * joined with newlines. Returns null when nothing is set.
1388
+ */
1389
+ function getInlineHeaderIncludes(config: BuildConfig): string | null {
1390
+ const top = (config as unknown as Record<string, unknown>)['header-includes'];
1391
+ const pdf = (config.pdf as unknown as Record<string, unknown> | undefined)?.['header-includes'];
1392
+ const parts: string[] = [];
1393
+ for (const value of [top, pdf]) {
1394
+ if (typeof value === 'string' && value.trim()) {
1395
+ parts.push(value);
1396
+ } else if (Array.isArray(value)) {
1397
+ for (const item of value) {
1398
+ if (typeof item === 'string' && item.trim()) parts.push(item);
1399
+ }
1400
+ }
1401
+ }
1402
+ return parts.length > 0 ? parts.join('\n') : null;
1403
+ }
1404
+
1405
+ /**
1406
+ * Resolve user-supplied LaTeX preamble sources into files to inject via
1407
+ * pandoc's `-H`. Covers `pdf.header` / `pdf.footer` (file paths, relative to
1408
+ * the project dir) and an inline `header-includes` string (written to a temp
1409
+ * `.tex` in the project dir). Pandoc runs with cwd = the project dir, so
1410
+ * returned paths are left relative to it.
1411
+ *
1412
+ * A `pdf.header`/`pdf.footer` file that does not exist is reported in
1413
+ * `warnings` rather than dropped silently — the fail-open loss of the entire
1414
+ * user preamble was the bug this addresses.
1415
+ *
1416
+ * @returns headerArgs — file paths to pass after `-H`; tempFiles — paths the
1417
+ * caller must clean up; warnings — human-readable notices for missing files.
1418
+ */
1419
+ export function resolveLatexPreambleSources(
1420
+ directory: string,
1421
+ config: BuildConfig
1422
+ ): { headerArgs: string[]; tempFiles: string[]; warnings: string[] } {
1423
+ const headerArgs: string[] = [];
1424
+ const tempFiles: string[] = [];
1425
+ const warnings: string[] = [];
1426
+
1427
+ const pdf = (config.pdf || {}) as PdfConfig;
1428
+ for (const key of ['header', 'footer'] as const) {
1429
+ const value = pdf[key];
1430
+ if (!value || typeof value !== 'string') continue;
1431
+ const abs = path.isAbsolute(value) ? value : path.join(directory, value);
1432
+ if (fs.existsSync(abs)) {
1433
+ headerArgs.push(value);
1434
+ } else {
1435
+ warnings.push(`pdf.${key}: LaTeX preamble file not found — ${value} (not applied)`);
1436
+ }
1437
+ }
1438
+
1439
+ const inline = getInlineHeaderIncludes(config);
1440
+ if (inline) {
1441
+ const inlinePath = path.join(directory, '.header-includes.tex');
1442
+ fs.writeFileSync(inlinePath, inline.endsWith('\n') ? inline : inline + '\n', 'utf-8');
1443
+ tempFiles.push(inlinePath);
1444
+ headerArgs.push(path.basename(inlinePath));
1445
+ }
1446
+
1447
+ return { headerArgs, tempFiles, warnings };
1448
+ }
1449
+
1450
+ /**
1451
+ * Build pandoc arguments for format.
1452
+ *
1453
+ * Returns only the built-in args derived from config. Passthrough args
1454
+ * (config.pandocArgs, config[format].pandocArgs, CLI --pandoc-arg) are
1455
+ * appended later in runPandoc so they win against pptx/crossref defaults
1456
+ * added there.
1457
+ */
1458
+ export function buildPandocArgs(format: string, config: BuildConfig, outputPath: string): string[] {
1459
+ const args: string[] = [];
1460
+
1461
+ // Output format
1462
+ if (format === 'tex') {
1463
+ args.push('-t', 'latex');
1464
+ if (config.tex.standalone) {
1465
+ args.push('-s');
1466
+ }
1467
+ } else if (format === 'pdf') {
1468
+ args.push('-t', 'pdf');
1469
+ } else if (format === 'docx') {
1470
+ args.push('-t', 'docx');
1471
+ } else if (format === 'beamer') {
1472
+ args.push('-t', 'beamer');
1473
+ } else if (format === 'pptx') {
1474
+ args.push('-t', 'pptx');
1475
+ }
1476
+
1477
+ // Output file. runPandoc sets cwd to the project directory and passes a
1478
+ // path relative to that cwd; passing it through here unchanged lets pandoc
1479
+ // write to subdirectories like output/<title-slug>.<ext>.
1480
+ args.push('-o', outputPath);
1481
+
1482
+ // Crossref filter (if available) - skip for slides
1483
+ if (hasPandocCrossref() && format !== 'beamer' && format !== 'pptx') {
1484
+ args.push('--filter', 'pandoc-crossref');
1485
+ }
1486
+
1487
+ // Bibliography
1488
+ if (config.bibliography) {
1489
+ args.push('--citeproc');
1490
+ }
1491
+
1492
+ // Format-specific options
1493
+ if (format === 'pdf') {
1494
+ if (config.pdf.template) {
1495
+ args.push('--template', config.pdf.template);
1496
+ }
1497
+ if (config.pdf.engine) {
1498
+ args.push(`--pdf-engine=${config.pdf.engine}`);
1499
+ }
1500
+ if (config.pdf.mainfont) {
1501
+ args.push('-V', `mainfont=${config.pdf.mainfont}`);
1502
+ }
1503
+ if (config.pdf.sansfont) {
1504
+ args.push('-V', `sansfont=${config.pdf.sansfont}`);
1505
+ }
1506
+ if (config.pdf.monofont) {
1507
+ args.push('-V', `monofont=${config.pdf.monofont}`);
1508
+ }
1509
+ args.push('-V', `documentclass=${config.pdf.documentclass}`);
1510
+ args.push('-V', `fontsize=${config.pdf.fontsize}`);
1511
+ args.push('-V', `geometry:${config.pdf.geometry}`);
1512
+ if (config.pdf.headerIncludes) {
1513
+ args.push('-H', config.pdf.headerIncludes);
1514
+ }
1515
+ if (config.pdf.linestretch !== 1) {
1516
+ args.push('-V', `linestretch=${config.pdf.linestretch}`);
1517
+ }
1518
+ if (config.pdf.numbersections) {
1519
+ args.push('--number-sections');
1520
+ }
1521
+ if (config.pdf.toc) {
1522
+ args.push('--toc');
1523
+ }
1524
+ } else if (format === 'docx') {
1525
+ if (config.docx.reference) {
1526
+ args.push('--reference-doc', config.docx.reference);
1527
+ }
1528
+ if (config.docx.toc) {
1529
+ args.push('--toc');
1530
+ }
1531
+ } else if (format === 'beamer') {
1532
+ // Beamer slide options
1533
+ const beamer = config.beamer || {};
1534
+ if (beamer.theme) {
1535
+ args.push('-V', `theme=${beamer.theme}`);
1536
+ }
1537
+ if (beamer.colortheme) {
1538
+ args.push('-V', `colortheme=${beamer.colortheme}`);
1539
+ }
1540
+ if (beamer.fonttheme) {
1541
+ args.push('-V', `fonttheme=${beamer.fonttheme}`);
1542
+ }
1543
+ if (beamer.aspectratio) {
1544
+ args.push('-V', `aspectratio=${beamer.aspectratio}`);
1545
+ }
1546
+ if (beamer.navigation) {
1547
+ args.push('-V', `navigation=${beamer.navigation}`);
1548
+ }
1549
+ // Speaker notes - default to 'show' which creates presenter view PDF
1550
+ // Options: 'show' (dual screen), 'only' (notes only), 'hide' (no notes), false (disabled)
1551
+ const notesMode = beamer.notes !== undefined ? beamer.notes : 'show';
1552
+ if (notesMode && notesMode !== 'hide') {
1553
+ args.push('-V', `classoption=notes=${notesMode}`);
1554
+ }
1555
+ // Fit images within slide bounds (default: true)
1556
+ if (beamer.fit_images !== false) {
1557
+ const fitImagesHeader = `\\makeatletter
1558
+ \\def\\maxwidth{\\ifdim\\Gin@nat@width>\\linewidth\\linewidth\\else\\Gin@nat@width\\fi}
1559
+ \\def\\maxheight{\\ifdim\\Gin@nat@height>0.75\\textheight 0.75\\textheight\\else\\Gin@nat@height\\fi}
1560
+ \\makeatother
1561
+ \\setkeys{Gin}{width=\\maxwidth,height=\\maxheight,keepaspectratio}`;
1562
+ args.push('-V', `header-includes=${fitImagesHeader}`);
1563
+ }
1564
+ // Slides need standalone
1565
+ args.push('-s');
1566
+ } else if (format === 'pptx') {
1567
+ // PowerPoint options - handled separately in preparePptxTemplate
1568
+ // Reference doc is set by caller after template generation
1569
+ }
1570
+
1571
+ return args;
1572
+ }
1573
+
1574
+ /**
1575
+ * Collect passthrough pandoc args for a format in the canonical order:
1576
+ * top-level config → format-specific config → CLI extras. Later wins for
1577
+ * repeated flags.
1578
+ */
1579
+ export function collectPandocPassthroughArgs(
1580
+ format: string,
1581
+ config: BuildConfig,
1582
+ extraArgs: string[] = []
1583
+ ): string[] {
1584
+ const out: string[] = [];
1585
+ if (config.pandocArgs && config.pandocArgs.length > 0) {
1586
+ out.push(...config.pandocArgs);
1587
+ }
1588
+ const formatConfig = (config as unknown as Record<string, { pandocArgs?: string[] } | undefined>)[format];
1589
+ if (formatConfig?.pandocArgs && formatConfig.pandocArgs.length > 0) {
1590
+ out.push(...formatConfig.pandocArgs);
1591
+ }
1592
+ if (extraArgs.length > 0) {
1593
+ out.push(...extraArgs);
1594
+ }
1595
+ return out;
1596
+ }
1597
+
1598
+ /**
1599
+ * Write crossref.yaml if needed
1600
+ */
1601
+ function ensureCrossrefConfig(directory: string, config: BuildConfig): void {
1602
+ const crossrefPath = path.join(directory, 'crossref.yaml');
1603
+
1604
+ if (!fs.existsSync(crossrefPath) && hasPandocCrossref()) {
1605
+ fs.writeFileSync(crossrefPath, YAML.stringify(config.crossref), 'utf-8');
1606
+ }
1607
+ }
1608
+
1609
+ /**
1610
+ * Get install instructions for missing dependency
1611
+ */
1612
+ function getInstallInstructions(tool: string): string {
1613
+ const instructions: Record<string, string> = {
1614
+ pandoc: 'https://pandoc.org/installing.html',
1615
+ latex: 'https://www.latex-project.org/get/',
1616
+ };
1617
+ return instructions[tool] || 'Check documentation';
1618
+ }
1619
+
1620
+ /**
1621
+ * Resolve the absolute directory where final outputs should land.
1622
+ * Honors config.outputDir; falls back to the project directory when null/empty.
1623
+ */
1624
+ export function resolveOutputDir(directory: string, config: BuildConfig): string {
1625
+ const out = config.outputDir;
1626
+ if (!out) return directory;
1627
+ return path.isAbsolute(out) ? out : path.join(directory, out);
1628
+ }
1629
+
1630
+ /** File extension (with leading dot) for each supported pandoc format. */
1631
+ const FORMAT_EXTENSIONS: Record<string, string> = {
1632
+ tex: '.tex',
1633
+ pdf: '.pdf',
1634
+ docx: '.docx',
1635
+ beamer: '.pdf',
1636
+ pptx: '.pptx',
1637
+ };
1638
+
1639
+ /** Get file extension for a format, defaulting to `.pdf`. */
1640
+ export function getFormatExtension(format: string): string {
1641
+ return FORMAT_EXTENSIONS[format] ?? '.pdf';
1642
+ }
1643
+
1644
+ /**
1645
+ * Slugify a title for use as a default output filename. Lowercases, replaces
1646
+ * non-alphanumeric runs with `-`, and truncates at the last `-` boundary
1647
+ * at-or-before MAX_TITLE_FILENAME_LENGTH so words stay whole (the old blind
1648
+ * `.slice` cut mid-word).
1649
+ */
1650
+ export function slugifyTitle(title: string): string {
1651
+ if (!title) return 'paper';
1652
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1653
+ if (!slug) return 'paper';
1654
+ if (slug.length <= MAX_TITLE_FILENAME_LENGTH) return slug;
1655
+ const cut = slug.slice(0, MAX_TITLE_FILENAME_LENGTH);
1656
+ const lastDash = cut.lastIndexOf('-');
1657
+ // Only truncate at a hyphen if it leaves a reasonable amount of content.
1658
+ // Otherwise hard-cut (handles degenerate titles with no spaces at all).
1659
+ if (lastDash >= MAX_TITLE_FILENAME_LENGTH / 2) {
1660
+ return slug.slice(0, lastDash);
1661
+ }
1662
+ return cut;
1663
+ }
1664
+
1665
+ /**
1666
+ * Ensure `name` ends with `ext` (case-insensitive). If the user already supplied
1667
+ * the correct extension, return unchanged; if they supplied none or a different
1668
+ * one, append the format's canonical extension.
1669
+ *
1670
+ * Different-extension case (e.g. `output.docx` when building tex): we append
1671
+ * rather than replace, since stripping looks like an unsafe guess. The result
1672
+ * `output.docx.tex` is loud enough to flag the misconfiguration.
1673
+ */
1674
+ function ensureExtension(name: string, ext: string): string {
1675
+ if (name.toLowerCase().endsWith(ext.toLowerCase())) return name;
1676
+ return name + ext;
1677
+ }
1678
+
1679
+ /**
1680
+ * Resolve the final output path for a build.
1681
+ *
1682
+ * Priority: `options.outputPath` (internal force) > `cliOverride` (-o flag) >
1683
+ * `config.output[format]` > slugified title fallback.
1684
+ *
1685
+ * Relative paths from `cliOverride`/`config.output` resolve under outputDir;
1686
+ * absolute paths bypass outputDir. The fallback path always lives under
1687
+ * outputDir.
1688
+ *
1689
+ * @param suffix - Appended before the extension (e.g. "-changes", "-slides").
1690
+ * Suppressed when user supplied an explicit name via CLI or
1691
+ * config — they pick their own suffix.
1692
+ */
1693
+ export function resolveOutputPath(
1694
+ directory: string,
1695
+ config: BuildConfig,
1696
+ format: string,
1697
+ options: { cliOverride?: string; suffix?: string } = {}
1698
+ ): string {
1699
+ const { cliOverride, suffix = '' } = options;
1700
+ const ext = getFormatExtension(format);
1701
+
1702
+ const explicit = cliOverride ?? config.output?.[format];
1703
+ if (explicit) {
1704
+ const baseDir = path.isAbsolute(explicit)
1705
+ ? path.dirname(explicit)
1706
+ : resolveOutputDir(directory, config);
1707
+ const baseName = path.basename(explicit);
1708
+ const stem = baseName.replace(/\.[^./\\]+$/, '');
1709
+ return path.join(baseDir, ensureExtension(`${stem}${suffix}`, ext));
1710
+ }
1711
+
1712
+ const slug = slugifyTitle(config.title);
1713
+ return path.join(resolveOutputDir(directory, config), `${slug}${suffix}${ext}`);
1714
+ }
1715
+
1716
+ /**
1717
+ * Run pandoc build
1718
+ */
1719
+ export async function runPandoc(
1720
+ inputPath: string,
1721
+ format: string,
1722
+ config: BuildConfig,
1723
+ options: BuildOptions = {}
1724
+ ): Promise<PandocResult> {
1725
+ const directory = path.dirname(inputPath);
1726
+
1727
+ // outputPath (internal force) wins over the resolver. For beamer, we keep
1728
+ // the `-slides` suffix on the slug fallback to distinguish from a regular
1729
+ // PDF build; when the user supplies an explicit name, they pick their own.
1730
+ const suffix = format === 'beamer' ? '-slides' : '';
1731
+ const outputPath = options.outputPath
1732
+ ?? resolveOutputPath(directory, config, format, {
1733
+ cliOverride: options.output,
1734
+ suffix,
1735
+ });
1736
+
1737
+ if (!options.outputPath) {
1738
+ const outDir = path.dirname(outputPath);
1739
+ if (!fs.existsSync(outDir)) {
1740
+ fs.mkdirSync(outDir, { recursive: true });
1741
+ }
1742
+ }
1743
+
1744
+ // Ensure crossref.yaml exists
1745
+ ensureCrossrefConfig(directory, config);
1746
+
1747
+ // Pandoc runs with cwd = directory, so pass the output path relative to it.
1748
+ const args = buildPandocArgs(format, config, path.relative(directory, outputPath) || path.basename(outputPath));
1749
+
1750
+ // Handle PPTX reference template and themes
1751
+ let pptxMediaDir: string | null = null;
1752
+ if (format === 'pptx') {
1753
+ const pptx = config.pptx || {};
1754
+
1755
+ // Determine media directory (default: pptx/media or slides/media)
1756
+ let mediaDir = pptx.media;
1757
+ if (!mediaDir) {
1758
+ if (fs.existsSync(path.join(directory, 'pptx', 'media'))) {
1759
+ mediaDir = path.join(directory, 'pptx', 'media');
1760
+ } else if (fs.existsSync(path.join(directory, 'slides', 'media'))) {
1761
+ mediaDir = path.join(directory, 'slides', 'media');
1762
+ }
1763
+ } else if (!path.isAbsolute(mediaDir)) {
1764
+ mediaDir = path.join(directory, mediaDir);
1765
+ }
1766
+ pptxMediaDir = mediaDir || null;
1767
+
1768
+ // Determine reference doc: custom reference overrides theme
1769
+ let referenceDoc: string | null = null;
1770
+ if (pptx.reference && fs.existsSync(path.join(directory, pptx.reference))) {
1771
+ // Custom reference doc takes precedence
1772
+ referenceDoc = path.join(directory, pptx.reference);
1773
+ } else {
1774
+ // Use built-in theme (default: 'default')
1775
+ const themeName = pptx.theme || 'default';
1776
+ const themePath = getThemePath(themeName);
1777
+ if (themePath && fs.existsSync(themePath)) {
1778
+ referenceDoc = themePath;
1779
+ }
1780
+ }
1781
+
1782
+ if (referenceDoc) {
1783
+ args.push('--reference-doc', referenceDoc);
1784
+ }
1785
+
1786
+ // Add color filter for PPTX (handles [text]{color=#RRGGBB} syntax).
1787
+ // fileURLToPath handles Windows paths with spaces — the old
1788
+ // `new URL(...).pathname` returned URL-encoded `%20` and fs.existsSync
1789
+ // silently failed.
1790
+ const colorFilterPath = path.join(
1791
+ path.dirname(fileURLToPath(import.meta.url)),
1792
+ 'pptx-color-filter.lua'
1793
+ );
1794
+ if (fs.existsSync(colorFilterPath)) {
1795
+ args.push('--lua-filter', colorFilterPath);
1796
+ }
1797
+ }
1798
+
1799
+ // Wire placeholder macros (built-in \tofill plus user-declared entries).
1800
+ // - docx/html: lua filter expands \name{X} to format-specific raw runs.
1801
+ // - pdf/tex/beamer: inject a \providecommand preamble so LaTeX renders it
1802
+ // directly. `\providecommand` is non-clobbering, so a user who already
1803
+ // has `\providecommand{\tofill}{...}` in their own header keeps theirs.
1804
+ //
1805
+ // Sidecar path is passed to the lua filter via DOCREV_MACROS_FILE in the
1806
+ // child env (not pandoc metadata) because pandoc walks RawInline/RawBlock
1807
+ // BEFORE Meta by the time a Meta handler could read the path, the inline
1808
+ // expansion has already happened.
1809
+ const macroTempFiles: string[] = [];
1810
+ let macroEnvFile: string | null = null;
1811
+ const macros = mergeMacros((config as { macros?: unknown }).macros);
1812
+ if (macros.length > 0) {
1813
+ if (format === 'docx' || format === 'html' || format === 'html5' || format === 'html4') {
1814
+ const sidecarPath = writeMacrosSidecar(directory, macros);
1815
+ macroTempFiles.push(sidecarPath);
1816
+ macroEnvFile = sidecarPath;
1817
+ const filterPath = getMacroFilterPath();
1818
+ if (fs.existsSync(filterPath)) {
1819
+ args.push('--lua-filter', filterPath);
1820
+ }
1821
+ } else if (format === 'pdf' || format === 'tex' || format === 'beamer') {
1822
+ const preamble = generateLatexPreamble(macros);
1823
+ const preamblePath = path.join(directory, '.macros.tex');
1824
+ fs.writeFileSync(preamblePath, preamble, 'utf-8');
1825
+ macroTempFiles.push(preamblePath);
1826
+ args.push('-H', path.basename(preamblePath));
1827
+ }
1828
+ }
1829
+
1830
+ // User-supplied LaTeX preamble: pdf.header / pdf.footer (file paths) and an
1831
+ // inline header-includes block, injected through pandoc's -H channel — the
1832
+ // same mechanism the macros preamble and annotated-comments path use. Only
1833
+ // the LaTeX family consumes a preamble.
1834
+ if (format === 'pdf' || format === 'tex' || format === 'beamer') {
1835
+ const { headerArgs, tempFiles, warnings } = resolveLatexPreambleSources(directory, config);
1836
+ for (const warning of warnings) {
1837
+ console.warn(`Warning: ${warning}`);
1838
+ }
1839
+ for (const headerFile of headerArgs) {
1840
+ args.push('-H', headerFile);
1841
+ }
1842
+ macroTempFiles.push(...tempFiles);
1843
+ }
1844
+
1845
+ // Add crossref metadata file if exists (skip for slides - they don't use crossref)
1846
+ if (format !== 'beamer' && format !== 'pptx') {
1847
+ const crossrefPath = path.join(directory, 'crossref.yaml');
1848
+ if (fs.existsSync(crossrefPath) && hasPandocCrossref()) {
1849
+ // Use basename since we set cwd to directory
1850
+ args.push('--metadata-file', 'crossref.yaml');
1851
+ }
1852
+ }
1853
+
1854
+ // Passthrough args go last so they win against built-in defaults.
1855
+ args.push(...collectPandocPassthroughArgs(format, config, options.pandocArgs));
1856
+
1857
+ // Input file (use basename since we set cwd to directory)
1858
+ args.push(path.basename(inputPath));
1859
+
1860
+ if (options.verbose) {
1861
+ const quoted = args.map(a => /[\s"'$`]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a).join(' ');
1862
+ console.error(`[pandoc ${format}] (cwd: ${directory})`);
1863
+ console.error(` pandoc ${quoted}`);
1864
+ }
1865
+
1866
+ return new Promise((resolve) => {
1867
+ const pandocEnv: NodeJS.ProcessEnv = { ...process.env };
1868
+ if (macroEnvFile) {
1869
+ pandocEnv.DOCREV_MACROS_FILE = macroEnvFile;
1870
+ }
1871
+ const pandoc: ChildProcess = spawn('pandoc', args, {
1872
+ cwd: directory,
1873
+ stdio: ['ignore', 'pipe', 'pipe'],
1874
+ env: pandocEnv,
1875
+ });
1876
+
1877
+ let stderr = '';
1878
+ pandoc.stderr?.on('data', (data) => {
1879
+ stderr += data.toString();
1880
+ });
1881
+
1882
+ const cleanupMacroTempFiles = (): void => {
1883
+ for (const tmp of macroTempFiles) {
1884
+ try {
1885
+ fs.unlinkSync(tmp);
1886
+ } catch {
1887
+ // ignore — best-effort cleanup
1888
+ }
1889
+ }
1890
+ };
1891
+
1892
+ pandoc.on('close', async (code) => {
1893
+ cleanupMacroTempFiles();
1894
+ if (code === 0) {
1895
+ // For PPTX, post-process to add slide numbers, buildup colors, and logos.
1896
+ // These steps enhance an already-written deck; a failure degrades the
1897
+ // output rather than aborting it, so each is reported as a warning
1898
+ // instead of silently swallowed — otherwise the user gets a deck
1899
+ // missing slide numbers / colors / logos with no indication why.
1900
+ if (format === 'pptx') {
1901
+ const warn = (step: string, e: unknown) =>
1902
+ console.warn(`Warning: PPTX ${step} failed; deck written without it (${(e as Error).message})`);
1903
+ try {
1904
+ await injectSlideNumbers(outputPath);
1905
+ } catch (e) {
1906
+ warn('slide numbering', e);
1907
+ }
1908
+ try {
1909
+ // Apply colors (default text color, title color, buildup greying)
1910
+ const pptxConfig = config.pptx || {};
1911
+ const colorsConfig = pptxConfig.colors || {};
1912
+ const buildupConfig = pptxConfig.buildup || {};
1913
+ const colorConfig = {
1914
+ default: colorsConfig.default,
1915
+ title: colorsConfig.title,
1916
+ grey: buildupConfig.grey,
1917
+ accent: buildupConfig.accent,
1918
+ enabled: buildupConfig.enabled
1919
+ };
1920
+ await applyBuildupColors(outputPath, colorConfig);
1921
+ } catch (e) {
1922
+ warn('color application', e);
1923
+ }
1924
+ // Inject logos into cover slide (if media dir configured)
1925
+ if (pptxMediaDir) {
1926
+ try {
1927
+ await injectMediaIntoPptx(outputPath, pptxMediaDir);
1928
+ } catch (e) {
1929
+ warn('logo injection', e);
1930
+ }
1931
+ }
1932
+ }
1933
+
1934
+ // Run user postprocess scripts
1935
+ const postResult = await runPostprocess(outputPath, format, config as unknown as Parameters<typeof runPostprocess>[2], options);
1936
+ if (!postResult.success && options.verbose) {
1937
+ console.error(`Postprocess warning: ${postResult.error}`);
1938
+ }
1939
+
1940
+ resolve({ outputPath, success: true });
1941
+ } else {
1942
+ resolve({ outputPath, success: false, error: stderr || `Exit code ${code}` });
1943
+ }
1944
+ });
1945
+
1946
+ pandoc.on('error', (err) => {
1947
+ cleanupMacroTempFiles();
1948
+ resolve({ outputPath, success: false, error: err.message });
1949
+ });
1950
+ });
1951
+ }
1952
+
1953
+ /**
1954
+ * Full build pipeline
1955
+ */
1956
+ export async function build(
1957
+ directory: string,
1958
+ formats: string[] = ['pdf', 'docx'],
1959
+ options: BuildOptions = {}
1960
+ ): Promise<FullBuildResult> {
1961
+ const warnings: string[] = [];
1962
+ let forwardRefsResolved = 0;
1963
+
1964
+ // Check pandoc
1965
+ if (!hasPandoc()) {
1966
+ const instruction = getInstallInstructions('pandoc');
1967
+ throw new Error(`Pandoc not found. Install with: ${instruction}\nOr run: rev doctor`);
1968
+ }
1969
+
1970
+ // Check LaTeX if PDF is requested
1971
+ if ((formats.includes('pdf') || formats.includes('all')) && !hasLatex()) {
1972
+ warnings.push(`LaTeX not found - PDF generation may fail. Install with: ${getInstallInstructions('latex')}`);
1973
+ }
1974
+
1975
+ // Check pandoc-crossref
1976
+ if (!hasPandocCrossref()) {
1977
+ warnings.push('pandoc-crossref not found - figure/table numbering will not work');
1978
+ }
1979
+
1980
+ // Load config (use passed config if provided, otherwise load from file)
1981
+ const config = options.config || loadConfig(directory);
1982
+
1983
+ // Combine sections → paper.md
1984
+ const buildOptions: CombineOptions = { ...options };
1985
+ const paperPath = combineSections(directory, config, buildOptions);
1986
+ forwardRefsResolved = buildOptions._forwardRefsResolved || 0;
1987
+ const refsAutoInjected = buildOptions._refsAutoInjected || false;
1988
+
1989
+ // Expand 'all' to all formats
1990
+ if (formats.includes('all')) {
1991
+ formats = ['pdf', 'docx', 'tex'];
1992
+ }
1993
+
1994
+ // Build and save image registry when DOCX is being built
1995
+ // This allows import to restore proper image syntax from Word documents
1996
+ if (formats.includes('docx')) {
1997
+ const paperContent = fs.readFileSync(paperPath, 'utf-8');
1998
+ const crossrefReg = buildRegistry(directory, config.sections);
1999
+ const imageReg = buildImageRegistry(paperContent, crossrefReg as any);
2000
+ if ((imageReg as any).figures?.length > 0) {
2001
+ writeImageRegistry(directory, imageReg);
2002
+ }
2003
+
2004
+ // Warn about raw LaTeX figure blocks that won't render in docx (pandoc
2005
+ // drops them silently). With auto-translate on (default), this surfaces
2006
+ // only the exotic leftovers; with it off, every block.
2007
+ const rawFigWarning = collectRawLatexFigureWarning(directory, config);
2008
+ if (rawFigWarning) warnings.push(rawFigWarning);
2009
+ }
2010
+
2011
+ const results: BuildResult[] = [];
2012
+
2013
+ for (const format of formats) {
2014
+ // Prepare format-specific version
2015
+ const preparedPath = prepareForFormat(paperPath, format, config, options);
2016
+
2017
+ // Run pandoc
2018
+ const result = await runPandoc(preparedPath, format, config, options);
2019
+ results.push({ format, ...result });
2020
+
2021
+ // Clean up temp file
2022
+ try {
2023
+ fs.unlinkSync(preparedPath);
2024
+ } catch {
2025
+ // Ignore cleanup errors
2026
+ }
2027
+ }
2028
+
2029
+ return { results, paperPath, warnings, forwardRefsResolved, refsAutoInjected };
2030
+ }
2031
+
2032
+ /**
2033
+ * Get build status summary
2034
+ */
2035
+ export function formatBuildResults(results: BuildResult[]): string {
2036
+ const lines: string[] = [];
2037
+
2038
+ for (const r of results) {
2039
+ if (r.success) {
2040
+ lines.push(` ${r.format.toUpperCase()}: ${path.basename(r.outputPath!)}`);
2041
+ } else {
2042
+ lines.push(` ${r.format.toUpperCase()}: FAILED - ${r.error}`);
2043
+ }
2044
+ }
2045
+
2046
+ return lines.join('\n');
2047
+ }