docrev 0.9.17 → 0.10.0
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.
- package/CHANGELOG.md +15 -0
- package/README.md +25 -0
- package/dist/lib/build.d.ts +119 -1
- package/dist/lib/build.d.ts.map +1 -1
- package/dist/lib/build.js +409 -23
- package/dist/lib/build.js.map +1 -1
- package/dist/lib/commands/build.d.ts.map +1 -1
- package/dist/lib/commands/build.js +25 -10
- package/dist/lib/commands/build.js.map +1 -1
- package/dist/lib/macro-filter.lua +201 -0
- package/dist/lib/macros.d.ts +102 -0
- package/dist/lib/macros.d.ts.map +1 -0
- package/dist/lib/macros.js +218 -0
- package/dist/lib/macros.js.map +1 -0
- package/dist/lib/pptx-color-filter.lua +37 -0
- package/dist/lib/schema.d.ts.map +1 -1
- package/dist/lib/schema.js +71 -0
- package/dist/lib/schema.js.map +1 -1
- package/lib/build.ts +484 -24
- package/lib/commands/build.ts +32 -10
- package/lib/macro-filter.lua +201 -0
- package/lib/macros.ts +273 -0
- package/lib/schema.ts +71 -0
- package/package.json +1 -1
- package/scripts/postbuild.js +21 -2
- package/skill/REFERENCE.md +66 -0
- package/skill/SKILL.md +25 -4
- package/.claude/settings.local.json +0 -9
package/dist/lib/build.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import * as fs from 'fs';
|
|
11
11
|
import * as path from 'path';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
12
13
|
import { spawn } from 'child_process';
|
|
13
14
|
import YAML from 'yaml';
|
|
14
15
|
import { stripAnnotations } from './annotations.js';
|
|
@@ -22,13 +23,19 @@ import { hasPandoc, hasPandocCrossref, hasLatex } from './dependencies.js';
|
|
|
22
23
|
import { buildImageRegistry, writeImageRegistry } from './image-registry.js';
|
|
23
24
|
import { getJournalProfile } from './journals.js';
|
|
24
25
|
import { resolveCSL } from './csl.js';
|
|
26
|
+
import { mergeMacros, generateLatexPreamble, writeMacrosSidecar, getMacroFilterPath, } from './macros.js';
|
|
25
27
|
// =============================================================================
|
|
26
28
|
// Constants
|
|
27
29
|
// =============================================================================
|
|
28
30
|
/** Supported output formats */
|
|
29
31
|
const SUPPORTED_FORMATS = ['pdf', 'docx', 'tex', 'beamer', 'pptx'];
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Maximum length for slugified-title output filenames. Only used when no
|
|
34
|
+
* explicit `output:` filename is configured. Long titles are truncated at the
|
|
35
|
+
* last `-` boundary at-or-before this length so words stay intact (the old
|
|
36
|
+
* blind `.slice(0, 50)` cut mid-word).
|
|
37
|
+
*/
|
|
38
|
+
const MAX_TITLE_FILENAME_LENGTH = 80;
|
|
32
39
|
/**
|
|
33
40
|
* Default rev.yaml configuration
|
|
34
41
|
*/
|
|
@@ -61,6 +68,7 @@ export const DEFAULT_CONFIG = {
|
|
|
61
68
|
keepComments: false,
|
|
62
69
|
affiliationNewline: true,
|
|
63
70
|
toc: false,
|
|
71
|
+
translateRawFigures: true,
|
|
64
72
|
},
|
|
65
73
|
tex: {
|
|
66
74
|
standalone: true,
|
|
@@ -94,6 +102,9 @@ export const DEFAULT_CONFIG = {
|
|
|
94
102
|
beamer: null,
|
|
95
103
|
all: null, // Runs after any format
|
|
96
104
|
},
|
|
105
|
+
// Placeholder/highlight macros. Defaults are the built-ins from
|
|
106
|
+
// lib/macros.ts; users append their own here.
|
|
107
|
+
macros: [],
|
|
97
108
|
// Final outputs land here (created on demand). Set to null or '' to keep
|
|
98
109
|
// outputs in the project root.
|
|
99
110
|
outputDir: 'output',
|
|
@@ -158,6 +169,21 @@ export function mergeJournalFormatting(config, formatting, directory) {
|
|
|
158
169
|
}
|
|
159
170
|
return merged;
|
|
160
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* In-place: copy `pandoc-args` → `pandocArgs` on an object (if not already set).
|
|
174
|
+
* Idempotent. Coerces a single string into a one-element array.
|
|
175
|
+
*/
|
|
176
|
+
function normalizePandocArgsKey(obj) {
|
|
177
|
+
if (!obj || typeof obj !== 'object')
|
|
178
|
+
return;
|
|
179
|
+
const hy = obj['pandoc-args'];
|
|
180
|
+
if (hy === undefined)
|
|
181
|
+
return;
|
|
182
|
+
if (obj.pandocArgs === undefined) {
|
|
183
|
+
obj.pandocArgs = Array.isArray(hy) ? hy : [hy];
|
|
184
|
+
}
|
|
185
|
+
delete obj['pandoc-args'];
|
|
186
|
+
}
|
|
161
187
|
/**
|
|
162
188
|
* Load rev.yaml config from directory
|
|
163
189
|
* @param directory - Project directory path
|
|
@@ -176,6 +202,15 @@ export function loadConfig(directory) {
|
|
|
176
202
|
try {
|
|
177
203
|
const content = fs.readFileSync(configPath, 'utf-8');
|
|
178
204
|
const userConfig = YAML.parse(content) || {};
|
|
205
|
+
// Accept hyphenated `pandoc-args` (the form pandoc itself uses) in addition
|
|
206
|
+
// to camelCase `pandocArgs`. Hyphenated is what we document; camelCase is
|
|
207
|
+
// accepted for users who already prefer that convention.
|
|
208
|
+
normalizePandocArgsKey(userConfig);
|
|
209
|
+
for (const fmt of ['pdf', 'docx', 'tex', 'beamer', 'pptx']) {
|
|
210
|
+
if (userConfig[fmt] && typeof userConfig[fmt] === 'object') {
|
|
211
|
+
normalizePandocArgsKey(userConfig[fmt]);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
179
214
|
// Deep merge with defaults
|
|
180
215
|
let config = {
|
|
181
216
|
...DEFAULT_CONFIG,
|
|
@@ -576,6 +611,13 @@ export function applyFormatTransforms(content, format, config, registry) {
|
|
|
576
611
|
}
|
|
577
612
|
else if (format === 'docx') {
|
|
578
613
|
content = convertDynamicRefsToDisplay(content, registry);
|
|
614
|
+
// Pandoc strips raw LaTeX in docx output. Translate the common
|
|
615
|
+
// `\begin{figure}...\end{figure}` shape to portable markdown so figures
|
|
616
|
+
// actually appear; exotic blocks are left alone (warned about in build()).
|
|
617
|
+
if (config.docx?.translateRawFigures !== false) {
|
|
618
|
+
const { translated } = translateRawLatexFigures(content);
|
|
619
|
+
content = translated;
|
|
620
|
+
}
|
|
579
621
|
if (hasNumberedAffiliations(config)) {
|
|
580
622
|
const mdBlock = generateMarkdownAuthorBlock(config);
|
|
581
623
|
content = content.replace(/^(---\r?\n[\s\S]*?---\r?\n)/, `$1\n${mdBlock}\n`);
|
|
@@ -629,8 +671,202 @@ function convertDynamicRefsToDisplay(text, registry) {
|
|
|
629
671
|
}
|
|
630
672
|
return result;
|
|
631
673
|
}
|
|
674
|
+
/** Match `\begin{figure}` / `\begin{figure*}` … `\end{figure}` blocks. */
|
|
675
|
+
function makeRawFigureRegex() {
|
|
676
|
+
return /\\begin\{figure\*?\}(?:\[[^\]]*\])?[\s\S]*?\\end\{figure\*?\}/g;
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Convert a LaTeX width spec to a markdown image attribute value.
|
|
680
|
+
* - `0.8\textwidth` → `80%`
|
|
681
|
+
* - `\linewidth` → `100%`
|
|
682
|
+
* - `8cm`, `2in`, `12pt` → kept verbatim
|
|
683
|
+
* Returns null for anything we don't translate (block stays "exotic").
|
|
684
|
+
*/
|
|
685
|
+
function convertLatexWidth(raw) {
|
|
686
|
+
const trimmed = raw.trim();
|
|
687
|
+
// Coefficient × relative length
|
|
688
|
+
const rel = trimmed.match(/^([\d.]+)\s*\\(textwidth|linewidth|columnwidth)$/);
|
|
689
|
+
if (rel) {
|
|
690
|
+
const pct = Math.round(parseFloat(rel[1]) * 100);
|
|
691
|
+
if (!isFinite(pct) || pct <= 0)
|
|
692
|
+
return null;
|
|
693
|
+
return `${pct}%`;
|
|
694
|
+
}
|
|
695
|
+
// Bare relative length
|
|
696
|
+
if (/^\\(textwidth|linewidth|columnwidth)$/.test(trimmed))
|
|
697
|
+
return '100%';
|
|
698
|
+
// Absolute units
|
|
699
|
+
if (/^[\d.]+\s*(cm|mm|in|pt|px|em|ex)$/.test(trimmed))
|
|
700
|
+
return trimmed.replace(/\s+/g, '');
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
/** Extract a balanced `{...}` argument that follows `command` in `text`. */
|
|
704
|
+
function extractBracedArg(text, command) {
|
|
705
|
+
const idx = text.indexOf(command);
|
|
706
|
+
if (idx === -1)
|
|
707
|
+
return null;
|
|
708
|
+
let i = idx + command.length;
|
|
709
|
+
while (i < text.length && /\s/.test(text[i]))
|
|
710
|
+
i++;
|
|
711
|
+
if (text[i] !== '{')
|
|
712
|
+
return null;
|
|
713
|
+
i++;
|
|
714
|
+
const start = i;
|
|
715
|
+
let depth = 1;
|
|
716
|
+
while (i < text.length) {
|
|
717
|
+
const ch = text[i];
|
|
718
|
+
if (ch === '\\' && i + 1 < text.length) {
|
|
719
|
+
i += 2;
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
if (ch === '{')
|
|
723
|
+
depth++;
|
|
724
|
+
else if (ch === '}') {
|
|
725
|
+
depth--;
|
|
726
|
+
if (depth === 0)
|
|
727
|
+
return text.slice(start, i);
|
|
728
|
+
}
|
|
729
|
+
i++;
|
|
730
|
+
}
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
733
|
+
/** True if a `\begin{figure}` block contains features we don't auto-translate. */
|
|
734
|
+
function isExoticFigureBlock(block) {
|
|
735
|
+
if (/\\subfloat\b/.test(block))
|
|
736
|
+
return true;
|
|
737
|
+
if (/\\rotatebox\b/.test(block))
|
|
738
|
+
return true;
|
|
739
|
+
const includes = (block.match(/\\includegraphics\b/g) || []).length;
|
|
740
|
+
if (includes !== 1)
|
|
741
|
+
return true;
|
|
742
|
+
const m = block.match(/\\includegraphics\s*(?:\[([^\]]*)\])?\s*\{([^}]+)\}/);
|
|
743
|
+
if (!m)
|
|
744
|
+
return true;
|
|
745
|
+
const opts = m[1] || '';
|
|
746
|
+
const widthMatch = opts.match(/(?:^|,)\s*width\s*=\s*([^,]+)/);
|
|
747
|
+
if (widthMatch && !convertLatexWidth(widthMatch[1]))
|
|
748
|
+
return true;
|
|
749
|
+
return false;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Find raw LaTeX figure blocks containing `\includegraphics` in markdown.
|
|
753
|
+
* `file`, if given, is attached to each result. `line` is 1-based within the
|
|
754
|
+
* supplied content (the line where `\begin{figure}` sits).
|
|
755
|
+
*/
|
|
756
|
+
export function detectRawLatexFigures(content, file) {
|
|
757
|
+
const figures = [];
|
|
758
|
+
const re = makeRawFigureRegex();
|
|
759
|
+
let m;
|
|
760
|
+
while ((m = re.exec(content)) !== null) {
|
|
761
|
+
const block = m[0];
|
|
762
|
+
if (!block.includes('\\includegraphics'))
|
|
763
|
+
continue;
|
|
764
|
+
const line = content.slice(0, m.index).split(/\r?\n/).length;
|
|
765
|
+
figures.push({ file, line, block, exotic: isExoticFigureBlock(block) });
|
|
766
|
+
}
|
|
767
|
+
return figures;
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Translate the 80% case: single `\includegraphics` figure with optional
|
|
771
|
+
* `\caption{...}` and `\label{...}`, wrapped in `\begin{figure}...\end{figure}`,
|
|
772
|
+
* to portable `{#fig:label width=N%}` markdown. Exotic blocks
|
|
773
|
+
* (see `isExoticFigureBlock`) are left untouched.
|
|
774
|
+
*/
|
|
775
|
+
export function translateRawLatexFigures(content) {
|
|
776
|
+
let translatedCount = 0;
|
|
777
|
+
const re = makeRawFigureRegex();
|
|
778
|
+
const translated = content.replace(re, (block) => {
|
|
779
|
+
if (!block.includes('\\includegraphics'))
|
|
780
|
+
return block;
|
|
781
|
+
if (isExoticFigureBlock(block))
|
|
782
|
+
return block;
|
|
783
|
+
const inc = block.match(/\\includegraphics\s*(?:\[([^\]]*)\])?\s*\{([^}]+)\}/);
|
|
784
|
+
if (!inc)
|
|
785
|
+
return block;
|
|
786
|
+
const optsStr = inc[1] || '';
|
|
787
|
+
const imgPath = inc[2].trim();
|
|
788
|
+
let width;
|
|
789
|
+
const widthMatch = optsStr.match(/(?:^|,)\s*width\s*=\s*([^,]+)/);
|
|
790
|
+
if (widthMatch) {
|
|
791
|
+
const w = convertLatexWidth(widthMatch[1]);
|
|
792
|
+
if (!w)
|
|
793
|
+
return block; // already filtered by isExoticFigureBlock, defensive
|
|
794
|
+
width = w;
|
|
795
|
+
}
|
|
796
|
+
const caption = (extractBracedArg(block, '\\caption') ?? '').trim();
|
|
797
|
+
const labelRaw = extractBracedArg(block, '\\label');
|
|
798
|
+
const attrs = [];
|
|
799
|
+
if (labelRaw) {
|
|
800
|
+
const label = labelRaw.trim();
|
|
801
|
+
const labelWithPrefix = /^[a-z]+:/i.test(label) ? label : `fig:${label}`;
|
|
802
|
+
attrs.push(`#${labelWithPrefix}`);
|
|
803
|
+
}
|
|
804
|
+
if (width)
|
|
805
|
+
attrs.push(`width=${width}`);
|
|
806
|
+
translatedCount++;
|
|
807
|
+
const attrStr = attrs.length > 0 ? ` {${attrs.join(' ')}}` : '';
|
|
808
|
+
return `${attrStr}`;
|
|
809
|
+
});
|
|
810
|
+
return { translated, translatedCount };
|
|
811
|
+
}
|
|
632
812
|
/**
|
|
633
|
-
*
|
|
813
|
+
* Format the warning surfaced for raw LaTeX figure blocks that won't render
|
|
814
|
+
* in docx. `translateEnabled` reflects whether auto-translate ran (true = the
|
|
815
|
+
* listed blocks are exotic leftovers; false = no translation was attempted).
|
|
816
|
+
*/
|
|
817
|
+
function formatRawLatexFigureWarning(figs, translateEnabled) {
|
|
818
|
+
const reason = translateEnabled ? 'too complex to auto-translate' : 'translateRawFigures: false';
|
|
819
|
+
const lines = [
|
|
820
|
+
`${figs.length} raw LaTeX figure block(s) won't render in docx (${reason}).`,
|
|
821
|
+
];
|
|
822
|
+
for (const f of figs) {
|
|
823
|
+
const loc = f.file ? `${f.file}:${f.line}` : `line ${f.line}`;
|
|
824
|
+
const pathMatch = f.block.match(/\\includegraphics\s*(?:\[[^\]]*\])?\s*\{([^}]+)\}/);
|
|
825
|
+
const pathInfo = pathMatch ? ` ${pathMatch[1].trim()}` : '';
|
|
826
|
+
lines.push(` ${loc}${pathInfo}`);
|
|
827
|
+
}
|
|
828
|
+
lines.push(' Hint: use {#fig:label width=80%} for format-portable figures,');
|
|
829
|
+
lines.push(' or pass --pandoc-arg=--lua-filter=<your.lua> to translate them yourself.');
|
|
830
|
+
return lines.join('\n');
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Walk section files and gather a warning for any raw LaTeX figure blocks that
|
|
834
|
+
* won't survive the docx build. Returns null when there's nothing to warn about.
|
|
835
|
+
*/
|
|
836
|
+
export function collectRawLatexFigureWarning(directory, config) {
|
|
837
|
+
const translateEnabled = config.docx?.translateRawFigures !== false;
|
|
838
|
+
const all = [];
|
|
839
|
+
for (const section of findSections(directory, config.sections)) {
|
|
840
|
+
const sectionPath = path.join(directory, section);
|
|
841
|
+
if (!fs.existsSync(sectionPath))
|
|
842
|
+
continue;
|
|
843
|
+
try {
|
|
844
|
+
const content = fs.readFileSync(sectionPath, 'utf-8');
|
|
845
|
+
const figs = detectRawLatexFigures(content, section);
|
|
846
|
+
for (const f of figs) {
|
|
847
|
+
// When auto-translate is on, non-exotic blocks get rewritten cleanly —
|
|
848
|
+
// only the exotic leftovers need warning. When opted out, everything
|
|
849
|
+
// is at risk and we warn about every block.
|
|
850
|
+
if (translateEnabled && !f.exotic)
|
|
851
|
+
continue;
|
|
852
|
+
all.push(f);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
// ignore unreadable sections
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
if (all.length === 0)
|
|
860
|
+
return null;
|
|
861
|
+
return formatRawLatexFigureWarning(all, translateEnabled);
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Build pandoc arguments for format.
|
|
865
|
+
*
|
|
866
|
+
* Returns only the built-in args derived from config. Passthrough args
|
|
867
|
+
* (config.pandocArgs, config[format].pandocArgs, CLI --pandoc-arg) are
|
|
868
|
+
* appended later in runPandoc so they win against pptx/crossref defaults
|
|
869
|
+
* added there.
|
|
634
870
|
*/
|
|
635
871
|
export function buildPandocArgs(format, config, outputPath) {
|
|
636
872
|
const args = [];
|
|
@@ -748,6 +984,25 @@ export function buildPandocArgs(format, config, outputPath) {
|
|
|
748
984
|
}
|
|
749
985
|
return args;
|
|
750
986
|
}
|
|
987
|
+
/**
|
|
988
|
+
* Collect passthrough pandoc args for a format in the canonical order:
|
|
989
|
+
* top-level config → format-specific config → CLI extras. Later wins for
|
|
990
|
+
* repeated flags.
|
|
991
|
+
*/
|
|
992
|
+
export function collectPandocPassthroughArgs(format, config, extraArgs = []) {
|
|
993
|
+
const out = [];
|
|
994
|
+
if (config.pandocArgs && config.pandocArgs.length > 0) {
|
|
995
|
+
out.push(...config.pandocArgs);
|
|
996
|
+
}
|
|
997
|
+
const formatConfig = config[format];
|
|
998
|
+
if (formatConfig?.pandocArgs && formatConfig.pandocArgs.length > 0) {
|
|
999
|
+
out.push(...formatConfig.pandocArgs);
|
|
1000
|
+
}
|
|
1001
|
+
if (extraArgs.length > 0) {
|
|
1002
|
+
out.push(...extraArgs);
|
|
1003
|
+
}
|
|
1004
|
+
return out;
|
|
1005
|
+
}
|
|
751
1006
|
/**
|
|
752
1007
|
* Write crossref.yaml if needed
|
|
753
1008
|
*/
|
|
@@ -777,31 +1032,98 @@ export function resolveOutputDir(directory, config) {
|
|
|
777
1032
|
return directory;
|
|
778
1033
|
return path.isAbsolute(out) ? out : path.join(directory, out);
|
|
779
1034
|
}
|
|
1035
|
+
/** File extension (with leading dot) for each supported pandoc format. */
|
|
1036
|
+
const FORMAT_EXTENSIONS = {
|
|
1037
|
+
tex: '.tex',
|
|
1038
|
+
pdf: '.pdf',
|
|
1039
|
+
docx: '.docx',
|
|
1040
|
+
beamer: '.pdf',
|
|
1041
|
+
pptx: '.pptx',
|
|
1042
|
+
};
|
|
1043
|
+
/** Get file extension for a format, defaulting to `.pdf`. */
|
|
1044
|
+
export function getFormatExtension(format) {
|
|
1045
|
+
return FORMAT_EXTENSIONS[format] ?? '.pdf';
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Slugify a title for use as a default output filename. Lowercases, replaces
|
|
1049
|
+
* non-alphanumeric runs with `-`, and truncates at the last `-` boundary
|
|
1050
|
+
* at-or-before MAX_TITLE_FILENAME_LENGTH so words stay whole (the old blind
|
|
1051
|
+
* `.slice` cut mid-word).
|
|
1052
|
+
*/
|
|
1053
|
+
export function slugifyTitle(title) {
|
|
1054
|
+
if (!title)
|
|
1055
|
+
return 'paper';
|
|
1056
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
1057
|
+
if (!slug)
|
|
1058
|
+
return 'paper';
|
|
1059
|
+
if (slug.length <= MAX_TITLE_FILENAME_LENGTH)
|
|
1060
|
+
return slug;
|
|
1061
|
+
const cut = slug.slice(0, MAX_TITLE_FILENAME_LENGTH);
|
|
1062
|
+
const lastDash = cut.lastIndexOf('-');
|
|
1063
|
+
// Only truncate at a hyphen if it leaves a reasonable amount of content.
|
|
1064
|
+
// Otherwise hard-cut (handles degenerate titles with no spaces at all).
|
|
1065
|
+
if (lastDash >= MAX_TITLE_FILENAME_LENGTH / 2) {
|
|
1066
|
+
return slug.slice(0, lastDash);
|
|
1067
|
+
}
|
|
1068
|
+
return cut;
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* Ensure `name` ends with `ext` (case-insensitive). If the user already supplied
|
|
1072
|
+
* the correct extension, return unchanged; if they supplied none or a different
|
|
1073
|
+
* one, append the format's canonical extension.
|
|
1074
|
+
*
|
|
1075
|
+
* Different-extension case (e.g. `output.docx` when building tex): we append
|
|
1076
|
+
* rather than replace, since stripping looks like an unsafe guess. The result
|
|
1077
|
+
* `output.docx.tex` is loud enough to flag the misconfiguration.
|
|
1078
|
+
*/
|
|
1079
|
+
function ensureExtension(name, ext) {
|
|
1080
|
+
if (name.toLowerCase().endsWith(ext.toLowerCase()))
|
|
1081
|
+
return name;
|
|
1082
|
+
return name + ext;
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Resolve the final output path for a build.
|
|
1086
|
+
*
|
|
1087
|
+
* Priority: `options.outputPath` (internal force) > `cliOverride` (-o flag) >
|
|
1088
|
+
* `config.output[format]` > slugified title fallback.
|
|
1089
|
+
*
|
|
1090
|
+
* Relative paths from `cliOverride`/`config.output` resolve under outputDir;
|
|
1091
|
+
* absolute paths bypass outputDir. The fallback path always lives under
|
|
1092
|
+
* outputDir.
|
|
1093
|
+
*
|
|
1094
|
+
* @param suffix - Appended before the extension (e.g. "-changes", "-slides").
|
|
1095
|
+
* Suppressed when user supplied an explicit name via CLI or
|
|
1096
|
+
* config — they pick their own suffix.
|
|
1097
|
+
*/
|
|
1098
|
+
export function resolveOutputPath(directory, config, format, options = {}) {
|
|
1099
|
+
const { cliOverride, suffix = '' } = options;
|
|
1100
|
+
const ext = getFormatExtension(format);
|
|
1101
|
+
const explicit = cliOverride ?? config.output?.[format];
|
|
1102
|
+
if (explicit) {
|
|
1103
|
+
const baseDir = path.isAbsolute(explicit)
|
|
1104
|
+
? path.dirname(explicit)
|
|
1105
|
+
: resolveOutputDir(directory, config);
|
|
1106
|
+
const baseName = path.basename(explicit);
|
|
1107
|
+
const stem = baseName.replace(/\.[^./\\]+$/, '');
|
|
1108
|
+
return path.join(baseDir, ensureExtension(`${stem}${suffix}`, ext));
|
|
1109
|
+
}
|
|
1110
|
+
const slug = slugifyTitle(config.title);
|
|
1111
|
+
return path.join(resolveOutputDir(directory, config), `${slug}${suffix}${ext}`);
|
|
1112
|
+
}
|
|
780
1113
|
/**
|
|
781
1114
|
* Run pandoc build
|
|
782
1115
|
*/
|
|
783
1116
|
export async function runPandoc(inputPath, format, config, options = {}) {
|
|
784
1117
|
const directory = path.dirname(inputPath);
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
// Map format to file extension
|
|
789
|
-
const extMap = {
|
|
790
|
-
tex: '.tex',
|
|
791
|
-
pdf: '.pdf',
|
|
792
|
-
docx: '.docx',
|
|
793
|
-
beamer: '.pdf', // beamer outputs PDF
|
|
794
|
-
pptx: '.pptx',
|
|
795
|
-
};
|
|
796
|
-
const ext = extMap[format] || '.pdf';
|
|
797
|
-
// For beamer, use -slides suffix to distinguish from regular PDF
|
|
1118
|
+
// outputPath (internal force) wins over the resolver. For beamer, we keep
|
|
1119
|
+
// the `-slides` suffix on the slug fallback to distinguish from a regular
|
|
1120
|
+
// PDF build; when the user supplies an explicit name, they pick their own.
|
|
798
1121
|
const suffix = format === 'beamer' ? '-slides' : '';
|
|
799
|
-
// Allow custom output path via options. Auto-named outputs go through the
|
|
800
|
-
// configured outputDir (default 'output/'); explicit paths are honored as-is
|
|
801
|
-
// so callers can route temp/intermediate artefacts where they want.
|
|
802
1122
|
const outputPath = options.outputPath
|
|
803
|
-
|
|
804
|
-
|
|
1123
|
+
?? resolveOutputPath(directory, config, format, {
|
|
1124
|
+
cliOverride: options.output,
|
|
1125
|
+
suffix,
|
|
1126
|
+
});
|
|
805
1127
|
if (!options.outputPath) {
|
|
806
1128
|
const outDir = path.dirname(outputPath);
|
|
807
1129
|
if (!fs.existsSync(outDir)) {
|
|
@@ -847,12 +1169,46 @@ export async function runPandoc(inputPath, format, config, options = {}) {
|
|
|
847
1169
|
if (referenceDoc) {
|
|
848
1170
|
args.push('--reference-doc', referenceDoc);
|
|
849
1171
|
}
|
|
850
|
-
// Add color filter for PPTX (handles [text]{color=#RRGGBB} syntax)
|
|
851
|
-
|
|
1172
|
+
// Add color filter for PPTX (handles [text]{color=#RRGGBB} syntax).
|
|
1173
|
+
// fileURLToPath handles Windows paths with spaces — the old
|
|
1174
|
+
// `new URL(...).pathname` returned URL-encoded `%20` and fs.existsSync
|
|
1175
|
+
// silently failed.
|
|
1176
|
+
const colorFilterPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'pptx-color-filter.lua');
|
|
852
1177
|
if (fs.existsSync(colorFilterPath)) {
|
|
853
1178
|
args.push('--lua-filter', colorFilterPath);
|
|
854
1179
|
}
|
|
855
1180
|
}
|
|
1181
|
+
// Wire placeholder macros (built-in \tofill plus user-declared entries).
|
|
1182
|
+
// - docx/html: lua filter expands \name{X} to format-specific raw runs.
|
|
1183
|
+
// - pdf/tex/beamer: inject a \providecommand preamble so LaTeX renders it
|
|
1184
|
+
// directly. `\providecommand` is non-clobbering, so a user who already
|
|
1185
|
+
// has `\providecommand{\tofill}{...}` in their own header keeps theirs.
|
|
1186
|
+
//
|
|
1187
|
+
// Sidecar path is passed to the lua filter via DOCREV_MACROS_FILE in the
|
|
1188
|
+
// child env (not pandoc metadata) because pandoc walks RawInline/RawBlock
|
|
1189
|
+
// BEFORE Meta — by the time a Meta handler could read the path, the inline
|
|
1190
|
+
// expansion has already happened.
|
|
1191
|
+
const macroTempFiles = [];
|
|
1192
|
+
let macroEnvFile = null;
|
|
1193
|
+
const macros = mergeMacros(config.macros);
|
|
1194
|
+
if (macros.length > 0) {
|
|
1195
|
+
if (format === 'docx' || format === 'html' || format === 'html5' || format === 'html4') {
|
|
1196
|
+
const sidecarPath = writeMacrosSidecar(directory, macros);
|
|
1197
|
+
macroTempFiles.push(sidecarPath);
|
|
1198
|
+
macroEnvFile = sidecarPath;
|
|
1199
|
+
const filterPath = getMacroFilterPath();
|
|
1200
|
+
if (fs.existsSync(filterPath)) {
|
|
1201
|
+
args.push('--lua-filter', filterPath);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
else if (format === 'pdf' || format === 'tex' || format === 'beamer') {
|
|
1205
|
+
const preamble = generateLatexPreamble(macros);
|
|
1206
|
+
const preamblePath = path.join(directory, '.macros.tex');
|
|
1207
|
+
fs.writeFileSync(preamblePath, preamble, 'utf-8');
|
|
1208
|
+
macroTempFiles.push(preamblePath);
|
|
1209
|
+
args.push('-H', path.basename(preamblePath));
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
856
1212
|
// Add crossref metadata file if exists (skip for slides - they don't use crossref)
|
|
857
1213
|
if (format !== 'beamer' && format !== 'pptx') {
|
|
858
1214
|
const crossrefPath = path.join(directory, 'crossref.yaml');
|
|
@@ -861,18 +1217,41 @@ export async function runPandoc(inputPath, format, config, options = {}) {
|
|
|
861
1217
|
args.push('--metadata-file', 'crossref.yaml');
|
|
862
1218
|
}
|
|
863
1219
|
}
|
|
1220
|
+
// Passthrough args go last so they win against built-in defaults.
|
|
1221
|
+
args.push(...collectPandocPassthroughArgs(format, config, options.pandocArgs));
|
|
864
1222
|
// Input file (use basename since we set cwd to directory)
|
|
865
1223
|
args.push(path.basename(inputPath));
|
|
1224
|
+
if (options.verbose) {
|
|
1225
|
+
const quoted = args.map(a => /[\s"'$`]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a).join(' ');
|
|
1226
|
+
console.error(`[pandoc ${format}] (cwd: ${directory})`);
|
|
1227
|
+
console.error(` pandoc ${quoted}`);
|
|
1228
|
+
}
|
|
866
1229
|
return new Promise((resolve) => {
|
|
1230
|
+
const pandocEnv = { ...process.env };
|
|
1231
|
+
if (macroEnvFile) {
|
|
1232
|
+
pandocEnv.DOCREV_MACROS_FILE = macroEnvFile;
|
|
1233
|
+
}
|
|
867
1234
|
const pandoc = spawn('pandoc', args, {
|
|
868
1235
|
cwd: directory,
|
|
869
1236
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1237
|
+
env: pandocEnv,
|
|
870
1238
|
});
|
|
871
1239
|
let stderr = '';
|
|
872
1240
|
pandoc.stderr?.on('data', (data) => {
|
|
873
1241
|
stderr += data.toString();
|
|
874
1242
|
});
|
|
1243
|
+
const cleanupMacroTempFiles = () => {
|
|
1244
|
+
for (const tmp of macroTempFiles) {
|
|
1245
|
+
try {
|
|
1246
|
+
fs.unlinkSync(tmp);
|
|
1247
|
+
}
|
|
1248
|
+
catch {
|
|
1249
|
+
// ignore — best-effort cleanup
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
};
|
|
875
1253
|
pandoc.on('close', async (code) => {
|
|
1254
|
+
cleanupMacroTempFiles();
|
|
876
1255
|
if (code === 0) {
|
|
877
1256
|
// For PPTX, post-process to add slide numbers, buildup colors, and logos
|
|
878
1257
|
if (format === 'pptx') {
|
|
@@ -923,6 +1302,7 @@ export async function runPandoc(inputPath, format, config, options = {}) {
|
|
|
923
1302
|
}
|
|
924
1303
|
});
|
|
925
1304
|
pandoc.on('error', (err) => {
|
|
1305
|
+
cleanupMacroTempFiles();
|
|
926
1306
|
resolve({ outputPath, success: false, error: err.message });
|
|
927
1307
|
});
|
|
928
1308
|
});
|
|
@@ -966,6 +1346,12 @@ export async function build(directory, formats = ['pdf', 'docx'], options = {})
|
|
|
966
1346
|
if (imageReg.figures?.length > 0) {
|
|
967
1347
|
writeImageRegistry(directory, imageReg);
|
|
968
1348
|
}
|
|
1349
|
+
// Warn about raw LaTeX figure blocks that won't render in docx (pandoc
|
|
1350
|
+
// drops them silently). With auto-translate on (default), this surfaces
|
|
1351
|
+
// only the exotic leftovers; with it off, every block.
|
|
1352
|
+
const rawFigWarning = collectRawLatexFigureWarning(directory, config);
|
|
1353
|
+
if (rawFigWarning)
|
|
1354
|
+
warnings.push(rawFigWarning);
|
|
969
1355
|
}
|
|
970
1356
|
const results = [];
|
|
971
1357
|
for (const format of formats) {
|