Files
santifer--career-ops/lib/latex-content.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

244 lines
6.7 KiB
JavaScript

/**
* Content-level extract/patch for user-owned LaTeX CV templates.
* v1 families: resumeSubheading | tabularx-itemize
*/
import { escapeLatex } from './latex-escape.mjs';
export const SUPPORTED_FAMILIES = ['resumeSubheading', 'tabularx-itemize'];
export const UNSUPPORTED_HINT =
'Unsupported LaTeX CV layout. v1 supports \\resumeSubheading + \\resumeItem macros, ' +
'or tabularx + itemize without resume macros. Use /career-ops latex (cv.md → career-ops template) instead.';
/**
* @param {string} tex
* @returns {number}
*/
export function findMatchingBrace(tex, openIdx) {
if (tex[openIdx] !== '{') return -1;
let depth = 0;
let escaped = false;
for (let i = openIdx; i < tex.length; i++) {
const ch = tex[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) return i;
}
}
return -1;
}
/**
* @param {string} tex
* @returns {'resumeSubheading'|'tabularx-itemize'|null}
*/
export function detectFamily(tex) {
if (typeof tex !== 'string' || !tex.trim()) return null;
if (/\\resumeSubheading\b/.test(tex) && /\\resumeItem\b/.test(tex)) {
return 'resumeSubheading';
}
const hasTabularx = /\\usepackage\{[^}]*tabularx[^}]*\}/.test(tex) || /\\begin\{tabularx\}/.test(tex);
const hasItemize = /\\begin\{itemize\}/.test(tex);
if (hasTabularx && hasItemize && !/\\resumeSubheading\b/.test(tex)) {
return 'tabularx-itemize';
}
return null;
}
/**
* @param {string} tex
* @param {string} macroName
* @param {string} kind
* @returns {Array<{id: string, kind: string, text: string, span: {start: number, end: number}}>}
*/
function extractMacroBodies(tex, macroName, kind) {
const slots = [];
const needle = `\\${macroName}{`;
let searchFrom = 0;
while (searchFrom < tex.length) {
const idx = tex.indexOf(needle, searchFrom);
if (idx === -1) break;
const openBrace = idx + needle.length - 1;
const closeBrace = findMatchingBrace(tex, openBrace);
if (closeBrace === -1) break;
const innerStart = openBrace + 1;
const innerEnd = closeBrace;
slots.push({
id: `${kind}-${slots.length}`,
kind,
text: tex.slice(innerStart, innerEnd),
span: { start: innerStart, end: innerEnd },
});
searchFrom = closeBrace + 1;
}
return slots;
}
/**
* @param {string} tex
* @returns {Array<{id: string, kind: string, text: string, span: {start: number, end: number}}>}
*/
function extractSkillValues(tex) {
const slots = [];
let searchFrom = 0;
while (searchFrom < tex.length) {
const idx = tex.indexOf('\\textbf{', searchFrom);
if (idx === -1) break;
const openCat = tex.indexOf('{', idx);
const closeCat = findMatchingBrace(tex, openCat);
if (closeCat === -1) break;
let cursor = closeCat + 1;
while (cursor < tex.length && /\s/.test(tex[cursor])) cursor++;
if (tex[cursor] !== '{') {
searchFrom = closeCat + 1;
continue;
}
const openVal = cursor;
const closeVal = findMatchingBrace(tex, openVal);
if (closeVal === -1) break;
const rawValue = tex.slice(openVal + 1, closeVal);
const colon = rawValue.match(/^:\s*/);
if (!colon) {
searchFrom = closeVal + 1;
continue;
}
const itemsStart = openVal + 1 + colon[0].length;
const itemsText = tex.slice(itemsStart, closeVal);
slots.push({
id: `skill-${slots.length}`,
kind: 'skill',
text: itemsText,
span: { start: itemsStart, end: closeVal },
});
searchFrom = closeVal + 1;
}
return slots;
}
/**
* @param {string} tex
* @returns {Array<{id: string, kind: string, text: string, span: {start: number, end: number}}>}
*/
function extractItemizeItems(tex) {
const docStart = tex.indexOf('\\begin{document}');
const body = docStart === -1 ? tex : tex.slice(docStart);
const offset = docStart === -1 ? 0 : docStart;
const slots = [];
const itemRe = /\\item\b/g;
let match;
while ((match = itemRe.exec(body)) !== null) {
let i = match.index + match[0].length;
while (i < body.length && /\s/.test(body[i])) i++;
if (body[i] === '{') {
const openBrace = i;
const closeBrace = findMatchingBrace(body, openBrace);
if (closeBrace === -1) continue;
slots.push({
id: `item-${slots.length}`,
kind: 'item',
text: body.slice(openBrace + 1, closeBrace),
span: { start: offset + openBrace + 1, end: offset + closeBrace },
});
continue;
}
const lineEnd = body.indexOf('\n', i);
const end = lineEnd === -1 ? body.length : lineEnd;
const text = body.slice(i, end).trim();
if (!text) continue;
slots.push({
id: `item-${slots.length}`,
kind: 'item',
text,
span: { start: offset + i, end: offset + end },
});
}
return slots;
}
/**
* @param {string} tex
* @param {'resumeSubheading'|'tabularx-itemize'} family
* @returns {Array<{id: string, kind: string, text: string, span: {start: number, end: number}}>}
*/
export function extractSlots(tex, family) {
if (family === 'resumeSubheading') {
return [
...extractMacroBodies(tex, 'resumeItem', 'bullet'),
...extractSkillValues(tex),
];
}
if (family === 'tabularx-itemize') {
return extractItemizeItems(tex);
}
return [];
}
/**
* @param {string} texPath
* @param {string} tex
* @returns {{supported: boolean, family: string|null, source: string, slots: Array, error?: string, hint?: string}}
*/
export function buildManifest(texPath, tex) {
const family = detectFamily(tex);
if (!family) {
return {
supported: false,
family: null,
source: texPath,
slots: [],
error: UNSUPPORTED_HINT,
hint: 'Place resume.tex in the project root or set latex.source in config/profile.yml.',
};
}
const slots = extractSlots(tex, family);
return {
supported: true,
family,
source: texPath,
slots,
};
}
/**
* @param {string} tex
* @param {Array<{id: string, text: string}>} patches
* @param {Array<{id: string, span: {start: number, end: number}}>} slots
* @param {{escape?: boolean}} [opts]
* @returns {string}
*/
export function applyPatches(tex, patches, slots, { escape = true } = {}) {
const slotById = new Map(slots.map(s => [s.id, s]));
const ordered = [...patches]
.map(p => {
const slot = slotById.get(p.id);
if (!slot) return null;
return { slot, text: p.text };
})
.filter(Boolean)
.sort((a, b) => b.slot.span.start - a.slot.span.start);
let out = tex;
for (const { slot, text } of ordered) {
const replacement = escape ? escapeLatex(text) : text;
out = out.slice(0, slot.span.start) + replacement + out.slice(slot.span.end);
}
return out;
}