paste-html.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950
  1. import { EditorView } from '@codemirror/view'
  2. import { Prec } from '@codemirror/state'
  3. import {
  4. insertPastedContent,
  5. pastedContent,
  6. storePastedContent,
  7. } from './pasted-content'
  8. import { debugConsole } from '@/utils/debugging'
  9. import {
  10. isBlockContainingElement,
  11. isBlockElement,
  12. isElementNode,
  13. isInlineElement,
  14. isTextNode,
  15. shouldRemoveEmptyBlockElement,
  16. } from './html-elements'
  17. export const pasteHtml = [
  18. Prec.highest(
  19. EditorView.domEventHandlers({
  20. paste(event, view) {
  21. const { clipboardData } = event
  22. if (!clipboardData) {
  23. return false
  24. }
  25. // only handle pasted HTML
  26. if (!clipboardData.types.includes('text/html')) {
  27. return false
  28. }
  29. // ignore text/html from VS Code
  30. if (
  31. clipboardData.types.includes('application/vnd.code.copymetadata') ||
  32. clipboardData.types.includes('vscode-editor-data')
  33. ) {
  34. return false
  35. }
  36. const html = clipboardData.getData('text/html').trim()
  37. const text = clipboardData.getData('text/plain').trim()
  38. if (html.length === 0) {
  39. return false
  40. }
  41. try {
  42. const latex = convertHtmlStringToLatex(
  43. html,
  44. clipboardData.files.length
  45. )
  46. // if there's no latex conversion, use plain text version
  47. if (latex === null) {
  48. return false
  49. }
  50. // if there's no formatting, use the plain text version
  51. if (latex === text && clipboardData.files.length === 0) {
  52. return false
  53. }
  54. view.dispatch(insertPastedContent(view, { latex, text }))
  55. view.dispatch(storePastedContent({ latex, text }, true))
  56. return true
  57. } catch (error) {
  58. debugConsole.error(error)
  59. // fall back to the default paste handler
  60. return false
  61. }
  62. },
  63. })
  64. ),
  65. pastedContent,
  66. ]
  67. export function convertHtmlStringToLatex(
  68. html: string,
  69. filesLength: number
  70. ): string | null {
  71. const parser = new DOMParser()
  72. const { documentElement } = parser.parseFromString(html, 'text/html')
  73. // Do not process HTML as LaTeX when the clipboard contains files (e.g. images),
  74. // unless the HTML is from an Office application or is a table-only selection.
  75. if (
  76. filesLength > 0 &&
  77. !hasProgId(documentElement) &&
  78. !isOnlyTable(documentElement)
  79. ) {
  80. return null
  81. }
  82. const bodyElement = documentElement.querySelector('body')
  83. // DOMParser should always create a body element, so this is mostly for TypeScript
  84. if (!bodyElement) {
  85. return null
  86. }
  87. // If the only content is a code block, skip latex conversion
  88. if (onlyCode(bodyElement)) {
  89. return null
  90. }
  91. return htmlToLaTeX(bodyElement)
  92. }
  93. const removeUnwantedElements = (
  94. documentElement: HTMLElement,
  95. selector: string
  96. ) => {
  97. for (const element of documentElement.querySelectorAll(selector)) {
  98. element.remove()
  99. }
  100. }
  101. const findCodeContainingElement = (documentElement: HTMLElement) => {
  102. let result: HTMLElement | null
  103. // a code element
  104. result = documentElement.querySelector<HTMLElement>('code')
  105. if (result) {
  106. return result
  107. }
  108. // a pre element with "monospace" somewhere in the font family
  109. result = documentElement.querySelector<HTMLPreElement>('pre')
  110. if (result?.style.fontFamily.includes('monospace')) {
  111. return result
  112. }
  113. return null
  114. }
  115. // return true if the text content of the first <code> element
  116. // is the same as the text content of the whole document element
  117. const onlyCode = (documentElement: HTMLElement) => {
  118. const codeElement = findCodeContainingElement(documentElement)
  119. return (
  120. codeElement?.textContent?.trim() === documentElement.textContent?.trim()
  121. )
  122. }
  123. const hasProgId = (documentElement: HTMLElement) => {
  124. const meta = documentElement.querySelector<HTMLMetaElement>(
  125. 'meta[name="ProgId"]'
  126. )
  127. return meta && meta.content.trim().length > 0
  128. }
  129. // detect a table (probably pasted from desktop Excel)
  130. const isOnlyTable = (documentElement: HTMLElement) => {
  131. const body = documentElement.querySelector<HTMLBodyElement>('body')
  132. return (
  133. body &&
  134. body.childElementCount === 1 &&
  135. body.firstElementChild!.nodeName === 'TABLE'
  136. )
  137. }
  138. const htmlToLaTeX = (bodyElement: HTMLElement) => {
  139. // remove style elements
  140. removeUnwantedElements(bodyElement, 'style')
  141. let before: string | null = null
  142. let after: string | null = null
  143. // repeat until the content stabilises
  144. do {
  145. before = bodyElement.textContent
  146. // normalise whitespace in text
  147. normaliseWhitespace(bodyElement)
  148. // replace unwanted whitespace in blocks
  149. processWhitespaceInBlocks(bodyElement)
  150. after = bodyElement.textContent
  151. } while (before !== after)
  152. // pre-process table elements
  153. processTables(bodyElement)
  154. // pre-process lists
  155. processLists(bodyElement)
  156. // protect special characters in non-LaTeX text nodes
  157. protectSpecialCharacters(bodyElement)
  158. processMatchedElements(bodyElement)
  159. const text = bodyElement.textContent
  160. if (!text) {
  161. return ''
  162. }
  163. return (
  164. text
  165. // remove zero-width spaces (e.g. those added by Powerpoint)
  166. .replaceAll('​', '')
  167. // normalise multiple newlines
  168. .replaceAll(/\n{2,}/g, '\n\n')
  169. // only allow a single newline at the start and end
  170. .replaceAll(/(^\n+|\n+$)/g, '\n')
  171. // replace tab with 4 spaces (hard-coded indent unit)
  172. .replaceAll('\t', ' ')
  173. )
  174. }
  175. const trimInlineElements = (
  176. element: HTMLElement,
  177. precedingSpace = true
  178. ): boolean => {
  179. for (const node of element.childNodes) {
  180. if (isTextNode(node)) {
  181. let text = node.textContent!
  182. if (precedingSpace) {
  183. text = text.replace(/^\s+/, '')
  184. }
  185. if (text === '') {
  186. node.remove()
  187. } else {
  188. node.textContent = text
  189. precedingSpace = /\s$/.test(text)
  190. }
  191. } else if (isInlineElement(node)) {
  192. precedingSpace = trimInlineElements(node, precedingSpace)
  193. } else if (isBlockElement(node)) {
  194. precedingSpace = true // TODO
  195. } else {
  196. precedingSpace = false // TODO
  197. }
  198. }
  199. // TODO: trim whitespace at the end
  200. return precedingSpace
  201. }
  202. const processWhitespaceInBlocks = (documentElement: HTMLElement) => {
  203. trimInlineElements(documentElement)
  204. const walker = document.createTreeWalker(
  205. documentElement,
  206. NodeFilter.SHOW_ELEMENT,
  207. node =>
  208. isElementNode(node) && isElementContainingCode(node)
  209. ? NodeFilter.FILTER_REJECT
  210. : NodeFilter.FILTER_ACCEPT
  211. )
  212. for (let node = walker.nextNode(); node; node = walker.nextNode()) {
  213. // TODO: remove leading newline from pre, code and textarea?
  214. if (isBlockContainingElement(node)) {
  215. // remove all text nodes directly inside elements that should only contain blocks
  216. for (const childNode of node.childNodes) {
  217. if (isTextNode(childNode)) {
  218. childNode.remove()
  219. }
  220. }
  221. }
  222. if (isBlockElement(node)) {
  223. trimInlineElements(node)
  224. if (shouldRemoveEmptyBlockElement(node)) {
  225. node.remove()
  226. // TODO: and parents?
  227. }
  228. }
  229. }
  230. }
  231. const normaliseWhitespace = (documentElement: HTMLElement) => {
  232. const walker = document.createTreeWalker(
  233. documentElement,
  234. NodeFilter.SHOW_TEXT,
  235. node =>
  236. isElementNode(node) && isElementContainingCode(node)
  237. ? NodeFilter.FILTER_REJECT
  238. : NodeFilter.FILTER_ACCEPT
  239. )
  240. for (let node = walker.nextNode(); node; node = walker.nextNode()) {
  241. const text = node.textContent
  242. if (text !== null) {
  243. if (/^\s+$/.test(text)) {
  244. // replace nodes containing only whitespace (including non-breaking space) with a single space
  245. node.textContent = ' '
  246. } else {
  247. // collapse contiguous whitespace (except for non-breaking space) to a single space
  248. node.textContent = text.replaceAll(/[\n\r\f\t \u2028\u2029]+/g, ' ')
  249. }
  250. }
  251. }
  252. }
  253. // TODO: negative lookbehind once Safari supports it
  254. const specialCharacterRegExp = /(^|[^\\])([#$%&~_^\\{}])/g
  255. const specialCharacterReplacer = (
  256. _match: string,
  257. prefix: string,
  258. char: string
  259. ) => {
  260. if (char === '\\') {
  261. // convert `\` to `\textbackslash{}`, preserving subsequent whitespace
  262. char = 'textbackslash{}'
  263. }
  264. return `${prefix}\\${char}`
  265. }
  266. const isElementContainingCode = (element: HTMLElement) =>
  267. element.nodeName === 'CODE' ||
  268. (element.nodeName === 'PRE' && element.style.fontFamily.includes('monospace'))
  269. const protectSpecialCharacters = (documentElement: HTMLElement) => {
  270. const walker = document.createTreeWalker(
  271. documentElement,
  272. NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
  273. node =>
  274. isElementNode(node) && isElementContainingCode(node)
  275. ? NodeFilter.FILTER_REJECT
  276. : NodeFilter.FILTER_ACCEPT
  277. )
  278. for (let node = walker.nextNode(); node; node = walker.nextNode()) {
  279. if (isTextNode(node)) {
  280. const text = node.textContent
  281. if (text) {
  282. // replace non-backslash-prefixed characters
  283. node.textContent = text.replaceAll(
  284. specialCharacterRegExp,
  285. specialCharacterReplacer
  286. )
  287. }
  288. }
  289. }
  290. }
  291. const processMatchedElements = (documentElement: HTMLElement) => {
  292. for (const item of selectors) {
  293. for (const element of documentElement.querySelectorAll<any>(
  294. item.selector
  295. )) {
  296. if (!item.match || item.match(element)) {
  297. // start the markup
  298. if (item.start) {
  299. const start = document.createTextNode(item.start(element))
  300. if (item.inside) {
  301. element.prepend(start)
  302. } else {
  303. element.before(start)
  304. }
  305. }
  306. // end the markup
  307. if (item.end) {
  308. const end = document.createTextNode(item.end(element))
  309. if (item.inside) {
  310. element.append(end)
  311. } else {
  312. element.after(end)
  313. }
  314. }
  315. }
  316. }
  317. }
  318. }
  319. const matchingParents = (element: HTMLElement, selector: string) => {
  320. const matches = []
  321. for (
  322. let ancestor = element.parentElement?.closest(selector);
  323. ancestor;
  324. ancestor = ancestor.parentElement?.closest(selector)
  325. ) {
  326. matches.push(ancestor)
  327. }
  328. return matches
  329. }
  330. const urlCharacterReplacements = new Map<string, string>([
  331. ['\\', '\\\\'],
  332. ['#', '\\#'],
  333. ['%', '\\%'],
  334. ['{', '%7B'],
  335. ['}', '%7D'],
  336. ])
  337. const protectUrlCharacters = (url: string) => {
  338. // NOTE: add new characters to both this regex and urlCharacterReplacements
  339. return url.replaceAll(/[\\#%{}]/g, match => {
  340. const replacement = urlCharacterReplacements.get(match)
  341. if (!replacement) {
  342. throw new Error(`No replacement found for ${match}`)
  343. }
  344. return replacement
  345. })
  346. }
  347. const processLists = (element: HTMLElement) => {
  348. for (const list of element.querySelectorAll('ol,ul')) {
  349. // if the list has only one item, replace the list with an element containing the contents of the item
  350. if (list.childElementCount === 1) {
  351. const div = document.createElement('div')
  352. div.append(...list.firstElementChild!.childNodes)
  353. list.before('\n', div, '\n')
  354. list.remove()
  355. }
  356. }
  357. }
  358. const processTables = (element: HTMLElement) => {
  359. for (const table of element.querySelectorAll('table')) {
  360. // create a wrapper element for the table and the caption
  361. const container = document.createElement('div')
  362. container.className = 'ol-table-wrap'
  363. table.after(container)
  364. // move the caption (if it exists) into the container before the table
  365. const caption = table.querySelector('caption')
  366. if (caption) {
  367. container.append(caption)
  368. }
  369. // move the table into the container
  370. container.append(table)
  371. // add empty cells to account for rowspan
  372. for (const cell of table.querySelectorAll<HTMLTableCellElement>(
  373. 'th[rowspan],td[rowspan]'
  374. )) {
  375. const rowspan = Number(cell.getAttribute('rowspan') || '1')
  376. const colspan = Number(cell.getAttribute('colspan') || '1')
  377. let row: HTMLTableRowElement | null = cell.closest('tr')
  378. if (row) {
  379. let position = 0
  380. for (const child of row.cells) {
  381. if (child === cell) {
  382. break
  383. }
  384. position += Number(child.getAttribute('colspan') || '1')
  385. }
  386. for (let i = 1; i < rowspan; i++) {
  387. const nextElement: Element | null = row?.nextElementSibling
  388. if (!isTableRow(nextElement)) {
  389. break
  390. }
  391. row = nextElement
  392. let targetCell: HTMLTableCellElement | undefined
  393. let targetPosition = 0
  394. for (const child of row.cells) {
  395. if (targetPosition === position) {
  396. targetCell = child
  397. break
  398. }
  399. targetPosition += Number(child.getAttribute('colspan') || '1')
  400. }
  401. const fillerCells = Array.from({ length: colspan }, () =>
  402. document.createElement('td')
  403. )
  404. if (targetCell) {
  405. targetCell.before(...fillerCells)
  406. } else {
  407. row.append(...fillerCells)
  408. }
  409. }
  410. }
  411. }
  412. }
  413. }
  414. const isTableRow = (element: Element | null): element is HTMLTableRowElement =>
  415. element?.nodeName === 'TR'
  416. const cellAlignment = new Map([
  417. ['left', 'l'],
  418. ['center', 'c'],
  419. ['right', 'r'],
  420. ])
  421. const tabular = (element: HTMLTableElement) => {
  422. const definitions: Array<{
  423. alignment: string
  424. borderLeft: boolean
  425. borderRight: boolean
  426. inferred?: boolean
  427. }> = []
  428. const rows = element.querySelectorAll('tr')
  429. for (const row of rows) {
  430. const cells = [...row.childNodes].filter(
  431. element => element.nodeName === 'TD' || element.nodeName === 'TH'
  432. ) as Array<HTMLTableCellElement>
  433. let index = 0
  434. for (const cell of cells) {
  435. // NOTE: reading the alignment and borders from the first cell definition in each column
  436. const colspan = Number(cell.getAttribute('colspan') ?? 1)
  437. const { textAlign, borderLeftStyle, borderRightStyle } = cell.style
  438. for (let i = 0; i < colspan; i++) {
  439. if (
  440. // There's no definition for this column
  441. definitions[index + i] === undefined ||
  442. // There's an inferred definition of the column, and we're a cell that
  443. // can accurately represent the whole column, since we're not a
  444. // multicolumn cell ourselves.
  445. (colspan === 1 && definitions[index + i].inferred)
  446. ) {
  447. definitions[index + i] = {
  448. alignment: textAlign,
  449. borderLeft: visibleBorderStyle(borderLeftStyle),
  450. borderRight: visibleBorderStyle(borderRightStyle),
  451. // We can't trust the details from a multicolumn cell to represent the
  452. // whole column, so we mark it as inferred.
  453. inferred: colspan > 1,
  454. }
  455. }
  456. }
  457. index += colspan
  458. }
  459. }
  460. for (let index = 0; index <= definitions.length; index++) {
  461. // fill in missing definitions
  462. const item = definitions[index] || {
  463. alignment: 'left',
  464. borderLeft: false,
  465. borderRight: false,
  466. }
  467. // remove left border if previous column had a right border
  468. if (item.borderLeft && index > 0 && definitions[index - 1]?.borderRight) {
  469. item.borderLeft = false
  470. }
  471. }
  472. return definitions
  473. .flatMap(definition => [
  474. definition.borderLeft ? '|' : '',
  475. cellAlignment.get(definition.alignment) ?? 'l',
  476. definition.borderRight ? '|' : '',
  477. ])
  478. .filter(Boolean)
  479. .join(' ')
  480. }
  481. const listDepth = (element: HTMLElement): number =>
  482. Math.max(0, matchingParents(element, 'ul,ol').length)
  483. const indentUnit = ' ' // TODO: replace hard-coded indent unit?
  484. const listIndent = (element: HTMLElement | null): string =>
  485. element ? indentUnit.repeat(listDepth(element)) : ''
  486. type ElementSelector<T extends string, E extends HTMLElement = HTMLElement> = {
  487. selector: T
  488. match?: (element: E) => boolean
  489. start?: (element: E) => string
  490. end?: (element: E) => string
  491. inside?: boolean
  492. }
  493. const createSelector = <
  494. T extends string,
  495. E extends HTMLElement = T extends keyof HTMLElementTagNameMap
  496. ? HTMLElementTagNameMap[T]
  497. : HTMLElement,
  498. >({
  499. selector,
  500. ...elementSelector
  501. }: ElementSelector<T, E>) => ({
  502. selector,
  503. ...elementSelector,
  504. })
  505. const headings = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6']
  506. const isHeading = (element: HTMLElement | null) => {
  507. return element && headings.includes(element.nodeName)
  508. }
  509. const hasContent = (element: HTMLElement): boolean => {
  510. return Boolean(element.textContent && element.textContent.trim().length > 0)
  511. }
  512. type BorderStyle =
  513. | 'borderTopStyle'
  514. | 'borderRightStyle'
  515. | 'borderBottomStyle'
  516. | 'borderLeftStyle'
  517. const visibleBorderStyle = (style: CSSStyleDeclaration[BorderStyle]): boolean =>
  518. !!style && style !== 'none' && style !== 'hidden'
  519. const rowHasBorderStyle = (
  520. element: HTMLTableRowElement,
  521. style: BorderStyle
  522. ): boolean => {
  523. if (visibleBorderStyle(element.style[style])) {
  524. return true
  525. }
  526. const cells = element.querySelectorAll<HTMLTableCellElement>('th,td')
  527. return [...cells].every(cell => visibleBorderStyle(cell.style[style]))
  528. }
  529. const isTableRowElement = (
  530. element: Element | null
  531. ): element is HTMLTableRowElement => element?.nodeName === 'TR'
  532. const nextRowHasBorderStyle = (
  533. element: HTMLTableRowElement,
  534. style: BorderStyle
  535. ) => {
  536. const { nextElementSibling } = element
  537. return (
  538. isTableRowElement(nextElementSibling) &&
  539. rowHasBorderStyle(nextElementSibling, style)
  540. )
  541. }
  542. const startMulticolumn = (element: HTMLTableCellElement): string => {
  543. const { textAlign, borderLeftStyle, borderRightStyle } = element.style
  544. const colspan = Number(element.getAttribute('colspan') || 1)
  545. const alignment = cellAlignment.get(textAlign) ?? 'l'
  546. const borderLeft = visibleBorderStyle(borderLeftStyle)
  547. const borderRight = visibleBorderStyle(borderRightStyle)
  548. return `\\multicolumn{${colspan}}{${borderLeft ? '|' : ''}${alignment}${borderRight ? '|' : ''}}{`
  549. }
  550. const startMultirow = (element: HTMLTableCellElement): string => {
  551. const rowspan = Number(element.getAttribute('rowspan') || 1)
  552. // NOTE: it would be useful to read cell width if specified, using `*` as a starting point
  553. return `\\multirow{${rowspan}}{*}{`
  554. }
  555. const listPrefix = (element: HTMLOListElement | HTMLUListElement) => {
  556. if (isListOrListItemElement(element.parentElement)) {
  557. // within a list = newline
  558. return '\n'
  559. }
  560. // outside a list = double newline
  561. return '\n\n'
  562. }
  563. const listSuffix = (element: HTMLOListElement | HTMLUListElement) => {
  564. if (listDepth(element) === 0) {
  565. // a top-level list => newline
  566. return '\n'
  567. } else {
  568. // a nested list => no extra newline
  569. return ''
  570. }
  571. }
  572. const isListElement = (
  573. element: Element | null
  574. ): element is HTMLOListElement | HTMLUListElement =>
  575. element !== null && listNodeNames.includes(element.nodeName)
  576. const isListOrListItemElement = (
  577. element: Element | null
  578. ): element is HTMLOListElement | HTMLUListElement =>
  579. element !== null && (isListElement(element) || element.nodeName === 'LI')
  580. const listNodeNames = ['OL', 'UL']
  581. const selectors = [
  582. createSelector({
  583. selector: 'b',
  584. match: element =>
  585. !element.style.fontWeight &&
  586. !isHeading(element.parentElement) &&
  587. hasContent(element),
  588. start: () => '\\textbf{',
  589. end: () => '}',
  590. }),
  591. createSelector({
  592. selector: '*',
  593. match: element =>
  594. (element.style.fontWeight === 'bold' ||
  595. parseInt(element.style.fontWeight) >= 700) &&
  596. hasContent(element),
  597. start: () => '\\textbf{',
  598. end: () => '}',
  599. inside: true,
  600. }),
  601. createSelector({
  602. selector: 'strong',
  603. match: element => !element.style.fontWeight && hasContent(element),
  604. start: () => '\\textbf{',
  605. end: () => '}',
  606. }),
  607. createSelector({
  608. selector: 'i',
  609. match: element => !element.style.fontStyle && hasContent(element),
  610. start: () => '\\textit{',
  611. end: () => '}',
  612. }),
  613. createSelector({
  614. selector: '*',
  615. match: element =>
  616. element.style.fontStyle === 'italic' && hasContent(element),
  617. start: () => '\\textit{',
  618. end: () => '}',
  619. inside: true,
  620. }),
  621. createSelector({
  622. selector: 'em',
  623. match: element => !element.style.fontStyle && hasContent(element),
  624. start: () => '\\textit{',
  625. end: () => '}',
  626. }),
  627. createSelector({
  628. selector: 'sup',
  629. match: element => !element.style.verticalAlign && hasContent(element),
  630. start: () => '\\textsuperscript{',
  631. end: () => '}',
  632. }),
  633. createSelector({
  634. selector: 'span',
  635. match: element =>
  636. element.style.verticalAlign === 'super' && hasContent(element),
  637. start: () => '\\textsuperscript{',
  638. end: () => '}',
  639. }),
  640. createSelector({
  641. selector: 'sub',
  642. match: element => !element.style.verticalAlign && hasContent(element),
  643. start: () => '\\textsubscript{',
  644. end: () => '}',
  645. }),
  646. createSelector({
  647. selector: 'span',
  648. match: element =>
  649. element.style.verticalAlign === 'sub' && hasContent(element),
  650. start: () => '\\textsubscript{',
  651. end: () => '}',
  652. }),
  653. createSelector({
  654. selector: 'a',
  655. match: element => !!element.href && hasContent(element),
  656. start: (element: HTMLAnchorElement) => {
  657. const url = protectUrlCharacters(element.href)
  658. return `\\href{${url}}{`
  659. },
  660. end: () => `}`,
  661. }),
  662. createSelector({
  663. selector: 'h1',
  664. match: element => !element.closest('table') && hasContent(element),
  665. start: () => `\n\n\\section{`,
  666. end: () => `}\n\n`,
  667. }),
  668. createSelector({
  669. selector: 'h2',
  670. match: element => !element.closest('table') && hasContent(element),
  671. start: () => `\n\n\\subsection{`,
  672. end: () => `}\n\n`,
  673. }),
  674. createSelector({
  675. selector: 'h3',
  676. match: element => !element.closest('table') && hasContent(element),
  677. start: () => `\n\n\\subsubsection{`,
  678. end: () => `}\n\n`,
  679. }),
  680. createSelector({
  681. selector: 'h4',
  682. match: element => !element.closest('table') && hasContent(element),
  683. start: () => `\n\n\\paragraph{`,
  684. end: () => `}\n\n`,
  685. }),
  686. createSelector({
  687. selector: 'h5',
  688. match: element => !element.closest('table') && hasContent(element),
  689. start: () => `\n\n\\subparagraph{`,
  690. end: () => `}\n\n`,
  691. }),
  692. // TODO: h6?
  693. createSelector({
  694. selector: 'br',
  695. match: element => !element.closest('table'),
  696. start: () => `\n\n`,
  697. }),
  698. createSelector({
  699. selector: 'code',
  700. match: element =>
  701. element.parentElement?.nodeName !== 'PRE' && hasContent(element),
  702. start: () => `\\verb|`,
  703. end: () => `|`,
  704. }),
  705. createSelector({
  706. selector: 'pre > code',
  707. match: element => hasContent(element),
  708. start: () => `\n\n\\begin{verbatim}\n`,
  709. end: () => `\n\\end{verbatim}\n\n`,
  710. }),
  711. createSelector({
  712. selector: 'pre',
  713. match: element =>
  714. element.style.fontFamily.includes('monospace') &&
  715. element.firstElementChild?.nodeName !== 'CODE' &&
  716. hasContent(element),
  717. start: () => `\n\n\\begin{verbatim}\n`,
  718. end: () => `\n\\end{verbatim}\n\n`,
  719. }),
  720. createSelector({
  721. selector: '.ol-table-wrap',
  722. start: () => `\n\n\\begin{table}\n\\centering\n`,
  723. end: () => `\n\\end{table}\n\n`,
  724. }),
  725. createSelector({
  726. selector: 'table',
  727. start: element => `\n\\begin{tabular}{${tabular(element)}}`,
  728. end: () => `\\end{tabular}\n`,
  729. }),
  730. createSelector({
  731. selector: 'thead',
  732. start: () => `\n`,
  733. end: () => `\n`,
  734. }),
  735. createSelector({
  736. selector: 'tfoot',
  737. start: () => `\n`,
  738. end: () => `\n`,
  739. }),
  740. createSelector({
  741. selector: 'tbody',
  742. start: () => `\n`,
  743. end: () => `\n`,
  744. }),
  745. createSelector({
  746. selector: 'tr',
  747. start: element => {
  748. const borderTop = rowHasBorderStyle(element, 'borderTopStyle')
  749. return borderTop ? '\\hline\n' : ''
  750. },
  751. end: element => {
  752. const borderBottom = rowHasBorderStyle(element, 'borderBottomStyle')
  753. return borderBottom && !nextRowHasBorderStyle(element, 'borderTopStyle')
  754. ? '\n\\hline\n'
  755. : '\n'
  756. },
  757. }),
  758. createSelector({
  759. selector: 'tr > td, tr > th',
  760. start: (element: HTMLTableCellElement) => {
  761. let output = ''
  762. const colspan = element.getAttribute('colspan')
  763. if (colspan && Number(colspan) > 1) {
  764. output += startMulticolumn(element)
  765. }
  766. // NOTE: multirow is nested inside multicolumn
  767. const rowspan = element.getAttribute('rowspan')
  768. if (rowspan && Number(rowspan) > 1) {
  769. output += startMultirow(element)
  770. }
  771. return output
  772. },
  773. end: element => {
  774. let output = ''
  775. // NOTE: multirow is nested inside multicolumn
  776. const rowspan = element.getAttribute('rowspan')
  777. if (rowspan && Number(rowspan) > 1) {
  778. output += '}'
  779. }
  780. const colspan = element.getAttribute('colspan')
  781. if (colspan && Number(colspan) > 1) {
  782. output += '}'
  783. }
  784. const row = element.parentElement as HTMLTableRowElement
  785. const isLastChild = row.cells.item(row.cells.length - 1) === element
  786. return output + (isLastChild ? ' \\\\' : ' & ')
  787. },
  788. }),
  789. createSelector({
  790. selector: 'caption',
  791. start: () => `\n\n\\caption{`,
  792. end: () => `}\n\n`,
  793. }),
  794. createSelector({
  795. // selector: 'ul:has(> li:nth-child(2))', // only select lists with at least 2 items (once Firefox supports :has())
  796. selector: 'ul',
  797. start: element => {
  798. return `${listPrefix(element)}${listIndent(element)}\\begin{itemize}`
  799. },
  800. end: element => {
  801. return `\n${listIndent(element)}\\end{itemize}${listSuffix(element)}`
  802. },
  803. }),
  804. createSelector({
  805. // selector: 'ol:has(> li:nth-child(2))', // only select lists with at least 2 items (once Firefox supports :has())
  806. selector: 'ol',
  807. start: element => {
  808. return `${listPrefix(element)}${listIndent(element)}\\begin{enumerate}`
  809. },
  810. end: element => {
  811. return `\n${listIndent(element)}\\end{enumerate}${listSuffix(element)}`
  812. },
  813. }),
  814. createSelector({
  815. selector: 'li',
  816. start: element => {
  817. return `\n${listIndent(element.parentElement)}${indentUnit}\\item `
  818. },
  819. }),
  820. createSelector({
  821. selector: 'p',
  822. match: element => {
  823. // must have content
  824. if (!hasContent(element)) {
  825. return false
  826. }
  827. // inside lists and tables, must precede another paragraph
  828. if (element.closest('li') || element.closest('table')) {
  829. return element.nextElementSibling?.nodeName === 'P'
  830. }
  831. return true
  832. },
  833. end: () => '\n\n',
  834. }),
  835. createSelector({
  836. selector: 'blockquote',
  837. start: () => `\n\n\\begin{quote}\n`,
  838. end: () => `\n\\end{quote}\n\n`,
  839. }),
  840. ]