PdfController.js 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237
  1. import App from '../../../base'
  2. import HumanReadableLogs from '../../human-readable-logs/HumanReadableLogs'
  3. import BibLogParser from 'libs/bib-log-parser'
  4. import PreviewPane from '../../../features/preview/components/preview-pane'
  5. import { react2angular } from 'react2angular'
  6. import { rootContext } from '../../../shared/context/root-context'
  7. import 'ace/ace'
  8. import getMeta from '../../../utils/meta'
  9. import { waitForServiceWorker } from '../../pdfng/directives/serviceWorkerManager'
  10. const AUTO_COMPILE_MAX_WAIT = 5000
  11. // We add a 1 second debounce to sending user changes to server if they aren't
  12. // collaborating with anyone. This needs to be higher than that, and allow for
  13. // client to server latency, otherwise we compile before the op reaches the server
  14. // and then again on ack.
  15. const AUTO_COMPILE_DEBOUNCE = 2000
  16. App.filter('trusted', $sce => url => $sce.trustAsResourceUrl(url))
  17. App.controller(
  18. 'PdfController',
  19. function (
  20. $scope,
  21. $http,
  22. ide,
  23. $modal,
  24. synctex,
  25. eventTracking,
  26. localStorage,
  27. $q
  28. ) {
  29. let autoCompile = true
  30. // pdf.view = uncompiled | pdf | errors
  31. $scope.pdf.view = $scope.pdf.url ? 'pdf' : 'uncompiled'
  32. $scope.pdf.clearingCache = false
  33. $scope.shouldShowLogs = false
  34. $scope.logsUISubvariant = window.logsUISubvariant
  35. // view logic to check whether the files dropdown should "drop up" or "drop down"
  36. $scope.shouldDropUp = false
  37. // Exposed methods for React layout handling
  38. $scope.setPdfSplitLayout = function () {
  39. $scope.$applyAsync(() => $scope.switchToSideBySideLayout('editor'))
  40. }
  41. $scope.setPdfFullLayout = function () {
  42. $scope.$applyAsync(() => $scope.switchToFlatLayout('pdf'))
  43. }
  44. const logsContainerEl = document.querySelector('.pdf-logs')
  45. const filesDropdownEl =
  46. logsContainerEl && logsContainerEl.querySelector('.files-dropdown')
  47. // get the top coordinate of the files dropdown as a ratio (to the logs container height)
  48. // logs container supports scrollable content, so it's possible that ratio > 1.
  49. function getFilesDropdownTopCoordAsRatio() {
  50. if (filesDropdownEl == null || logsContainerEl == null) {
  51. return 0
  52. }
  53. return (
  54. filesDropdownEl.getBoundingClientRect().top /
  55. logsContainerEl.getBoundingClientRect().height
  56. )
  57. }
  58. $scope.$watch('shouldShowLogs', shouldShow => {
  59. if (shouldShow) {
  60. $scope.$applyAsync(() => {
  61. $scope.shouldDropUp = getFilesDropdownTopCoordAsRatio() > 0.65
  62. })
  63. }
  64. })
  65. $scope.trackLogHintsLearnMore = function () {
  66. eventTracking.sendMB('logs-hints-learn-more')
  67. }
  68. if (ace.require('ace/lib/useragent').isMac) {
  69. $scope.modifierKey = 'Cmd'
  70. } else {
  71. $scope.modifierKey = 'Ctrl'
  72. }
  73. // utility for making a query string from a hash, could use jquery $.param
  74. function createQueryString(args) {
  75. const qsArgs = []
  76. for (const k in args) {
  77. const v = args[k]
  78. qsArgs.push(`${k}=${v}`)
  79. }
  80. if (qsArgs.length) {
  81. return `?${qsArgs.join('&')}`
  82. } else {
  83. return ''
  84. }
  85. }
  86. $scope.$on('project:joined', () => {
  87. if (!autoCompile) {
  88. return
  89. }
  90. autoCompile = false
  91. $scope.recompile({ isAutoCompileOnLoad: true })
  92. $scope.hasPremiumCompile =
  93. $scope.project.features.compileGroup === 'priority'
  94. })
  95. $scope.$on('pdf:error:display', function () {
  96. $scope.pdf.view = 'errors'
  97. $scope.pdf.renderingError = true
  98. })
  99. let autoCompileInterval = null
  100. function autoCompileIfReady() {
  101. if (
  102. $scope.pdf.compiling ||
  103. !$scope.autocompile_enabled ||
  104. !$scope.pdf.uncompiled
  105. ) {
  106. return
  107. }
  108. // Only checking linting if syntaxValidation is on and visible to the user
  109. const autoCompileLintingError =
  110. ide.$scope.hasLintingError && ide.$scope.settings.syntaxValidation
  111. if ($scope.autoCompileLintingError !== autoCompileLintingError) {
  112. $scope.$apply(() => {
  113. $scope.autoCompileLintingError = autoCompileLintingError
  114. // We've likely been waiting a while until the user fixed the linting, but we
  115. // don't want to compile as soon as it is fixed, so reset the timeout.
  116. $scope.startedTryingAutoCompileAt = Date.now()
  117. $scope.docLastChangedAt = Date.now()
  118. })
  119. }
  120. if (autoCompileLintingError && $scope.stop_on_validation_error) {
  121. return
  122. }
  123. // If there's a longish compile, don't compile immediately after if user is still typing
  124. const startedTryingAt = Math.max(
  125. $scope.startedTryingAutoCompileAt,
  126. $scope.lastFinishedCompileAt || 0
  127. )
  128. const timeSinceStartedTrying = Date.now() - startedTryingAt
  129. const timeSinceLastChange = Date.now() - $scope.docLastChangedAt
  130. let shouldCompile = false
  131. if (timeSinceLastChange > AUTO_COMPILE_DEBOUNCE) {
  132. // Don't compile in the middle of the user typing
  133. shouldCompile = true
  134. } else if (timeSinceStartedTrying > AUTO_COMPILE_MAX_WAIT) {
  135. // Unless they type for a long time
  136. shouldCompile = true
  137. } else if (timeSinceStartedTrying < 0 || timeSinceLastChange < 0) {
  138. // If time is non-monotonic, assume that the user's system clock has been
  139. // changed and continue with compile
  140. shouldCompile = true
  141. }
  142. if (shouldCompile) {
  143. return triggerAutoCompile()
  144. }
  145. }
  146. function triggerAutoCompile() {
  147. $scope.recompile({ isAutoCompileOnChange: true })
  148. }
  149. function startTryingAutoCompile() {
  150. if (autoCompileInterval != null) {
  151. return
  152. }
  153. $scope.startedTryingAutoCompileAt = Date.now()
  154. autoCompileInterval = setInterval(autoCompileIfReady, 200)
  155. }
  156. function stopTryingAutoCompile() {
  157. clearInterval(autoCompileInterval)
  158. autoCompileInterval = null
  159. }
  160. $scope.changesToAutoCompile = false
  161. $scope.$watch('pdf.uncompiled', uncompiledChanges => {
  162. // don't autocompile if disabled or the pdf is not visible
  163. if (
  164. $scope.pdf.uncompiled &&
  165. $scope.autocompile_enabled &&
  166. !$scope.ui.pdfHidden
  167. ) {
  168. $scope.changesToAutoCompile = true
  169. startTryingAutoCompile()
  170. } else {
  171. $scope.changesToAutoCompile = false
  172. stopTryingAutoCompile()
  173. }
  174. })
  175. function recalculateUncompiledChanges() {
  176. if ($scope.docLastChangedAt == null) {
  177. $scope.pdf.uncompiled = false
  178. } else if (
  179. $scope.lastStartedCompileAt == null ||
  180. $scope.docLastChangedAt > $scope.lastStartedCompileAt
  181. ) {
  182. $scope.pdf.uncompiled = true
  183. } else {
  184. $scope.pdf.uncompiled = false
  185. }
  186. }
  187. function _updateDocLastChangedAt() {
  188. $scope.docLastChangedAt = Date.now()
  189. recalculateUncompiledChanges()
  190. }
  191. function onDocChanged() {
  192. _updateDocLastChangedAt()
  193. }
  194. function onDocSaved() {
  195. // We use the save as a trigger too, to account for the delay between the client
  196. // and server. Otherwise, we might have compiled after the user made
  197. // the change on the client, but before the server had it.
  198. _updateDocLastChangedAt()
  199. }
  200. function onCompilingStateChanged(compiling) {
  201. recalculateUncompiledChanges()
  202. }
  203. ide.$scope.$on('doc:changed', onDocChanged)
  204. ide.$scope.$on('doc:saved', onDocSaved)
  205. $scope.$watch('pdf.compiling', onCompilingStateChanged)
  206. $scope.autocompile_enabled =
  207. localStorage(`autocompile_enabled:${$scope.project_id}`) || false
  208. $scope.$watch('autocompile_enabled', (newValue, oldValue) => {
  209. if (newValue != null && oldValue !== newValue) {
  210. if (newValue === true) {
  211. $scope.autoCompileLintingError = false
  212. autoCompileIfReady()
  213. }
  214. localStorage(`autocompile_enabled:${$scope.project_id}`, newValue)
  215. eventTracking.sendMB('autocompile-setting-changed', {
  216. value: newValue,
  217. })
  218. }
  219. })
  220. // abort compile if syntax checks fail
  221. $scope.stop_on_validation_error = localStorage(
  222. `stop_on_validation_error:${$scope.project_id}`
  223. )
  224. if ($scope.stop_on_validation_error == null) {
  225. $scope.stop_on_validation_error = true
  226. }
  227. // turn on for all users by default
  228. $scope.$watch('stop_on_validation_error', (newValue, oldValue) => {
  229. if (newValue != null && oldValue !== newValue) {
  230. localStorage(`stop_on_validation_error:${$scope.project_id}`, newValue)
  231. }
  232. })
  233. $scope.draft = localStorage(`draft:${$scope.project_id}`) || false
  234. $scope.$watch('draft', (newValue, oldValue) => {
  235. if (newValue != null && oldValue !== newValue) {
  236. localStorage(`draft:${$scope.project_id}`, newValue)
  237. }
  238. })
  239. function sendCompileRequest(options) {
  240. if (options == null) {
  241. options = {}
  242. }
  243. const url = `/project/${$scope.project_id}/compile`
  244. let setup = Promise.resolve()
  245. const params = {}
  246. if (options.isAutoCompileOnLoad || options.isAutoCompileOnChange) {
  247. params.auto_compile = true
  248. }
  249. if (getMeta('ol-enablePdfCaching')) {
  250. setup = waitForServiceWorker()
  251. params.enable_pdf_caching = true
  252. }
  253. // if the previous run was a check, clear the error logs
  254. if ($scope.check) {
  255. $scope.pdf.logEntries = {}
  256. }
  257. // keep track of whether this is a compile or check
  258. $scope.check = !!options.check
  259. if (options.check) {
  260. eventTracking.sendMB('syntax-check-request')
  261. }
  262. // send appropriate check type to clsi
  263. let checkType
  264. if ($scope.check) {
  265. checkType = 'validate' // validate only
  266. } else if (options.try) {
  267. checkType = 'silent' // allow use to try compile once
  268. } else if ($scope.stop_on_validation_error) {
  269. checkType = 'error' // try to compile
  270. } else {
  271. checkType = 'silent' // ignore errors
  272. }
  273. // FIXME: Temporarily disable syntax checking as it is causing
  274. // excessive support requests for projects migrated from v1
  275. // https://github.com/overleaf/sharelatex/issues/911
  276. if (checkType === 'error') {
  277. checkType = 'silent'
  278. }
  279. return setup.then(() =>
  280. $http.post(
  281. url,
  282. {
  283. rootDoc_id: options.rootDocOverride_id || null,
  284. draft: $scope.draft,
  285. check: checkType,
  286. // use incremental compile for all users but revert to a full
  287. // compile if there is a server error
  288. incrementalCompilesEnabled: !$scope.pdf.error,
  289. _csrf: window.csrfToken,
  290. },
  291. { params }
  292. )
  293. )
  294. }
  295. function buildPdfDownloadUrl(pdfDownloadDomain, url) {
  296. if (pdfDownloadDomain) {
  297. return `${pdfDownloadDomain}${url}`
  298. } else {
  299. return url
  300. }
  301. }
  302. function noop() {}
  303. function parseCompileResponse(response) {
  304. // keep last url
  305. const lastPdfUrl = $scope.pdf.url
  306. const { pdfDownloadDomain } = response
  307. // Reset everything
  308. $scope.pdf.error = false
  309. $scope.pdf.timedout = false
  310. $scope.pdf.failure = false
  311. $scope.pdf.url = null
  312. $scope.pdf.updateConsumedBandwidth = noop
  313. $scope.pdf.firstRenderDone = noop
  314. $scope.pdf.clsiMaintenance = false
  315. $scope.pdf.tooRecentlyCompiled = false
  316. $scope.pdf.renderingError = false
  317. $scope.pdf.projectTooLarge = false
  318. $scope.pdf.compileTerminated = false
  319. $scope.pdf.compileExited = false
  320. $scope.pdf.failedCheck = false
  321. $scope.pdf.compileInProgress = false
  322. $scope.pdf.autoCompileDisabled = false
  323. if (window.showNewLogsUI) {
  324. $scope.clsiErrors = {}
  325. }
  326. // make a cache to look up files by name
  327. const fileByPath = {}
  328. if (response.outputFiles != null) {
  329. for (const file of response.outputFiles) {
  330. fileByPath[file.path] = file
  331. }
  332. }
  333. // prepare query string
  334. let qs = {}
  335. // add a query string parameter for the compile group
  336. if (response.compileGroup != null) {
  337. ide.compileGroup = qs.compileGroup = response.compileGroup
  338. }
  339. // add a query string parameter for the clsi server id
  340. if (response.clsiServerId != null) {
  341. ide.clsiServerId = qs.clsiserverid = response.clsiServerId
  342. }
  343. // TODO(das7pad): drop this hack once 2747f0d40af8729304 has landed in clsi
  344. if (response.status === 'success' && !fileByPath['output.pdf']) {
  345. response.status = 'failure'
  346. }
  347. if (response.status === 'success') {
  348. $scope.pdf.view = 'pdf'
  349. $scope.shouldShowLogs = false
  350. $scope.pdf.lastCompileTimestamp = Date.now()
  351. $scope.pdf.validation = {}
  352. $scope.pdf.url = buildPdfDownloadUrl(
  353. pdfDownloadDomain,
  354. fileByPath['output.pdf'].url
  355. )
  356. if (window.location.search.includes('verify_chunks=true')) {
  357. qs.verify_chunks = 'true'
  358. }
  359. // convert the qs hash into a query string and append it
  360. $scope.pdf.url += createQueryString(qs)
  361. const {
  362. onFirstRenderDone,
  363. firstRenderDone,
  364. updateConsumedBandwidth,
  365. } = trackPDFDownload()
  366. $scope.pdf.firstRenderDone = firstRenderDone
  367. $scope.pdf.updateConsumedBandwidth = updateConsumedBandwidth
  368. const { serviceWorkerMetrics } = response
  369. onFirstRenderDone.then(({ latencyFetch, latencyRender }) => {
  370. sl_console.log(
  371. JSON.stringify({
  372. latencyFetch,
  373. latencyRender,
  374. totalBandwidthReportedByPdfJs,
  375. serviceWorkerMetrics,
  376. })
  377. )
  378. })
  379. // Save all downloads as files
  380. qs.popupDownload = true
  381. const { build: buildId } = fileByPath['output.pdf']
  382. $scope.pdf.downloadUrl =
  383. `/download/project/${$scope.project_id}/build/${buildId}/output/output.pdf` +
  384. createQueryString(qs)
  385. fetchLogs(fileByPath, { pdfDownloadDomain })
  386. } else if (response.status === 'timedout') {
  387. $scope.pdf.view = 'errors'
  388. $scope.pdf.timedout = true
  389. fetchLogs(fileByPath, { pdfDownloadDomain })
  390. if (
  391. !$scope.hasPremiumCompile &&
  392. ide.$scope.project.owner._id === ide.$scope.user.id
  393. ) {
  394. eventTracking.send(
  395. 'subscription-funnel',
  396. 'editor-click-feature',
  397. 'compile-timeout'
  398. )
  399. }
  400. } else if (response.status === 'terminated') {
  401. $scope.pdf.view = 'errors'
  402. $scope.pdf.compileTerminated = true
  403. fetchLogs(fileByPath, { pdfDownloadDomain })
  404. } else if (
  405. ['validation-fail', 'validation-pass'].includes(response.status)
  406. ) {
  407. $scope.pdf.view = 'pdf'
  408. $scope.pdf.url = lastPdfUrl
  409. $scope.shouldShowLogs = true
  410. if (response.status === 'validation-fail') {
  411. $scope.pdf.failedCheck = true
  412. }
  413. eventTracking.sendMB(`syntax-check-${response.status}`)
  414. fetchLogs(fileByPath, { validation: true, pdfDownloadDomain })
  415. } else if (response.status === 'exited') {
  416. $scope.pdf.view = 'pdf'
  417. $scope.pdf.compileExited = true
  418. $scope.pdf.url = lastPdfUrl
  419. $scope.shouldShowLogs = true
  420. fetchLogs(fileByPath, { pdfDownloadDomain })
  421. } else if (response.status === 'autocompile-backoff') {
  422. if ($scope.pdf.isAutoCompileOnLoad) {
  423. // initial autocompile
  424. $scope.pdf.view = 'uncompiled'
  425. } else {
  426. // background autocompile from typing
  427. $scope.pdf.view = 'errors'
  428. $scope.pdf.autoCompileDisabled = true
  429. $scope.autocompile_enabled = false // disable any further autocompiles
  430. eventTracking.sendMB('autocompile-rate-limited', {
  431. hasPremiumCompile: $scope.hasPremiumCompile,
  432. })
  433. }
  434. } else if (response.status === 'project-too-large') {
  435. $scope.pdf.view = 'errors'
  436. $scope.pdf.projectTooLarge = true
  437. } else if (response.status === 'failure') {
  438. $scope.pdf.view = 'errors'
  439. $scope.pdf.failure = true
  440. $scope.shouldShowLogs = true
  441. fetchLogs(fileByPath, { pdfDownloadDomain })
  442. } else if (response.status === 'clsi-maintenance') {
  443. $scope.pdf.view = 'errors'
  444. $scope.pdf.clsiMaintenance = true
  445. } else if (response.status === 'unavailable') {
  446. $scope.pdf.view = 'errors'
  447. $scope.pdf.clsiUnavailable = true
  448. } else if (response.status === 'too-recently-compiled') {
  449. $scope.pdf.view = 'errors'
  450. $scope.pdf.tooRecentlyCompiled = true
  451. } else if (response.status === 'validation-problems') {
  452. $scope.pdf.view = 'validation-problems'
  453. $scope.pdf.validation = response.validationProblems
  454. $scope.shouldShowLogs = false
  455. } else if (response.status === 'compile-in-progress') {
  456. $scope.pdf.view = 'errors'
  457. $scope.pdf.compileInProgress = true
  458. } else {
  459. // fall back to displaying an error
  460. $scope.pdf.view = 'errors'
  461. $scope.pdf.error = true
  462. }
  463. if (window.showNewLogsUI) {
  464. $scope.pdf.compileFailed = false
  465. // `$scope.clsiErrors` stores the error states nested within `$scope.pdf`
  466. // for use with React's <PreviewPane errors={$scope.clsiErrors}/>
  467. $scope.clsiErrors = Object.assign(
  468. {},
  469. $scope.pdf.error ? { error: true } : null,
  470. $scope.pdf.renderingError ? { renderingError: true } : null,
  471. $scope.pdf.clsiMaintenance ? { clsiMaintenance: true } : null,
  472. $scope.pdf.clsiUnavailable ? { clsiUnavailable: true } : null,
  473. $scope.pdf.tooRecentlyCompiled ? { tooRecentlyCompiled: true } : null,
  474. $scope.pdf.compileTerminated ? { compileTerminated: true } : null,
  475. $scope.pdf.rateLimited ? { rateLimited: true } : null,
  476. $scope.pdf.compileInProgress ? { compileInProgress: true } : null,
  477. $scope.pdf.timedout ? { timedout: true } : null,
  478. $scope.pdf.autoCompileDisabled ? { autoCompileDisabled: true } : null
  479. )
  480. if (
  481. $scope.pdf.view === 'errors' ||
  482. $scope.pdf.view === 'validation-problems'
  483. ) {
  484. $scope.shouldShowLogs = true
  485. $scope.pdf.compileFailed = true
  486. }
  487. }
  488. const IGNORE_FILES = ['output.fls', 'output.fdb_latexmk']
  489. $scope.pdf.outputFiles = []
  490. if (response.outputFiles == null) {
  491. return
  492. }
  493. // prepare list of output files for download dropdown
  494. qs = {}
  495. if (response.clsiServerId != null) {
  496. qs.clsiserverid = response.clsiServerId
  497. }
  498. for (const file of response.outputFiles) {
  499. if (IGNORE_FILES.indexOf(file.path) === -1) {
  500. const isOutputFile = /^output\./.test(file.path)
  501. $scope.pdf.outputFiles.push({
  502. // Turn 'output.blg' into 'blg file'.
  503. name: isOutputFile
  504. ? `${file.path.replace(/^output\./, '')} file`
  505. : file.path,
  506. url: file.url + createQueryString(qs),
  507. main: !!isOutputFile,
  508. fileName: file.path,
  509. type: file.type,
  510. })
  511. }
  512. }
  513. // sort the output files into order, main files first, then others
  514. $scope.pdf.outputFiles.sort(
  515. (a, b) => b.main - a.main || a.name.localeCompare(b.name)
  516. )
  517. }
  518. let totalBandwidthReportedByPdfJs = 0
  519. function trackPDFDownload() {
  520. const t0 = performance.now()
  521. let bandwidth = 0
  522. function firstRenderDone({ timePDFFetched, timePDFRendered }) {
  523. const latencyFetch = timePDFFetched - t0
  524. // The renderer does not yield in case the browser tab is hidden.
  525. // It will yield when the browser tab is visible again.
  526. // This will skew our performance metrics for rendering!
  527. const latencyRender = timePDFRendered - t0
  528. done({ latencyFetch, latencyRender })
  529. }
  530. function updateConsumedBandwidth(bytes) {
  531. totalBandwidthReportedByPdfJs += bytes - bandwidth
  532. bandwidth = bytes
  533. }
  534. let done
  535. const onFirstRenderDone = new Promise(resolve => {
  536. done = resolve
  537. })
  538. return {
  539. onFirstRenderDone,
  540. firstRenderDone,
  541. updateConsumedBandwidth,
  542. }
  543. }
  544. function fetchLogs(fileByPath, options) {
  545. let blgFile, chktexFile, logFile
  546. if (options != null ? options.validation : undefined) {
  547. chktexFile = fileByPath['output.chktex']
  548. } else {
  549. logFile = fileByPath['output.log']
  550. blgFile = fileByPath['output.blg']
  551. }
  552. function getFile(name, file) {
  553. const opts = {
  554. method: 'GET',
  555. url: buildPdfDownloadUrl(options.pdfDownloadDomain, file.url),
  556. params: {
  557. compileGroup: ide.compileGroup,
  558. clsiserverid: ide.clsiServerId,
  559. },
  560. }
  561. return $http(opts)
  562. }
  563. // accumulate the log entries
  564. const logEntries = {
  565. all: [],
  566. errors: [],
  567. warnings: [],
  568. typesetting: [],
  569. }
  570. function accumulateResults(newEntries) {
  571. for (const key of ['all', 'errors', 'warnings', 'typesetting']) {
  572. if (newEntries[key]) {
  573. if (newEntries.type != null) {
  574. for (const entry of newEntries[key]) {
  575. entry.type = newEntries.type
  576. }
  577. }
  578. logEntries[key] = logEntries[key].concat(newEntries[key])
  579. }
  580. }
  581. }
  582. // use the parsers for each file type
  583. function processLog(log) {
  584. $scope.pdf.rawLog = log
  585. const { errors, warnings, typesetting } = HumanReadableLogs.parse(log, {
  586. ignoreDuplicates: true,
  587. })
  588. const all = [].concat(errors, warnings, typesetting)
  589. accumulateResults({ all, errors, warnings, typesetting })
  590. }
  591. function processChkTex(log) {
  592. const errors = []
  593. const warnings = []
  594. for (const line of log.split('\n')) {
  595. var m
  596. if ((m = line.match(/^(\S+):(\d+):(\d+): (Error|Warning): (.*)/))) {
  597. const result = {
  598. file: m[1],
  599. line: m[2],
  600. column: m[3],
  601. level: m[4].toLowerCase(),
  602. message: `${m[4]}: ${m[5]}`,
  603. }
  604. if (result.level === 'error') {
  605. errors.push(result)
  606. } else {
  607. warnings.push(result)
  608. }
  609. }
  610. }
  611. const all = [].concat(errors, warnings)
  612. const logHints = HumanReadableLogs.parse({
  613. type: 'Syntax',
  614. all,
  615. errors,
  616. warnings,
  617. })
  618. eventTracking.sendMB('syntax-check-return-count', {
  619. errors: errors.length,
  620. warnings: warnings.length,
  621. })
  622. accumulateResults(logHints)
  623. }
  624. function processBiber(log) {
  625. const { errors, warnings } = BibLogParser.parse(log, {})
  626. const all = [].concat(errors, warnings)
  627. accumulateResults({ type: 'BibTeX:', all, errors, warnings })
  628. }
  629. // output the results
  630. function handleError() {
  631. $scope.pdf.logEntries = {}
  632. $scope.pdf.rawLog = ''
  633. }
  634. function annotateFiles() {
  635. $scope.pdf.logEntries = logEntries
  636. $scope.pdf.logEntryAnnotations = {}
  637. for (const entry of logEntries.all) {
  638. if (entry.file != null) {
  639. entry.file = normalizeFilePath(entry.file)
  640. const entity = ide.fileTreeManager.findEntityByPath(entry.file)
  641. if (entity != null) {
  642. if (!$scope.pdf.logEntryAnnotations[entity.id]) {
  643. $scope.pdf.logEntryAnnotations[entity.id] = []
  644. }
  645. $scope.pdf.logEntryAnnotations[entity.id].push({
  646. row: entry.line - 1,
  647. type: entry.level === 'error' ? 'error' : 'warning',
  648. text: entry.message,
  649. })
  650. }
  651. }
  652. }
  653. }
  654. // retrieve the logfile and process it
  655. let response
  656. if (logFile != null) {
  657. response = getFile('output.log', logFile).then(response =>
  658. processLog(response.data)
  659. )
  660. if (blgFile != null) {
  661. // retrieve the blg file if present
  662. response = response.then(() =>
  663. getFile('output.blg', blgFile).then(
  664. response => processBiber(response.data),
  665. () => true
  666. )
  667. )
  668. }
  669. }
  670. if (response != null) {
  671. response.catch(handleError)
  672. } else {
  673. handleError()
  674. }
  675. if (chktexFile != null) {
  676. const getChkTex = () =>
  677. getFile('output.chktex', chktexFile).then(response =>
  678. processChkTex(response.data)
  679. )
  680. // always retrieve the chktex file if present
  681. if (response != null) {
  682. response = response.then(getChkTex, getChkTex)
  683. } else {
  684. response = getChkTex()
  685. }
  686. }
  687. // display the combined result
  688. if (response != null) {
  689. response.finally(() => {
  690. annotateFiles()
  691. sendCompileMetrics()
  692. })
  693. }
  694. }
  695. function sendCompileMetrics() {
  696. const hasCompiled =
  697. $scope.pdf.view !== 'errors' &&
  698. $scope.pdf.view !== 'validation-problems'
  699. if (hasCompiled && !window.user.alphaProgram) {
  700. const metadata = {
  701. errors: $scope.pdf.logEntries.errors.length,
  702. warnings: $scope.pdf.logEntries.warnings.length,
  703. typesetting: $scope.pdf.logEntries.typesetting.length,
  704. newLogsUI: window.showNewLogsUI,
  705. subvariant: window.showNewLogsUI ? window.logsUISubvariant : null,
  706. }
  707. eventTracking.sendMBSampled(
  708. 'compile-result',
  709. JSON.stringify(metadata),
  710. 0.01
  711. )
  712. }
  713. }
  714. function getRootDocOverrideId() {
  715. const rootDocId = $scope.project.rootDoc_id
  716. const currentDocId = ide.editorManager.getCurrentDocId()
  717. if (currentDocId === rootDocId) {
  718. return null // no need to override when in the root doc itself
  719. }
  720. const doc = ide.editorManager.getCurrentDocValue()
  721. if (doc == null) {
  722. return null
  723. }
  724. for (const line of doc.split('\n')) {
  725. if (/^[^%]*\\documentclass/.test(line)) {
  726. return ide.editorManager.getCurrentDocId()
  727. }
  728. }
  729. return null
  730. }
  731. function normalizeFilePath(path) {
  732. path = path.replace(
  733. /^(.*)\/compiles\/[0-9a-f]{24}(-[0-9a-f]{24})?\/(\.\/)?/,
  734. ''
  735. )
  736. path = path.replace(/^\/compile\//, '')
  737. const rootDocDirname = ide.fileTreeManager.getRootDocDirname()
  738. if (rootDocDirname != null) {
  739. path = path.replace(/^\.\//, rootDocDirname + '/')
  740. }
  741. return path
  742. }
  743. $scope.recompile = function (options) {
  744. if (options == null) {
  745. options = {}
  746. }
  747. if ($scope.pdf.compiling) {
  748. return
  749. }
  750. eventTracking.sendMBSampled('editor-recompile-sampled', options)
  751. $scope.lastStartedCompileAt = Date.now()
  752. $scope.pdf.compiling = true
  753. $scope.pdf.isAutoCompileOnLoad =
  754. options != null ? options.isAutoCompileOnLoad : undefined // initial autocompile
  755. if (options != null ? options.force : undefined) {
  756. // for forced compile, turn off validation check and ignore errors
  757. $scope.stop_on_validation_error = false
  758. $scope.shouldShowLogs = false // hide the logs while compiling
  759. eventTracking.sendMB('syntax-check-turn-off-checking')
  760. }
  761. if (options != null ? options.try : undefined) {
  762. $scope.shouldShowLogs = false // hide the logs while compiling
  763. eventTracking.sendMB('syntax-check-try-compile-anyway')
  764. }
  765. ide.$scope.$broadcast('flush-changes')
  766. options.rootDocOverride_id = getRootDocOverrideId()
  767. sendCompileRequest(options)
  768. .then(function (response) {
  769. const { data } = response
  770. $scope.pdf.view = 'pdf'
  771. $scope.pdf.compiling = false
  772. parseCompileResponse(data)
  773. })
  774. .catch(function (response) {
  775. const { status } = response
  776. if (status === 429) {
  777. $scope.pdf.rateLimited = true
  778. }
  779. $scope.pdf.compiling = false
  780. $scope.pdf.renderingError = false
  781. $scope.pdf.error = true
  782. $scope.pdf.view = 'errors'
  783. if (window.showNewLogsUI) {
  784. $scope.clsiErrors = { error: true }
  785. $scope.shouldShowLogs = true
  786. $scope.pdf.compileFailed = true
  787. }
  788. })
  789. .finally(() => {
  790. $scope.lastFinishedCompileAt = Date.now()
  791. })
  792. }
  793. // This needs to be public.
  794. ide.$scope.recompile = $scope.recompile
  795. // This method is a simply wrapper and exists only for tracking purposes.
  796. ide.$scope.recompileViaKey = function () {
  797. $scope.recompile({ keyShortcut: true })
  798. }
  799. $scope.stop = function () {
  800. if (!$scope.pdf.compiling) {
  801. return
  802. }
  803. return $http({
  804. url: `/project/${$scope.project_id}/compile/stop`,
  805. method: 'POST',
  806. params: {
  807. clsiserverid: ide.clsiServerId,
  808. },
  809. headers: {
  810. 'X-Csrf-Token': window.csrfToken,
  811. },
  812. })
  813. }
  814. $scope.clearCache = function () {
  815. $scope.pdf.clearingCache = true
  816. const deferred = $q.defer()
  817. // disable various download buttons
  818. $scope.pdf.url = null
  819. $scope.pdf.downloadUrl = null
  820. $scope.pdf.outputFiles = []
  821. $http({
  822. url: `/project/${$scope.project_id}/output`,
  823. method: 'DELETE',
  824. params: {
  825. clsiserverid: ide.clsiServerId,
  826. },
  827. headers: {
  828. 'X-Csrf-Token': window.csrfToken,
  829. },
  830. })
  831. .then(function (response) {
  832. $scope.pdf.clearingCache = false
  833. return deferred.resolve()
  834. })
  835. .catch(function (response) {
  836. console.error(response)
  837. const error = response.data
  838. $scope.pdf.clearingCache = false
  839. $scope.pdf.renderingError = false
  840. $scope.pdf.error = true
  841. $scope.pdf.view = 'errors'
  842. return deferred.reject(error)
  843. })
  844. return deferred.promise
  845. }
  846. $scope.recompileFromScratch = function () {
  847. $scope.pdf.compiling = true
  848. return $scope
  849. .clearCache()
  850. .then(() => {
  851. $scope.pdf.compiling = false
  852. $scope.recompile()
  853. })
  854. .catch(error => {
  855. console.error(error)
  856. })
  857. }
  858. $scope.toggleLogs = function () {
  859. $scope.$applyAsync(() => {
  860. $scope.shouldShowLogs = !$scope.shouldShowLogs
  861. if ($scope.shouldShowLogs) {
  862. eventTracking.sendMBOnce('ide-open-logs-once')
  863. }
  864. })
  865. }
  866. $scope.showPdf = function () {
  867. $scope.pdf.view = 'pdf'
  868. $scope.shouldShowLogs = false
  869. }
  870. $scope.toggleRawLog = function () {
  871. $scope.pdf.showRawLog = !$scope.pdf.showRawLog
  872. if ($scope.pdf.showRawLog) {
  873. eventTracking.sendMB('logs-view-raw')
  874. }
  875. }
  876. $scope.openClearCacheModal = function () {
  877. $modal.open({
  878. templateUrl: 'clearCacheModalTemplate',
  879. controller: 'ClearCacheModalController',
  880. scope: $scope,
  881. })
  882. }
  883. $scope.syncToCode = function (position) {
  884. synctex.syncToCode(position).then(function (data) {
  885. const { doc, line } = data
  886. ide.editorManager.openDoc(doc, { gotoLine: line })
  887. })
  888. }
  889. $scope.setAutoCompile = function (isOn) {
  890. $scope.$applyAsync(function () {
  891. $scope.autocompile_enabled = isOn
  892. })
  893. }
  894. $scope.setDraftMode = function (isOn) {
  895. $scope.$applyAsync(function () {
  896. $scope.draft = isOn
  897. })
  898. }
  899. $scope.setSyntaxCheck = function (isOn) {
  900. $scope.$applyAsync(function () {
  901. $scope.stop_on_validation_error = isOn
  902. })
  903. }
  904. $scope.runSyntaxCheckNow = function () {
  905. $scope.$applyAsync(function () {
  906. $scope.recompile({ check: true })
  907. })
  908. }
  909. $scope.openInEditor = function (entry) {
  910. let column, line
  911. eventTracking.sendMBOnce('logs-jump-to-location-once')
  912. const entity = ide.fileTreeManager.findEntityByPath(entry.file)
  913. if (entity == null || entity.type !== 'doc') {
  914. return
  915. }
  916. if (entry.line != null) {
  917. line = entry.line
  918. }
  919. if (entry.column != null) {
  920. column = entry.column
  921. }
  922. ide.editorManager.openDoc(entity, {
  923. gotoLine: line,
  924. gotoColumn: column,
  925. })
  926. }
  927. }
  928. )
  929. App.factory('synctex', function (ide, $http, $q) {
  930. const synctex = {
  931. syncToPdfInFlight: false,
  932. syncToCodeInFlight: false,
  933. syncToPdf(cursorPosition) {
  934. const deferred = $q.defer()
  935. const docId = ide.editorManager.getCurrentDocId()
  936. if (docId == null) {
  937. deferred.reject()
  938. return deferred.promise
  939. }
  940. const doc = ide.fileTreeManager.findEntityById(docId)
  941. if (doc == null) {
  942. deferred.reject()
  943. return deferred.promise
  944. }
  945. let path = ide.fileTreeManager.getEntityPath(doc)
  946. if (path == null) {
  947. deferred.reject()
  948. return deferred.promise
  949. }
  950. // If the root file is folder/main.tex, then synctex sees the
  951. // path as folder/./main.tex
  952. const rootDocDirname = ide.fileTreeManager.getRootDocDirname()
  953. if (rootDocDirname != null && rootDocDirname !== '') {
  954. path = path.replace(RegExp(`^${rootDocDirname}`), `${rootDocDirname}/.`)
  955. }
  956. const { row, column } = cursorPosition
  957. this.syncToPdfInFlight = true
  958. $http({
  959. url: `/project/${ide.project_id}/sync/code`,
  960. method: 'GET',
  961. params: {
  962. file: path,
  963. line: row + 1,
  964. column,
  965. clsiserverid: ide.clsiServerId,
  966. },
  967. })
  968. .then(response => {
  969. this.syncToPdfInFlight = false
  970. const { data } = response
  971. return deferred.resolve(data.pdf || [])
  972. })
  973. .catch(response => {
  974. this.syncToPdfInFlight = false
  975. const error = response.data
  976. return deferred.reject(error)
  977. })
  978. return deferred.promise
  979. },
  980. syncToCode(position, options) {
  981. if (options == null) {
  982. options = {}
  983. }
  984. const deferred = $q.defer()
  985. if (position == null) {
  986. deferred.reject()
  987. return deferred.promise
  988. }
  989. // FIXME: this actually works better if it's halfway across the
  990. // page (or the visible part of the page). Synctex doesn't
  991. // always find the right place in the file when the point is at
  992. // the edge of the page, it sometimes returns the start of the
  993. // next paragraph instead.
  994. const h = position.offset.left
  995. // Compute the vertical position to pass to synctex, which
  996. // works with coordinates increasing from the top of the page
  997. // down. This matches the browser's DOM coordinate of the
  998. // click point, but the pdf position is measured from the
  999. // bottom of the page so we need to invert it.
  1000. let v
  1001. if (
  1002. options.fromPdfPosition &&
  1003. (position.pageSize != null ? position.pageSize.height : undefined) !=
  1004. null
  1005. ) {
  1006. v = position.pageSize.height - position.offset.top || 0 // measure from pdf point (inverted)
  1007. } else {
  1008. v = position.offset.top || 0 // measure from html click position
  1009. }
  1010. // It's not clear exactly where we should sync to if it wasn't directly
  1011. // clicked on, but a little bit down from the very top seems best.
  1012. if (options.includeVisualOffset) {
  1013. v += 72 // use the same value as in pdfViewer highlighting visual offset
  1014. }
  1015. this.syncToCodeInFlight = true
  1016. $http({
  1017. url: `/project/${ide.project_id}/sync/pdf`,
  1018. method: 'GET',
  1019. params: {
  1020. page: position.page + 1,
  1021. h: h.toFixed(2),
  1022. v: v.toFixed(2),
  1023. clsiserverid: ide.clsiServerId,
  1024. },
  1025. })
  1026. .then(response => {
  1027. this.syncToCodeInFlight = false
  1028. const { data } = response
  1029. if (
  1030. data.code != null &&
  1031. data.code.length > 0 &&
  1032. data.code[0].file !== ''
  1033. ) {
  1034. const doc = ide.fileTreeManager.findEntityByPath(data.code[0].file)
  1035. if (doc == null) {
  1036. deferred.reject()
  1037. }
  1038. return deferred.resolve({ doc, line: data.code[0].line })
  1039. } else if (data.code[0].file === '') {
  1040. ide.$scope.sync_tex_error = true
  1041. setTimeout(() => (ide.$scope.sync_tex_error = false), 4000)
  1042. }
  1043. })
  1044. .catch(response => {
  1045. this.syncToCodeInFlight = false
  1046. const error = response.data
  1047. return deferred.reject(error)
  1048. })
  1049. return deferred.promise
  1050. },
  1051. }
  1052. return synctex
  1053. })
  1054. App.controller('PdfSynctexController', function ($scope, synctex, ide) {
  1055. this.cursorPosition = null
  1056. $scope.$watch(
  1057. () => synctex.syncToPdfInFlight,
  1058. value => ($scope.syncToPdfInFlight = value)
  1059. )
  1060. $scope.$watch(
  1061. () => synctex.syncToCodeInFlight,
  1062. value => ($scope.syncToCodeInFlight = value)
  1063. )
  1064. ide.$scope.$on('cursor:editor:update', (event, cursorPosition) => {
  1065. this.cursorPosition = cursorPosition
  1066. })
  1067. $scope.syncToPdf = () => {
  1068. if (this.cursorPosition == null) {
  1069. return
  1070. }
  1071. synctex.syncToPdf(this.cursorPosition).then(highlights => {
  1072. $scope.pdf.highlights = highlights
  1073. })
  1074. }
  1075. ide.$scope.$on('cursor:editor:syncToPdf', $scope.syncToPdf)
  1076. $scope.syncToCode = function () {
  1077. synctex
  1078. .syncToCode($scope.pdf.position, {
  1079. includeVisualOffset: true,
  1080. fromPdfPosition: true,
  1081. })
  1082. .then(function (data) {
  1083. const { doc, line } = data
  1084. ide.editorManager.openDoc(doc, { gotoLine: line })
  1085. })
  1086. }
  1087. })
  1088. App.controller('ClearCacheModalController', function ($scope, $modalInstance) {
  1089. $scope.state = { error: false, inflight: false }
  1090. $scope.clear = function () {
  1091. $scope.state.inflight = true
  1092. $scope
  1093. .clearCache()
  1094. .then(function () {
  1095. $scope.state.inflight = false
  1096. $modalInstance.close()
  1097. })
  1098. .catch(function () {
  1099. $scope.state.error = true
  1100. $scope.state.inflight = false
  1101. })
  1102. }
  1103. $scope.cancel = () => $modalInstance.dismiss('cancel')
  1104. })
  1105. // Wrap React component as Angular component. Only needed for "top-level" component
  1106. App.component(
  1107. 'previewPane',
  1108. react2angular(
  1109. rootContext.use(PreviewPane),
  1110. Object.keys(PreviewPane.propTypes)
  1111. )
  1112. )