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

Support svg files in figure modal

GitOrigin-RevId: 4f0e110dad3c06f5e5ac699e0bfcaf3cd6c784ff
This commit is contained in:
Malik Glossop
2025-12-10 09:05:44 +00:00
committed by Copybot
parent ddbc40693b
commit 4462472e33
13 changed files with 543 additions and 66 deletions
@@ -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
@@ -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 labelCommand = includeLabel ? '\\label{fig:placeholder}' : ''
const captionCommand = includeCaption ? '\\caption{Enter Caption}' : ''
if (figure) {
// Updating existing figure
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 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])
@@ -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,
})
@@ -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,
@@ -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')
@@ -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
} |
@@ -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,
@@ -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) {
@@ -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')
@@ -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',
})
})
})
})
@@ -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')
})
})
})
})
@@ -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')
@@ -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)
})
})
})