Просмотр исходного кода

Merge pull request #30027 from overleaf/mg-svg-figure-modal

Support svg files in figure modal

GitOrigin-RevId: 4f0e110dad3c06f5e5ac699e0bfcaf3cd6c784ff
Malik Glossop 10 месяцев назад
Родитель
Сommit
4462472e33

+ 5 - 4
services/web/frontend/js/features/file-tree/util/path.ts

@@ -114,19 +114,20 @@ export function previewByPath(
     '.jpg',
     '.jpeg',
     '.pdf',
+    '.svg',
     '.PNG',
     '.JPG',
     '.JPEG',
     '.PDF',
+    '.SVG',
   ]) {
     const result = findEntityByPath(folder, path + suffix)
 
     if (result?.type === 'fileRef') {
       const { name, hash } = result.entity
-      return {
-        url: `/project/${projectId}/blob/${hash}`,
-        extension: name.slice(name.lastIndexOf('.') + 1),
-      }
+      const extension = name.slice(name.lastIndexOf('.') + 1)
+      const url = `/project/${projectId}/blob/${hash}`
+      return { url, extension }
     }
   }
   return null

+ 104 - 38
services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx

@@ -27,6 +27,7 @@ import { useTranslation } from 'react-i18next'
 import useEventListener from '../../../../shared/hooks/use-event-listener'
 import { prepareLines } from '../../utils/prepare-lines'
 import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
+import { isSvgFile } from '../../utils/file'
 
 const FigureModalBody = lazy(() => import('./figure-modal-body'))
 
@@ -143,26 +144,63 @@ const FigureModalContent = () => {
   )
 
   const insert = useCallback(async () => {
-    const figure = view.state.field<FigureData>(editFigureData, false)
-
-    if (!getPath) {
-      throw new Error('Cannot insert figure without a file path')
+    const replaceGraphicsCommand = (
+      figure: FigureData,
+      changes: ChangeSpec[],
+      isSvg: boolean,
+      insertPath: string
+    ) => {
+      // Replace the entire graphics command when switching between SVG and non-SVG
+      const graphicsCommandName = isSvg ? 'includesvg' : 'includegraphics'
+      let widthArgument = ''
+      if (figure.unknownGraphicsArguments) {
+        widthArgument = `[${figure.unknownGraphicsArguments}]`
+      } else if (width) {
+        widthArgument = `[width=${width}\\linewidth]`
+      }
+      changes.push({
+        from: figure.graphicsCommand.from,
+        to: figure.graphicsCommand.to,
+        insert: `\\${graphicsCommandName}${widthArgument}{${insertPath}}`,
+      })
     }
-    let path: string
-    try {
-      path = await getPath()
-    } catch (error) {
-      dispatch({ error: String(error) })
-      return
+
+    const updateGraphicsArgs = (
+      figure: FigureData,
+      changes: ChangeSpec[],
+      insertPath?: string
+    ) => {
+      if (!figure.unknownGraphicsArguments && width) {
+        // We understood the arguments, and should update the width
+        if (figure.graphicsCommandArguments !== null) {
+          changes.push({
+            from: figure.graphicsCommandArguments.from,
+            to: figure.graphicsCommandArguments.to,
+            insert: `width=${width}\\linewidth`,
+          })
+        } else {
+          // Insert new args
+          changes.push({
+            from: figure.file.from - 1,
+            insert: `[width=${width}\\linewidth]`,
+          })
+        }
+      }
+      changes.push({
+        from: figure.file.from,
+        to: figure.file.to,
+        insert: insertPath,
+      })
     }
-    const labelCommand = includeLabel ? '\\label{fig:placeholder}' : ''
-    const captionCommand = includeCaption ? '\\caption{Enter Caption}' : ''
 
-    if (figure) {
-      // Updating existing figure
+    const updateCaptionAndLabel = (
+      figure: FigureData,
+      changes: ChangeSpec[]
+    ) => {
+      const labelCommand = includeLabel ? '\\label{fig:placeholder}' : ''
+      const captionCommand = includeCaption ? '\\caption{Enter Caption}' : ''
       const hadCaptionBefore = figure.caption !== null
       const hadLabelBefore = figure.label !== null
-      const changes: ChangeSpec[] = []
       if (!hadCaptionBefore && includeCaption) {
         // We should insert a caption
         changes.push({
@@ -198,28 +236,9 @@ const FigureModalContent = () => {
           insert: '',
         })
       }
-      if (!figure.unknownGraphicsArguments && width) {
-        // We understood the arguments, and should update the width
-        if (figure.graphicsCommandArguments !== null) {
-          changes.push({
-            from: figure.graphicsCommandArguments.from,
-            to: figure.graphicsCommandArguments.to,
-            insert: `width=${width}\\linewidth`,
-          })
-        } else {
-          // Insert new args
-          changes.push({
-            from: figure.file.from - 1,
-            insert: `[width=${width}\\linewidth]`,
-          })
-        }
-      }
-      changes.push({ from: figure.file.from, to: figure.file.to, insert: path })
-      view.dispatch({
-        changes: view.state.changes(changes),
-        effects: editFigureDataEffect.of(null),
-      })
-    } else {
+    }
+
+    const insertNewFigure = (insertPath: string, isSvg: boolean) => {
       const { pos, suffix } = ensureEmptyLine(
         view.state,
         view.state.selection.main
@@ -229,11 +248,12 @@ const FigureModalContent = () => {
         width !== undefined ? `[width=${width}\\linewidth]` : ''
       const caption = includeCaption ? `\n\t\\caption{\${Enter Caption}}` : ''
       const label = includeLabel ? `\n\t\\label{\${fig:placeholder}}` : ''
+      const graphicsCommand = isSvg ? 'includesvg' : 'includegraphics'
 
       snippet(
         `\\begin{figure}
 \t\\centering
-\t\\includegraphics${widthArgument}{${path}}${caption}${label}
+\t\\${graphicsCommand}${widthArgument}{${insertPath}}${caption}${label}
 \\end{figure}${suffix}\${}`
       )(
         { state: view.state, dispatch: view.dispatch },
@@ -242,6 +262,52 @@ const FigureModalContent = () => {
         pos
       )
     }
+
+    const figure = view.state.field<FigureData>(editFigureData, false)
+
+    if (!getPath) {
+      throw new Error('Cannot insert figure without a file path')
+    }
+    let path: string
+    try {
+      path = await getPath()
+    } catch (error) {
+      dispatch({ error: String(error) })
+      return
+    }
+
+    const isSvg = isSvgFile(path)
+    // For SVG files, strip the .svg extension as \includesvg expects it without
+    const insertPath = isSvg ? path.replace(/\.svg$/i, '') : path
+
+    if (figure) {
+      // Updating existing figure
+      const changes: ChangeSpec[] = []
+
+      // Update caption and label as needed
+      updateCaptionAndLabel(figure, changes)
+
+      // Check if we're switching to/from SVG, which requires changing the command
+      const wasSvgBefore = isSvgFile(figure.file.path)
+      const needsCommandChange = isSvg !== wasSvgBefore
+
+      if (needsCommandChange) {
+        // Replace the entire graphics command when switching between SVG and non-SVG
+        replaceGraphicsCommand(figure, changes, isSvg, insertPath)
+      } else {
+        // No command change needed, do surgical edits
+        updateGraphicsArgs(figure, changes, insertPath)
+      }
+
+      view.dispatch({
+        changes: view.state.changes(changes),
+        effects: editFigureDataEffect.of(null),
+      })
+    } else {
+      // Inserting new figure
+      insertNewFigure(insertPath, isSvg)
+    }
+
     hide()
   }, [getPath, view, hide, includeCaption, includeLabel, width, dispatch])
 

+ 1 - 1
services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx

@@ -49,7 +49,7 @@ export const FigureModalUploadFileSource: FC = () => {
       restrictions: {
         maxNumberOfFiles: 1,
         maxFileSize: getMeta('ol-ExposedSettings').maxUploadSize,
-        allowedFileTypes: ['image/*', '.pdf', '.eps'],
+        allowedFileTypes: ['image/*', '.pdf', '.eps', '.svg'],
       },
       autoProceed: false,
     })

+ 22 - 3
services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts

@@ -881,11 +881,18 @@ export const atomicDecorations = (options: Options) => {
               )
             )
           }
-        } else if (nodeRef.type.is('IncludeGraphics')) {
-          // \includegraphics with a file path argument
+        } else if (
+          nodeRef.type.is('IncludeGraphics') ||
+          nodeRef.type.is('IncludeSvg')
+        ) {
+          // \includegraphics or \includesvg with a file path argument
+          const isIncludeSvg = nodeRef.type.is('IncludeSvg')
           if (shouldDecorate(state, nodeRef)) {
+            const argumentNodeName = isIncludeSvg
+              ? 'IncludeSvgArgument'
+              : 'IncludeGraphicsArgument'
             const filePathArgument = nodeRef.node
-              .getChild('IncludeGraphicsArgument')
+              .getChild(argumentNodeName)
               ?.getChild('FilePathArgument')
               ?.getChild('LiteralArgContent')
 
@@ -895,6 +902,18 @@ export const atomicDecorations = (options: Options) => {
                 filePathArgument.to
               )
 
+              // \includegraphics doesn't support SVG
+              if (!isIncludeSvg && filePath.toLowerCase().endsWith('.svg')) {
+                return false
+              }
+
+              if (
+                isIncludeSvg &&
+                previewByPath(filePath)?.extension !== 'svg'
+              ) {
+                return false
+              }
+
               if (filePath) {
                 const environmentNode = ancestorNodeOfType(
                   state,

+ 75 - 0
services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/graphics.ts

@@ -100,6 +100,7 @@ export class GraphicsWidget extends WidgetType {
 
     switch (preview.extension) {
       case 'pdf':
+      case 'PDF':
         {
           const canvas = document.createElement('canvas')
           canvas.classList.add('ol-cm-graphics')
@@ -108,6 +109,11 @@ export class GraphicsWidget extends WidgetType {
         }
         break
 
+      case 'svg':
+      case 'SVG':
+        element.append(this.createSvgImage(view, preview.url))
+        break
+
       default:
         element.append(this.createImage(view, preview.url))
         break
@@ -147,6 +153,75 @@ export class GraphicsWidget extends WidgetType {
     return wrapper
   }
 
+  /**
+   * Creates an image element for SVG files by fetching the content and
+   * creating a Blob URL with the correct MIME type. This is necessary because
+   * the server serves blobs as application/octet-stream, which browsers won't
+   * render as SVG.
+   */
+  createSvgImage(view: EditorView, url: string) {
+    const wrapper = document.createElement('div')
+    const image = document.createElement('img')
+    image.classList.add('ol-cm-graphics')
+    image.classList.add('ol-cm-graphics-loading')
+    const width = this.getFigureWidth()
+    image.style.width = width
+    image.style.maxWidth = width
+
+    // Fetch SVG and create a Blob URL with correct MIME type
+    fetch(url)
+      .then(response => {
+        if (!response.ok) {
+          throw new Error(`Failed to fetch SVG: ${response.status}`)
+        }
+        return response.text()
+      })
+      .then(svgText => {
+        if (this.destroyed) {
+          return
+        }
+
+        const blob = new Blob([svgText], { type: 'image/svg+xml' })
+        const objectUrl = URL.createObjectURL(blob)
+
+        const showError = () => {
+          URL.revokeObjectURL(objectUrl)
+          const errorElement = this.createErrorElement(view)
+          wrapper.replaceChildren(errorElement)
+          this.height = wrapper.clientHeight
+          view.requestMeasure()
+        }
+
+        image.addEventListener(
+          'load',
+          () => {
+            URL.revokeObjectURL(objectUrl)
+            image.classList.remove('ol-cm-graphics-loading')
+            this.height = image.height
+            view.requestMeasure()
+          },
+          { once: true }
+        )
+
+        image.addEventListener('error', showError, { once: true })
+
+        image.src = objectUrl
+      })
+      .catch(() => {
+        if (this.destroyed) {
+          return
+        }
+
+        const errorElement = this.createErrorElement(view)
+        wrapper.replaceChildren(errorElement)
+        this.height = wrapper.clientHeight
+        view.requestMeasure()
+      })
+
+    wrapper.appendChild(image)
+    return wrapper
+  }
+
   createErrorElement(view: EditorView): HTMLElement {
     const wrapper = document.createElement('div')
     wrapper.classList.add('ol-cm-graphics-loading-error')

+ 5 - 0
services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar

@@ -58,6 +58,7 @@
     VerbCtrlSeq,
     LstInlineCtrlSeq,
     IncludeGraphicsCtrlSeq,
+    IncludeSvgCtrlSeq,
     CaptionCtrlSeq,
     DefCtrlSeq,
     LetCtrlSeq,
@@ -297,6 +298,10 @@ KnownCommand<ArgumentType> {
         IncludeGraphicsCtrlSeq optionalWhitespace? OptionalArgument?
         IncludeGraphicsArgument { FilePathArgument }
     } |
+    IncludeSvg {
+        IncludeSvgCtrlSeq optionalWhitespace? OptionalArgument?
+        IncludeSvgArgument { FilePathArgument }
+    } |
     Caption {
         CaptionCtrlSeq "*"? optionalWhitespace? OptionalArgument? TextArgument
     } |

+ 2 - 0
services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs

@@ -33,6 +33,7 @@ import {
   VerbCtrlSeq,
   LstInlineCtrlSeq,
   IncludeGraphicsCtrlSeq,
+  IncludeSvgCtrlSeq,
   CaptionCtrlSeq,
   DefCtrlSeq,
   LetCtrlSeq,
@@ -559,6 +560,7 @@ const otherKnowncommands = {
   '\\verb': VerbCtrlSeq,
   '\\lstinline': LstInlineCtrlSeq,
   '\\includegraphics': IncludeGraphicsCtrlSeq,
+  '\\includesvg': IncludeSvgCtrlSeq,
   '\\caption': CaptionCtrlSeq,
   '\\def': DefCtrlSeq,
   '\\let': LetCtrlSeq,

+ 13 - 5
services/web/frontend/js/features/source-editor/utils/file.ts

@@ -46,15 +46,23 @@ function filterByType(type: 'file' | 'doc' | 'folder') {
 export const filterFiles = filterByType('file')
 export const filterFolders = filterByType('folder')
 
-const IMAGE_FILE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'pdf', 'eps']
+const IMAGE_FILE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'pdf', 'eps', 'svg']
 
-export const hasImageExtension = (filename: string) => {
+export const getFileExtension = (filename: string): string | null => {
   const parts = filename.split('.')
   if (parts.length < 2) {
-    return false
+    return null
   }
-  const extension = parts[parts.length - 1].toLowerCase()
-  return IMAGE_FILE_EXTENSIONS.includes(extension)
+  return parts[parts.length - 1].toLowerCase()
+}
+
+export const hasImageExtension = (filename: string) => {
+  const extension = getFileExtension(filename)
+  return extension !== null && IMAGE_FILE_EXTENSIONS.includes(extension)
+}
+
+export const isSvgFile = (filename: string): boolean => {
+  return getFileExtension(filename) === 'svg'
 }
 
 export function isImageFile(file: File) {

+ 10 - 3
services/web/frontend/js/features/source-editor/utils/tree-operations/environments.ts

@@ -227,7 +227,8 @@ export function parseFigureData(
         to: node.to,
       }
     }
-    if (node.type.is('IncludeGraphics')) {
+    if (node.type.is('IncludeGraphics') || node.type.is('IncludeSvg')) {
+      const isIncludeSvg = node.type.is('IncludeSvg')
       if (file) {
         // Multiple figure
         error = true
@@ -237,18 +238,24 @@ export function parseFigureData(
         from: node.from,
         to: node.to,
       }
+      const argumentNodeName = isIncludeSvg
+        ? 'IncludeSvgArgument'
+        : 'IncludeGraphicsArgument'
       const content = node.node
-        .getChild('IncludeGraphicsArgument')
+        .getChild(argumentNodeName)
         ?.getChild('FilePathArgument')
         ?.getChild('LiteralArgContent')
       if (!content) {
         error = true
         return false
       }
+      // \includesvg stores path without .svg extension, but we add it for consistency
       file = {
         from: content.from,
         to: content.to,
-        path: state.sliceDoc(content.from, content.to),
+        path:
+          state.sliceDoc(content.from, content.to) +
+          (isIncludeSvg ? '.svg' : ''),
       }
       const optionalArgs = node.node
         .getChild('OptionalArgument')

+ 17 - 0
services/web/test/frontend/features/file-tree/util/path.test.ts

@@ -70,6 +70,11 @@ describe('Path utils', function () {
           name: 'frog.JPG',
           hash: '22',
         },
+        {
+          _id: 'test-svg-file',
+          name: 'diagram.svg',
+          hash: '44',
+        },
       ],
     }
   })
@@ -158,5 +163,17 @@ describe('Path utils', function () {
         extension: 'png',
       })
     })
+
+    it('returns SVG files with correct extension', function () {
+      const preview = previewByPath(
+        rootFolder,
+        'test-project-id',
+        'diagram.svg'
+      )
+      expect(preview).to.deep.equal({
+        url: '/project/test-project-id/blob/44',
+        extension: 'svg',
+      })
+    })
   })
 })

+ 170 - 1
services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx

@@ -13,6 +13,9 @@ import getMeta from '@/utils/meta'
 import { mockProject } from '../helpers/mock-project'
 import { base64image } from '../fixtures/image'
 
+const svgContent =
+  '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><circle cx="50" cy="50" r="40" fill="red"/></svg>'
+
 const findInsertFigureToolbarButton = () => {
   return cy.findByRole('toolbar').within(() => {
     return cy
@@ -83,7 +86,16 @@ describe('<FigureModal />', function () {
           previewByPath: cy
             .stub()
             .as('previewByPath')
-            .returns({ url: base64image, extension: 'png' }),
+            .callsFake(path => {
+              if (path === 'diagram.svg' || path === 'diagram') {
+                return {
+                  url: '/project/test-project/blob/abc123',
+                  extension: 'svg',
+                }
+              }
+              // Default to PNG for any other path (for non-SVG tests)
+              return { url: base64image, extension: 'png' }
+            }),
         }}
       >
         {children}
@@ -674,5 +686,162 @@ text below`,
 
     // TODO: Add tests for replacing image when we can match on image path
     // TODO: Add tests for changing image size when we can match on figure width
+
+    it('Switches from includegraphics to includesvg when replacing with SVG file', function () {
+      cy.interceptLinkedFile()
+
+      cy.get('.cm-content').type(
+        `\\begin{{}figure}
+\\centering
+\\includegraphics[width=0.75\\linewidth]{{}frog.jpg}
+\\caption{{}My caption}
+\\label{{}fig:my-label}
+\\end{{}figure}`,
+        { delay: 0 }
+      )
+      cy.get('[aria-label="Edit figure"]').click({ force: true })
+      cy.findByRole('button', { name: 'Remove or replace figure' }).click()
+      cy.findByText('Replace from URL').click()
+      cy.findByLabelText('Image URL').type('https://example.com/diagram.svg')
+      cy.findByText('Insert figure').click()
+
+      cy.get('@linked-file-request').should('have.been.calledWithMatch', {
+        body: {
+          provider: 'url',
+          data: {
+            url: 'https://example.com/diagram.svg',
+          },
+        },
+      })
+
+      // Note: \caption and \label render as widgets in visual editor
+      cy.get('.cm-content').should(
+        'have.text',
+        '\\begin{figure}\\centering\\includesvg[width=0.75\\linewidth]{diagram}My caption🏷fig:my-label\\end{figure}'
+      )
+    })
+  })
+
+  describe('SVG file handling', function () {
+    beforeEach(function () {
+      // Intercept the fetch request for the SVG blob
+      cy.intercept('GET', '/project/test-project/blob/abc123', {
+        statusCode: 200,
+        body: svgContent,
+        headers: {
+          'Content-Type': 'application/octet-stream',
+        },
+      }).as('svgFetch')
+    })
+
+    describe('inserting SVG from URL', function () {
+      beforeEach(function () {
+        cy.interceptLinkedFile()
+        clickFigureToolbarButton()
+        cy.findByRole('menu').within(() => {
+          cy.findByText('From URL').click()
+        })
+        cy.findByLabelText('Image URL').as('image-url-input')
+        findInsertFigureDialogButton()
+      })
+
+      it('uses includesvg command for SVG files and displays the file', function () {
+        cy.get('@image-url-input').type('https://example.com/diagram.svg')
+        cy.get('@insertFigureDialogButton').click()
+
+        cy.get('@linked-file-request').should('have.been.calledWithMatch', {
+          body: {
+            provider: 'url',
+            data: {
+              url: 'https://example.com/diagram.svg',
+            },
+          },
+        })
+
+        cy.get('[aria-label="Edit figure"]').should('exist')
+        cy.get('.ol-cm-environment-figure[data-filepath="diagram"]').should(
+          'exist'
+        )
+        cy.get('.cm-content').should(
+          'have.text',
+          '\\begin{figure}    \\centeringedit    \\caption{Enter Caption}    🏷fig:placeholder\\end{figure}'
+        )
+      })
+    })
+
+    describe('editing existing SVG figure', function () {
+      it('parses existing includesvg with width, label, and caption', function () {
+        cy.get('.cm-content').type(
+          `\\begin{{}figure}
+\\centering
+\\includesvg[width=0.75\\linewidth]{{}diagram}
+\\caption{{}My SVG caption}
+\\label{{}fig:svg-label}
+\\end{{}figure}`,
+          { delay: 0 }
+        )
+        cy.get('[aria-label="Edit figure"]').click({ force: true })
+        cy.get('[value="0.75"]').should('be.checked')
+        cy.findByRole('checkbox', { name: 'Include caption' }).should(
+          'be.checked'
+        )
+        cy.findByRole('checkbox', { name: 'Include label' }).should(
+          'be.checked'
+        )
+      })
+
+      it('removes existing label from includesvg figure when unchecked', function () {
+        cy.get('.cm-content').type(
+          `\\begin{{}figure}
+\\centering
+\\includesvg[width=0.75\\linewidth]{{}diagram}
+\\label{{}fig:my-label}
+\\end{{}figure}`,
+          { delay: 0 }
+        )
+        cy.get('[aria-label="Edit figure"]').click({ force: true })
+        cy.findByRole('checkbox', { name: 'Include label' }).click()
+        cy.findByRole('checkbox', { name: 'Include label' }).should(
+          'not.be.checked'
+        )
+        cy.findByText('Done').click()
+        cy.get('.cm-content').should(
+          'have.text',
+          '\\begin{figure}\\centering\\includesvg[width=0.75\\linewidth]{diagram}\\end{figure}'
+        )
+      })
+
+      it('switches from includesvg to includegraphics when replacing with non-SVG file', function () {
+        cy.interceptLinkedFile()
+
+        cy.get('.cm-content').type(
+          `\\begin{{}figure}
+\\centering
+\\includesvg[width=0.75\\linewidth]{{}diagram}
+\\caption{{}My caption}
+\\label{{}fig:my-label}
+\\end{{}figure}`,
+          { delay: 0 }
+        )
+        cy.get('[aria-label="Edit figure"]').click({ force: true })
+        cy.findByRole('button', { name: 'Remove or replace figure' }).click()
+        cy.findByText('Replace from URL').click()
+        cy.findByLabelText('Image URL').type('https://example.com/photo.jpg')
+        cy.findByText('Insert figure').click()
+
+        cy.get('@linked-file-request').should('have.been.calledWithMatch', {
+          body: {
+            provider: 'url',
+            data: {
+              url: 'https://example.com/photo.jpg',
+            },
+          },
+        })
+
+        // Should switch to includegraphics for non-SVG files
+        cy.get('.cm-content').should('contain.text', '\\includegraphics')
+        cy.get('.cm-content').should('not.contain.text', '\\includesvg')
+      })
+    })
   })
 })

+ 66 - 10
services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx

@@ -12,6 +12,9 @@ import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-pat
 import { TestContainer } from '../helpers/test-container'
 import { base64image } from '../fixtures/image'
 
+const svgContent =
+  '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><circle cx="50" cy="50" r="40" fill="red"/></svg>'
+
 describe('<CodeMirrorEditor/> in Visual mode', function () {
   beforeEach(function () {
     window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
@@ -35,16 +38,24 @@ describe('<CodeMirrorEditor/> in Visual mode', function () {
           previewByPath: cy
             .stub()
             .as('previewByPath')
-            .callsFake(path =>
-              path === 'valid.png'
-                ? { url: base64image, extension: 'png' }
-                : path === 'graphic.eps'
-                  ? {
-                      url: 'data:application/postscript,0 0 moveto (hello) show',
-                      extension: 'eps',
-                    }
-                  : null
-            ),
+            .callsFake(path => {
+              if (path === 'valid.png') {
+                return { url: base64image, extension: 'png' }
+              }
+              if (path === 'graphic.eps') {
+                return {
+                  url: 'data:application/postscript,0 0 moveto (hello) show',
+                  extension: 'eps',
+                }
+              }
+              if (path === 'diagram.svg' || path === 'diagram') {
+                return {
+                  url: '/project/test-project/blob/abc123',
+                  extension: 'svg',
+                }
+              }
+              return null
+            }),
         }}
       >
         {children}
@@ -259,6 +270,51 @@ describe('<CodeMirrorEditor/> in Visual mode', function () {
     cy.get('@first-line').should('have.text', '\\foo[bar]{baz} ')
   })
 
+  it('renders \\includesvg command and fetches SVG with blob URL', function () {
+    // Intercept the fetch request for the SVG blob
+    cy.intercept('GET', '/project/test-project/blob/abc123', {
+      statusCode: 200,
+      body: svgContent,
+      headers: {
+        'Content-Type': 'application/octet-stream',
+      },
+    }).as('svgFetch')
+
+    // Type the includesvg command directly (no figure environment needed)
+    cy.get('@first-line').type('\\includesvg[width=0.5\\linewidth]{{}diagram}')
+
+    // Move cursor out to trigger widget rendering
+    cy.get('@second-line').click()
+
+    // Wait for the fetch to complete
+    cy.wait('@svgFetch')
+
+    // Should show the image with a blob URL (widget renders the SVG)
+    cy.get('img.ol-cm-graphics')
+      .should('exist')
+      .and('have.attr', 'src')
+      .and('match', /^blob:/)
+  })
+
+  it('does not render \\includegraphics with SVG file (invalid LaTeX)', function () {
+    // \includegraphics{file.svg} is not valid LaTeX - SVG files require \includesvg
+    cy.get('@first-line').type(
+      '\\includegraphics[width=0.5\\linewidth]{{}invalid.svg}'
+    )
+
+    // Move cursor out to trigger any potential widget rendering
+    cy.get('@second-line').click()
+
+    // Should NOT create a preview widget - raw LaTeX should be visible
+    cy.get('.cm-content').should(
+      'contain.text',
+      '\\includegraphics[width=0.5\\linewidth]{invalid.svg}'
+    )
+
+    // No graphics widget should be created
+    cy.get('img.ol-cm-graphics').should('not.exist')
+  })
+
   describe('Figure environments', function () {
     beforeEach(function () {
       cy.get('@first-line').type('\\begin{{}figure')

+ 52 - 0
services/web/test/frontend/features/source-editor/utils/file.test.ts

@@ -0,0 +1,52 @@
+import { expect } from 'chai'
+import {
+  getFileExtension,
+  hasImageExtension,
+  isSvgFile,
+} from '../../../../../frontend/js/features/source-editor/utils/file'
+
+describe('file utilities', function () {
+  describe('getFileExtension', function () {
+    ;[
+      ['image.png', 'png'],
+      ['image.PNG', 'png'],
+      ['folder/subfolder/image.SVG', 'svg'],
+      ['my.file.name.jpg', 'jpg'],
+      ['filename', null],
+      ['.gitignore.txt', 'txt'],
+    ].forEach(([input, expected]) => {
+      it(`returns correct extension for "${input}"`, function () {
+        expect(getFileExtension(input as string)).to.equal(expected)
+      })
+    })
+  })
+
+  describe('hasImageExtension', function () {
+    ;['png', 'jpg', 'jpeg', 'pdf', 'eps', 'svg'].forEach(ext => {
+      it(`returns true for .${ext} files (including nested and uppercase)`, function () {
+        expect(hasImageExtension(`image.${ext}`)).to.equal(true)
+        expect(hasImageExtension(`image.${ext.toUpperCase()}`)).to.equal(true)
+        expect(hasImageExtension(`folder/image.${ext}`)).to.equal(true)
+      })
+    })
+
+    it('returns false for non-image files', function () {
+      expect(hasImageExtension('document.tex')).to.equal(false)
+      expect(hasImageExtension('README')).to.equal(false)
+    })
+  })
+
+  describe('isSvgFile', function () {
+    it('returns true for svg files (including nested and uppercase)', function () {
+      expect(isSvgFile('diagram.svg')).to.equal(true)
+      expect(isSvgFile('diagram.SVG')).to.equal(true)
+      expect(isSvgFile('figures/diagram.svg')).to.equal(true)
+    })
+
+    it('returns false for non-svg files', function () {
+      expect(isSvgFile('image.png')).to.equal(false)
+      expect(isSvgFile('svgfile')).to.equal(false)
+      expect(isSvgFile('svg-diagram.png')).to.equal(false)
+    })
+  })
+})