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