/** * Converts a time string in seconds to an integer number of milliseconds. * * @param {string} timeStr - The time duration in seconds, represented as a string. * @returns {number} The equivalent time in milliseconds, rounded down to the nearest integer. */ function convertToMs(timeStr) { return Math.floor(parseFloat(timeStr, 10) * 1000) } const NOTEWORTHY_DEPENDENCIES_REGEXP = /\/(beamer\.cls|tikz\.sty|microtype\.sty|minted\.sty)$/ /* An array of metric parsers for `latexmk` time output (`-time` flag). * Each entry is a tuple containing a metric name and a function to parse that * metric from the `latexmk` log output. * * The parser functions generally take the log string as their first argument. * Some may take additional arguments, such as the already computed stats, * to derive new metrics. * * There are different formats of `latexmk` output depending on the version. * The parsers attempt to handle these variations gracefully. */ const LATEX_MK_METRICS_STDOUT = [ // Extract individual latexmk rule times as an array of objects, each with 'rule' // and 'time_ms' properties [ 'latexmk-rule-times', s => { // Each line looks like: 'pdflatex ... options ...': time = 12.34 // Take the command up to the first space as the rule name const matches = s.matchAll(/^'([^' ]+).*': time = (\d+\.\d+)/gm) return Array.from(matches, match => ({ rule: match[1], time_ms: convertToMs(match[2]), })) }, ], // Extract a comma-separated signature of rule names from the rule times above [ 'latexmk-rule-signature', (s, latexmkStats) => { const times = latexmkStats['latexmk-rule-times'] if (!times) return null // Example output: 'pdflatex,bibtex,pdflatex,pdflatex' return times.map(t => t.rule).join(',') }, ], // Total latexmk processing time, invoked processes time, and other time in ms [ 'latexmk-time', s => { const match = s.match( /^Processing time = (\d+\.\d+), of which invoked processes = (\d+\.\d+), other = (\d+\.\d+)/m ) if (match) { return { total: convertToMs(match[1]), invoked: convertToMs(match[2]), other: convertToMs(match[3]), } } // Older format const fallbackMatch = s.match( /^Accumulated processing time = (\d+\.\d+)/m ) if (fallbackMatch) { return { total: convertToMs(fallbackMatch[1]) } } return null }, ], // Total elapsed clock time in ms for latexmk [ 'latexmk-clock-time', s => { const match = s.match(/^Elapsed clock time = (\d+\.\d+)/m) if (match) { return convertToMs(match[1]) } // not present in older versions return null }, ], // Number of rules run by latexmk [ 'latexmk-rules-run', (s, latexmkStats) => { const match = s.match(/^Number of rules run = (\d+)/m) if (match) { return parseInt(match[1], 10) } // Fallback: count number of entries in rule times if available if (latexmkStats['latexmk-rule-times']) { return latexmkStats['latexmk-rule-times'].length } return null }, ], ] const LATEX_MK_METRICS_STDERR = [ [ 'latexmk-img-times', s => { const pngCategoriesByFile = new Map() const pngCopyMatches = s.matchAll(/^PNG copy: (.*)$/gm) for (const match of pngCopyMatches) { const filename = match[1] pngCategoriesByFile.set(filename, 'fast-copy') } const pngCopySkipMatches = s.matchAll( /^PNG copy skipped \((alpha|gamma|palette|interlaced)\): (.*)$/gm ) for (const match of pngCopySkipMatches) { const category = match[1] const filename = match[2] pngCategoriesByFile.set(filename, category) } const timingMatches = s.matchAll( /^Image written \((PNG|JPG|JBIG2|PDF), (\d+) ms\): (.*)$/gm ) const timingsByType = new Map() for (const match of timingMatches) { let type = match[1] const timeMs = parseInt(match[2], 10) const filename = match[3] if (type === 'PNG') { const pngCategory = pngCategoriesByFile.get(filename) if (pngCategory != null) { type = `PNG-${pngCategory}` } } const accumulatedTime = timingsByType.get(type) ?? 0 timingsByType.set(type, accumulatedTime + timeMs) } return Array.from(timingsByType.entries()).map(([type, timeMs]) => ({ type, time_ms: timeMs, })) }, ], ] /** * Parses latexmk stdout for metrics and adds them to the stats object. * It iterates through a predefined list of metric matchers (LATEX_MK_METRICS), * applies them to the stdout, and adds any successful matches to the * `stats.latexmk` object. * * @param {{stdout?: string, stderr?: string}} output - The output from the latexmk process. * @param {{latexmk: object}} stats - The statistics object to update. This object is mutated. */ function addLatexMkMetrics(output, stats) { for (const [stat, matcher] of LATEX_MK_METRICS_STDOUT) { const match = matcher(output?.stdout || '', stats.latexmk) if (match) { stats.latexmk[stat] = match } } for (const [stat, matcher] of LATEX_MK_METRICS_STDERR) { const match = matcher(output?.stderr || '', stats.latexmk) if (match) { stats.latexmk[stat] = match } } } /** * Adds a non-enumerable `latexmk` property to the stats object. * * This property is used to store statistics from the latexmk compilation * process. It is made non-enumerable to prevent it from being serialized by * `JSON.stringify()`, which means it is not sent in the compile response * to web where it would added to the analytics events. * * @param {object} stats - The compile stats object to be modified. */ function enableLatexMkMetrics(stats) { Object.defineProperty(stats, 'latexmk', { value: {}, enumerable: false, }) } /** * Parses the content of a latexmk .fdb file to extract and record metrics * about the files used during compilation. * * It categorizes files into 'system' (paths starting with '/') and 'user' * files. For each category, it aggregates the count and total size of files * grouped by their extension. The results are added to the provided stats * object under `stats.latexmk['fdb-file-types']`. * * Each file is counted only once, even if it appears multiple times in the * .fdb content. Files without a valid extension are counted as 'other'. * * @param {string | null | undefined} fdbContent - The content of the .fdb_latexmk file. * @param {object} stats - The statistics object to be populated. The metrics will be added to `stats.latexmk`. * @returns {void} */ function addLatexFdbMetrics(fdbContent, stats) { if (!fdbContent) { return } const { systemFileTypes, userFileTypes, dependencies } = parseFdbContent(fdbContent) if ( Object.keys(systemFileTypes).length > 0 || Object.keys(userFileTypes).length > 0 ) { const userSummary = summarizeFileTypes(userFileTypes) const systemSummary = summarizeFileTypes(systemFileTypes) stats.latexmk['fdb-file-types'] = { total: { systemFileCount: systemSummary.total.count, systemFileSize: systemSummary.total.size, imageFileCount: userSummary.image.count, imageFileSize: userSummary.image.size, textFileCount: userSummary.text.count, textFileSize: userSummary.text.size, fontFileCount: userSummary.font.count, fontFileSize: userSummary.font.size, otherFileCount: userSummary.other.count, otherFileSize: userSummary.other.size, }, system: convertToArray(systemFileTypes), user: convertToArray(userFileTypes), } } if (dependencies.length > 0) { stats.latexmk['fdb-dependencies'] = dependencies } } function parseFdbContent(fdbContent) { const systemFileTypes = {} const userFileTypes = {} const seenFiles = new Set() const dependencies = new Set() // Extract the file path and size from lines like: // FILENAME TIMESTAMP SIZE CHECKSUM ... // "main.tex" 1760016467 6147 9da336eb1132ecb1d61cd1f5a70cfa62 "" // The timestamp can be an integer or a float const lineRegex = /^\s*"([^"]+)"\s+\d+(?:\.\d*)?\s+(\d+)/gm for (const match of fdbContent.matchAll(lineRegex)) { // strip leading /compile/ from paths const filePath = match[1].replace(/^\/compile\//, '') if (seenFiles.has(filePath)) { continue } const fileSize = parseInt(match[2], 10) const isSystemFile = filePath.startsWith('/') const extMatch = filePath.match(/\.([a-z]{1,4})$/i) const ext = extMatch ? extMatch[1].toLowerCase() : 'other' const fileTypes = isSystemFile ? systemFileTypes : userFileTypes if (!fileTypes[ext]) { fileTypes[ext] = { count: 0, size: 0 } } fileTypes[ext].count++ fileTypes[ext].size += fileSize const depMatch = filePath.match(NOTEWORTHY_DEPENDENCIES_REGEXP) if (depMatch) { dependencies.add(depMatch[1]) } seenFiles.add(filePath) } return { systemFileTypes, userFileTypes, dependencies: Array.from(dependencies), } } function getFileTypeCategory(ext) { switch (ext) { case 'png': case 'jpg': case 'jpeg': case 'tif': case 'tiff': case 'bmp': case 'gif': case 'svg': case 'eps': return 'image' case 'tex': case 'sty': case 'cls': case 'bib': return 'text' case 'ttf': case 'otf': case 'pfb': return 'font' default: return 'other' } } function summarizeFileTypes(fileTypes) { const summary = { image: { count: 0, size: 0 }, text: { count: 0, size: 0 }, font: { count: 0, size: 0 }, other: { count: 0, size: 0 }, total: { count: 0, size: 0 }, } for (const [ext, info] of Object.entries(fileTypes)) { const category = getFileTypeCategory(ext) summary[category].count += info.count summary[category].size += info.size summary.total.count += info.count summary.total.size += info.size } return summary } function convertToArray(object) { return Object.entries(object) .map(([ext, value]) => ({ ext, count: value.count, size: value.size, })) .sort((a, b) => b.size - a.size) // sort by size descending } export default { enableLatexMkMetrics, addLatexMkMetrics, addLatexFdbMetrics }