LatexMetrics.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. /**
  2. * Converts a time string in seconds to an integer number of milliseconds.
  3. *
  4. * @param {string} timeStr - The time duration in seconds, represented as a string.
  5. * @returns {number} The equivalent time in milliseconds, rounded down to the nearest integer.
  6. */
  7. function convertToMs(timeStr) {
  8. return Math.floor(parseFloat(timeStr, 10) * 1000)
  9. }
  10. const NOTEWORTHY_DEPENDENCIES_REGEXP =
  11. /\/(beamer\.cls|tikz\.sty|microtype\.sty|minted\.sty)$/
  12. /* An array of metric parsers for `latexmk` time output (`-time` flag).
  13. * Each entry is a tuple containing a metric name and a function to parse that
  14. * metric from the `latexmk` log output.
  15. *
  16. * The parser functions generally take the log string as their first argument.
  17. * Some may take additional arguments, such as the already computed stats,
  18. * to derive new metrics.
  19. *
  20. * There are different formats of `latexmk` output depending on the version.
  21. * The parsers attempt to handle these variations gracefully.
  22. */
  23. const LATEX_MK_METRICS_STDOUT = [
  24. // Extract individual latexmk rule times as an array of objects, each with 'rule'
  25. // and 'time_ms' properties
  26. [
  27. 'latexmk-rule-times',
  28. s => {
  29. // Each line looks like: 'pdflatex ... options ...': time = 12.34
  30. // Take the command up to the first space as the rule name
  31. const matches = s.matchAll(/^'([^' ]+).*': time = (\d+\.\d+)/gm)
  32. return Array.from(matches, match => ({
  33. rule: match[1],
  34. time_ms: convertToMs(match[2]),
  35. }))
  36. },
  37. ],
  38. // Extract a comma-separated signature of rule names from the rule times above
  39. [
  40. 'latexmk-rule-signature',
  41. (s, latexmkStats) => {
  42. const times = latexmkStats['latexmk-rule-times']
  43. if (!times) return null
  44. // Example output: 'pdflatex,bibtex,pdflatex,pdflatex'
  45. return times.map(t => t.rule).join(',')
  46. },
  47. ],
  48. // Total latexmk processing time, invoked processes time, and other time in ms
  49. [
  50. 'latexmk-time',
  51. s => {
  52. const match = s.match(
  53. /^Processing time = (\d+\.\d+), of which invoked processes = (\d+\.\d+), other = (\d+\.\d+)/m
  54. )
  55. if (match) {
  56. return {
  57. total: convertToMs(match[1]),
  58. invoked: convertToMs(match[2]),
  59. other: convertToMs(match[3]),
  60. }
  61. }
  62. // Older format
  63. const fallbackMatch = s.match(
  64. /^Accumulated processing time = (\d+\.\d+)/m
  65. )
  66. if (fallbackMatch) {
  67. return { total: convertToMs(fallbackMatch[1]) }
  68. }
  69. return null
  70. },
  71. ],
  72. // Total elapsed clock time in ms for latexmk
  73. [
  74. 'latexmk-clock-time',
  75. s => {
  76. const match = s.match(/^Elapsed clock time = (\d+\.\d+)/m)
  77. if (match) {
  78. return convertToMs(match[1])
  79. }
  80. // not present in older versions
  81. return null
  82. },
  83. ],
  84. // Number of rules run by latexmk
  85. [
  86. 'latexmk-rules-run',
  87. (s, latexmkStats) => {
  88. const match = s.match(/^Number of rules run = (\d+)/m)
  89. if (match) {
  90. return parseInt(match[1], 10)
  91. }
  92. // Fallback: count number of entries in rule times if available
  93. if (latexmkStats['latexmk-rule-times']) {
  94. return latexmkStats['latexmk-rule-times'].length
  95. }
  96. return null
  97. },
  98. ],
  99. ]
  100. const LATEX_MK_METRICS_STDERR = [
  101. [
  102. 'latexmk-img-times',
  103. s => {
  104. const pngCategoriesByFile = new Map()
  105. const pngCopyMatches = s.matchAll(/^PNG copy: (.*)$/gm)
  106. for (const match of pngCopyMatches) {
  107. const filename = match[1]
  108. pngCategoriesByFile.set(filename, 'fast-copy')
  109. }
  110. const pngCopySkipMatches = s.matchAll(
  111. /^PNG copy skipped \((alpha|gamma|palette|interlaced)\): (.*)$/gm
  112. )
  113. for (const match of pngCopySkipMatches) {
  114. const category = match[1]
  115. const filename = match[2]
  116. pngCategoriesByFile.set(filename, category)
  117. }
  118. const timingMatches = s.matchAll(
  119. /^Image written \((PNG|JPG|JBIG2|PDF), (\d+) ms\): (.*)$/gm
  120. )
  121. const timingsByType = new Map()
  122. for (const match of timingMatches) {
  123. let type = match[1]
  124. const timeMs = parseInt(match[2], 10)
  125. const filename = match[3]
  126. if (type === 'PNG') {
  127. const pngCategory = pngCategoriesByFile.get(filename)
  128. if (pngCategory != null) {
  129. type = `PNG-${pngCategory}`
  130. }
  131. }
  132. const accumulatedTime = timingsByType.get(type) ?? 0
  133. timingsByType.set(type, accumulatedTime + timeMs)
  134. }
  135. return Array.from(timingsByType.entries()).map(([type, timeMs]) => ({
  136. type,
  137. time_ms: timeMs,
  138. }))
  139. },
  140. ],
  141. ]
  142. /**
  143. * Parses latexmk stdout for metrics and adds them to the stats object.
  144. * It iterates through a predefined list of metric matchers (LATEX_MK_METRICS),
  145. * applies them to the stdout, and adds any successful matches to the
  146. * `stats.latexmk` object.
  147. *
  148. * @param {{stdout?: string, stderr?: string}} output - The output from the latexmk process.
  149. * @param {{latexmk: object}} stats - The statistics object to update. This object is mutated.
  150. */
  151. function addLatexMkMetrics(output, stats) {
  152. for (const [stat, matcher] of LATEX_MK_METRICS_STDOUT) {
  153. const match = matcher(output?.stdout || '', stats.latexmk)
  154. if (match) {
  155. stats.latexmk[stat] = match
  156. }
  157. }
  158. for (const [stat, matcher] of LATEX_MK_METRICS_STDERR) {
  159. const match = matcher(output?.stderr || '', stats.latexmk)
  160. if (match) {
  161. stats.latexmk[stat] = match
  162. }
  163. }
  164. }
  165. /**
  166. * Adds a non-enumerable `latexmk` property to the stats object.
  167. *
  168. * This property is used to store statistics from the latexmk compilation
  169. * process. It is made non-enumerable to prevent it from being serialized by
  170. * `JSON.stringify()`, which means it is not sent in the compile response
  171. * to web where it would added to the analytics events.
  172. *
  173. * @param {object} stats - The compile stats object to be modified.
  174. */
  175. function enableLatexMkMetrics(stats) {
  176. Object.defineProperty(stats, 'latexmk', {
  177. value: {},
  178. enumerable: false,
  179. })
  180. }
  181. /**
  182. * Parses the content of a latexmk .fdb file to extract and record metrics
  183. * about the files used during compilation.
  184. *
  185. * It categorizes files into 'system' (paths starting with '/') and 'user'
  186. * files. For each category, it aggregates the count and total size of files
  187. * grouped by their extension. The results are added to the provided stats
  188. * object under `stats.latexmk['fdb-file-types']`.
  189. *
  190. * Each file is counted only once, even if it appears multiple times in the
  191. * .fdb content. Files without a valid extension are counted as 'other'.
  192. *
  193. * @param {string | null | undefined} fdbContent - The content of the .fdb_latexmk file.
  194. * @param {object} stats - The statistics object to be populated. The metrics will be added to `stats.latexmk`.
  195. * @returns {void}
  196. */
  197. function addLatexFdbMetrics(fdbContent, stats) {
  198. if (!fdbContent) {
  199. return
  200. }
  201. const { systemFileTypes, userFileTypes, dependencies } =
  202. parseFdbContent(fdbContent)
  203. if (
  204. Object.keys(systemFileTypes).length > 0 ||
  205. Object.keys(userFileTypes).length > 0
  206. ) {
  207. const userSummary = summarizeFileTypes(userFileTypes)
  208. const systemSummary = summarizeFileTypes(systemFileTypes)
  209. stats.latexmk['fdb-file-types'] = {
  210. total: {
  211. systemFileCount: systemSummary.total.count,
  212. systemFileSize: systemSummary.total.size,
  213. imageFileCount: userSummary.image.count,
  214. imageFileSize: userSummary.image.size,
  215. textFileCount: userSummary.text.count,
  216. textFileSize: userSummary.text.size,
  217. fontFileCount: userSummary.font.count,
  218. fontFileSize: userSummary.font.size,
  219. otherFileCount: userSummary.other.count,
  220. otherFileSize: userSummary.other.size,
  221. },
  222. system: convertToArray(systemFileTypes),
  223. user: convertToArray(userFileTypes),
  224. }
  225. }
  226. if (dependencies.length > 0) {
  227. stats.latexmk['fdb-dependencies'] = dependencies
  228. }
  229. }
  230. function parseFdbContent(fdbContent) {
  231. const systemFileTypes = {}
  232. const userFileTypes = {}
  233. const seenFiles = new Set()
  234. const dependencies = new Set()
  235. // Extract the file path and size from lines like:
  236. // FILENAME TIMESTAMP SIZE CHECKSUM ...
  237. // "main.tex" 1760016467 6147 9da336eb1132ecb1d61cd1f5a70cfa62 ""
  238. // The timestamp can be an integer or a float
  239. const lineRegex = /^\s*"([^"]+)"\s+\d+(?:\.\d*)?\s+(\d+)/gm
  240. for (const match of fdbContent.matchAll(lineRegex)) {
  241. // strip leading /compile/ from paths
  242. const filePath = match[1].replace(/^\/compile\//, '')
  243. if (seenFiles.has(filePath)) {
  244. continue
  245. }
  246. const fileSize = parseInt(match[2], 10)
  247. const isSystemFile = filePath.startsWith('/')
  248. const extMatch = filePath.match(/\.([a-z]{1,4})$/i)
  249. const ext = extMatch ? extMatch[1].toLowerCase() : 'other'
  250. const fileTypes = isSystemFile ? systemFileTypes : userFileTypes
  251. if (!fileTypes[ext]) {
  252. fileTypes[ext] = { count: 0, size: 0 }
  253. }
  254. fileTypes[ext].count++
  255. fileTypes[ext].size += fileSize
  256. const depMatch = filePath.match(NOTEWORTHY_DEPENDENCIES_REGEXP)
  257. if (depMatch) {
  258. dependencies.add(depMatch[1])
  259. }
  260. seenFiles.add(filePath)
  261. }
  262. return {
  263. systemFileTypes,
  264. userFileTypes,
  265. dependencies: Array.from(dependencies),
  266. }
  267. }
  268. function getFileTypeCategory(ext) {
  269. switch (ext) {
  270. case 'png':
  271. case 'jpg':
  272. case 'jpeg':
  273. case 'tif':
  274. case 'tiff':
  275. case 'bmp':
  276. case 'gif':
  277. case 'svg':
  278. case 'eps':
  279. return 'image'
  280. case 'tex':
  281. case 'sty':
  282. case 'cls':
  283. case 'bib':
  284. return 'text'
  285. case 'ttf':
  286. case 'otf':
  287. case 'pfb':
  288. return 'font'
  289. default:
  290. return 'other'
  291. }
  292. }
  293. function summarizeFileTypes(fileTypes) {
  294. const summary = {
  295. image: { count: 0, size: 0 },
  296. text: { count: 0, size: 0 },
  297. font: { count: 0, size: 0 },
  298. other: { count: 0, size: 0 },
  299. total: { count: 0, size: 0 },
  300. }
  301. for (const [ext, info] of Object.entries(fileTypes)) {
  302. const category = getFileTypeCategory(ext)
  303. summary[category].count += info.count
  304. summary[category].size += info.size
  305. summary.total.count += info.count
  306. summary.total.size += info.size
  307. }
  308. return summary
  309. }
  310. function convertToArray(object) {
  311. return Object.entries(object)
  312. .map(([ext, value]) => ({
  313. ext,
  314. count: value.count,
  315. size: value.size,
  316. }))
  317. .sort((a, b) => b.size - a.size) // sort by size descending
  318. }
  319. export default { enableLatexMkMetrics, addLatexMkMetrics, addLatexFdbMetrics }