viewer.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. document.addEventListener('DOMContentLoaded', () => {
  2. const viewerMode = document.getElementById('viewerMode').value
  3. const filename = document.getElementById('filename').value
  4. const zoom = 0.25// From the pdf2htmlex parameter --zoom
  5. const zoomRatio = 1 / zoom
  6. const saveButton = document.getElementById('saveButton')
  7. const whiteoutButton = document.getElementById('whiteoutButton')
  8. const editTextButton = document.getElementById('editTextButton')
  9. const saveForm = document.getElementById('saveForm')
  10. const filenameInput = document.getElementById('filenameInput')
  11. const htmlInput = document.getElementById('htmlInput')
  12. const pageContainer = document.getElementById('page-container')
  13. let host = null
  14. let mode = null
  15. const toggleMode = newMode => {
  16. if (mode === newMode) newMode = null
  17. switch (mode) {
  18. case 'whiteout': disableWhiteout(); break;
  19. case 'editText': disableEditText(); break;
  20. }
  21. switch (newMode) {
  22. case 'whiteout': enableWhiteout(); break;
  23. case 'editText': enableEditText(); break;
  24. }
  25. mode = newMode
  26. }
  27. document.querySelectorAll('#page-container img').forEach(image => {
  28. image.addEventListener('click', () => {
  29. const selection = getSelection()
  30. selection.removeAllRanges()
  31. const range = document.createRange()
  32. range.selectNode(image)
  33. selection.addRange(range)
  34. })
  35. })
  36. saveButton.addEventListener('click', () => {
  37. htmlInput.value = ''
  38. htmlInput.value = document.documentElement.outerHTML
  39. filenameInput.value = filename
  40. saveForm.submit()
  41. htmlInput.value = ''
  42. })
  43. const save = () => new Promise((resolve, reject) => {
  44. document.documentElement.classList.add('saving')
  45. const req = new XMLHttpRequest()
  46. req.open('POST', 'save', true)
  47. req.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded')
  48. req.responseType = 'arraybuffer'
  49. htmlInput.value = ''
  50. const body = `filename=${encodeURIComponent(filename)}&html=${encodeURIComponent(document.documentElement.outerHTML)}`
  51. req.onload = () => {
  52. if (req.status > 299) {
  53. reject(new Error(req.statusText))
  54. } else {
  55. resolve(req.response)
  56. }
  57. }
  58. req.onerror = (ev) => {
  59. reject(ev.error)
  60. }
  61. req.send(body)
  62. }).finally(() => {
  63. document.documentElement.classList.remove('saving')
  64. })
  65. // Get screen to print ratio
  66. /*
  67. The editing process deals with documents in several different dimensions and units of distance:
  68. - Paper size (A4, Legal, etc)
  69. - Print size (Font size in points, applied using the '@media print' rule)
  70. - Screen size (Font size in pixels, applied using the '@media screen' rule)
  71. - Zoom (Display transformation of the document preview, applied using transform-matrix)
  72. - Browser size (1:1 ratio for the browser itself)
  73. The following code reads evidence of these different values from the CSS, and determines how to transform from
  74. browser vector space, to preview vector space, to print vector space, so our edits and the preview are accurately
  75. represented in the resulting PDF.
  76. */
  77. const localStyleSheets = Array.from(document.styleSheets).filter(x => !x.href)
  78. const pageOnePixelWidth = localStyleSheets.map(ss =>
  79. Array.from(ss.rules)
  80. .find(rule => rule.selectorText === '.w0' && rule.style.width.endsWith('px'))
  81. ).find(x => x).style.width.split('px')[0]
  82. const pageOnePointWidth = localStyleSheets.map(ss =>
  83. Array.from(ss.rules)
  84. .filter(rule => rule instanceof CSSMediaRule && rule.conditionText === 'print')
  85. .map(mediaRule =>
  86. Array.from(mediaRule.cssRules)
  87. .filter(rule => rule.selectorText === '.w0' && rule.style.width.endsWith('pt'))
  88. .find(x => x)
  89. ).find(x => x)
  90. ).find(x => x).style.width.split('pt')[0]
  91. const pixelsPerPoint = 1.3333333333333333
  92. const pageOnePrintPixelWidth = pageOnePointWidth * pixelsPerPoint
  93. const screenToPrintRatio = pageOnePrintPixelWidth / pageOnePixelWidth
  94. const printToScreenRatio = pageOnePixelWidth / pageOnePrintPixelWidth
  95. // Replacements are constructed in print vector space, so they must be scaled down in screen.
  96. const myStyle = document.createElement('style')
  97. myStyle.type = 'text/css'
  98. myStyle.innerHTML = `
  99. @media screen {
  100. .replacement {
  101. zoom: ${printToScreenRatio};
  102. }
  103. }
  104. `
  105. document.head.appendChild(myStyle)
  106. // Whiteout
  107. let start = null
  108. const box = document.createElement('div')
  109. box.className = 'whiteout-box'
  110. whiteoutButton.addEventListener('click', () => toggleMode('whiteout'))
  111. const enableWhiteout = () => {
  112. whiteoutButton.classList.add('active')
  113. pageContainer.classList.add('whiteout')
  114. pageContainer.addEventListener('mousedown', whiteoutMouseDown)
  115. }
  116. const disableWhiteout = () => {
  117. whiteoutButton.classList.remove('active')
  118. pageContainer.classList.remove('whiteout')
  119. pageContainer.removeEventListener('mousedown', whiteoutMouseDown)
  120. }
  121. const whiteoutMouseDown = event => {
  122. start = {x: event.clientX, y: event.clientY}
  123. document.body.appendChild(box)
  124. drawBox(start, start)
  125. window.addEventListener('mousemove', whiteoutMouseMove)
  126. window.addEventListener('mouseup', whiteoutMouseUp)
  127. }
  128. const whiteoutMouseMove = event => {
  129. let end = {x: event.clientX, y: event.clientY}
  130. drawBox(rect(start, end))
  131. }
  132. const whiteoutMouseUp = event => {
  133. let end = {x: event.clientX, y: event.clientY}
  134. document.body.removeChild(box)
  135. const selection = rect(start, end)
  136. console.log('whiteout', selection)
  137. whiteout(selection)
  138. window.removeEventListener('mousemove', whiteoutMouseMove)
  139. window.removeEventListener('mouseup', whiteoutMouseUp)
  140. }
  141. const rect = (a, b) => {
  142. const left = Math.min(a.x, b.x)
  143. const top = Math.min(a.y, b.y)
  144. const right = Math.max(a.x, b.x)
  145. const bottom = Math.max(a.y, b.y)
  146. return new DOMRect(left, top, right - left, bottom - top)
  147. }
  148. const drawBox = rect => {
  149. box.style.cssText = `
  150. top: ${rect.top - 1}px;
  151. left: ${rect.left - 1}px;
  152. width: ${rect.width}px;
  153. height: ${rect.height}px;
  154. `
  155. }
  156. const intersects = (r1, r2) =>
  157. !(r2.left > r1.right ||
  158. r2.right < r1.left ||
  159. r2.top > r1.bottom ||
  160. r2.bottom < r1.top)
  161. /*
  162. The common whiteout approach of drawing an opaque white box over sensitive content is quite ineffective.
  163. A user can simply select the text from behind the whiteout, and copy & paste it into another program.
  164. This method finds every text character within the selected rectangle, and replaces it with a placeholder.
  165. It also modifies the background image by drawing a white box, replacing the pixels within the rectangle.
  166. */
  167. const whiteout = rect => {
  168. const elements = []
  169. const walk = (element) => {
  170. let elementRect
  171. if (element instanceof HTMLImageElement) {
  172. elementRect = element.getBoundingClientRect()
  173. } else {
  174. const range = document.createRange()
  175. range.selectNodeContents(element)
  176. elementRect = range.getBoundingClientRect()
  177. }
  178. if (intersects(rect, elementRect)) {
  179. if (element.childNodes && element.childNodes.length) {
  180. Array.from(element.childNodes).forEach(walk)
  181. } else if (element instanceof Text && element.textContent.length > 1) {
  182. while (element.textContent.length) {
  183. const next = element.splitText(1)
  184. walk(element)
  185. element = next
  186. }
  187. } else {
  188. elements.push(element)
  189. }
  190. }
  191. }
  192. walk(pageContainer)
  193. elements.forEach(element => {
  194. if (element instanceof Text) {
  195. const range = document.createRange()
  196. range.selectNodeContents(element)
  197. const elementRect = range.getBoundingClientRect()
  198. const replacement = document.createElement('span')
  199. replacement.className = 'replacement'
  200. replacement.style.cssText = `
  201. width: ${elementRect.width * zoomRatio * screenToPrintRatio}px;
  202. display: inline-block;
  203. position: relative;
  204. `
  205. element.replaceWith(replacement)
  206. } else if (element instanceof HTMLImageElement) {
  207. const canvas = document.createElement('canvas')
  208. canvas.width = element.offsetWidth * zoomRatio
  209. canvas.height = element.offsetHeight * zoomRatio
  210. const ctx = canvas.getContext('2d')
  211. ctx.fillStyle = 'white'
  212. ctx.drawImage(element, 0, 0, element.offsetWidth * zoomRatio, element.offsetHeight * zoomRatio)
  213. const elementRect = element.getBoundingClientRect()
  214. ctx.fillRect(
  215. (rect.x - elementRect.x) * zoomRatio,
  216. (rect.y - elementRect.y) * zoomRatio,
  217. rect.width * zoomRatio,
  218. rect.height * zoomRatio
  219. )
  220. element.src = canvas.toDataURL()
  221. }
  222. })
  223. window.editor.changed = true
  224. }
  225. // Edit Text
  226. editTextButton.addEventListener('click', () => toggleMode('editText'))
  227. const enableEditText = () => {
  228. editTextButton.classList.add('active')
  229. pageContainer.classList.add('editText')
  230. document.querySelectorAll('.pc').forEach(page => {
  231. page.setAttribute('contenteditable', 'true')
  232. })
  233. window.editor.changed = true
  234. }
  235. const disableEditText = () => {
  236. editTextButton.classList.remove('active')
  237. pageContainer.classList.remove('editText')
  238. document.querySelectorAll('.pc').forEach(page => {
  239. page.setAttribute('contenteditable', 'false')
  240. })
  241. }
  242. const attach = hostController => {
  243. host = hostController
  244. }
  245. window.editor = {
  246. attach,
  247. save,
  248. changed: false
  249. }
  250. // Debugging purposes: Display page CSS rules
  251. const rules = Array.from(document.styleSheets).filter(x => !x.href).map(x => Array.from(x.cssRules)).reduce((a, b) => [...a, ...b], [])
  252. const pageRules = rules.filter(x => x.constructor.name == 'CSSPageRule').map(x => x.cssText + rules.filter(y => y.parentRule === x).map(x => x.cssText).join('\n')).join('\n')
  253. console.log(pageRules)
  254. console.log(rules.filter(x => x.cssText.length < 2000).map(x => x.cssText).join('\n'))
  255. })