Repository: marp-team/marp-vscode
Pull request #489 (2025-01-16 17:51:25)

Add experimental `markdown.marp.pptx.editable` option
[Marp CLI v4.1.0](https://github.com/marp-team/marp-cli/releases/tag/v4.1.0) has implemented an experimental `--pptx-editable` option, to export the editable PowerPoint document. This PR adds an experimental `markdown.marp.pptx.editable` setting for an ability to output editable PPTX when exporting slides into PPTX.

![](https://github.com/user-attachments/assets/0fe0c42c-fc4c-4f68-809c-300abbae836c)


- `off` (default): Always export non-editable PPTX. It has full visual reproducibillity and presenter note support.
- `on`: Always export editable PPTX. Please note that the editable PPTX may fail to convert or be generated with an incomplete layout depending on the slide content.
- `smart`: Try to export editable PPTX first, and fallback to non-editable PPTX if failed. This option is a specific value to Marp for VS Code extension.

> [!IMPORTANT]
>
> It is required installing both of the supported browser and LibreOffice Impress to export editable PowerPoint document.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 69669dc..7248d95 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
 ### Added
 
 - `markdown.marp.exportAutoOpen` setting ([#464](https://github.com/marp-team/marp-vscode/pull/464) by [@rtfmkiesel](https://github.com/rtfmkiesel))
+- Experimental `markdown.marp.pptx.editable` setting ([#489](https://github.com/marp-team/marp-vscode/pull/489))
 
 ### Changed
 
diff --git a/README.md b/README.md
index 948a4ce..83c3df9 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ We will enhance your VS Code as the slide deck writer. Mark `marp: true`, and wr
 
 See the documentation of [Marpit Markdown](https://marpit.marp.app/markdown) and [the features of Marp Core](https://github.com/marp-team/marp-core#features) about how to write.
 
-> Please refer **[https://marp.app/][marp]** for more details of Marp ecosystem. We have powerful tools for Marp Markdown: [Marpit Framework](https://marpit.marp.app/), [CLI tool](https://github.com/marp-team/marp-cli), [Web interface](https://web.marp.app/) and so on.
+> Please refer **[https://marp.app/][marp]** for more details of Marp ecosystem. We have powerful tools for Marp Markdown: [Marpit Framework](https://marpit.marp.app/), [Marp Core](https://github.com/marp-team/marp-core), [CLI tool](https://github.com/marp-team/marp-cli) and so on.
 
 [marp]: https://marp.app/
 
@@ -189,7 +189,9 @@ We extend the outline view to support slide pages in Marp Markdown.
   <img src="https://raw.githubusercontent.com/marp-team/marp-vscode/main/docs/outline.png" alt="Outline view for each slide" width="400" />
 </p>
 
-> ℹ️ Please choose `Sort By: Position` from context menu of its panel if you see incorrect slide order.
+> [!TIP]
+>
+> Please choose `Sort By: Position` from context menu of its panel if you see incorrect slide order.
 
 #### Slide folding in editor
 
@@ -220,6 +222,22 @@ In the untrusted workspace, HTML elements in Marp Markdown will be always ignore
 > [!NOTE]
 > A legacy setting `markdown.marp.enableHtml` is deprecated since v2. Please use `markdown.marp.html` instead.
 
+## Experimental settings
+
+Some of settings are marked as experimental. These feature may be unstable and change the spec in the future.
+
+### [`markdown.marp.pptx.editable`](https://github.com/marp-team/marp-vscode/pull/489)
+
+You can enable the experimental feature to export PPTX with editable contents, based on [Marp CLI's corresponding experimental option](https://github.com/marp-team/marp-cli#experimental-generate-editable-pptx---pptx-editable). This feature requires to install both of the compatible browser and [LibreOffice Impress](https://www.libreoffice.org/).
+
+If set this setting as `smart`, Marp for VS Code will try to export into editable PPTX first, and then fallback to the regular PPTX export (non-editable) if failed.
+
+### [`markdown.marp.strictPathResolutionDuringExport`](https://github.com/marp-team/marp-vscode/pull/367)
+
+This experimental setting is useful to improve the export result compatibility with VS Code preview. If enabled, the export command will try to resolve relative paths in Markdown from VS Code workspace that a Markdown file belongs.
+
+If disabled, or the Markdown does not belong to any workspace, the export command will resolve paths based on the local file system. This behavior is same as [Marp CLI](https://github.com/marp-team/marp-cli).
+
 ## Web extension
 
 You can use Marp extension in VS Code for the Web environment like [vscode.dev](https://vscode.dev) and [github.dev](https://github.dev). Try opening https://github.dev/marp-team/marp-vscode/blob/main/docs/example.md, with an environment that has installed Marp extension.
diff --git a/package.json b/package.json
index ac6d90c..ae1411b 100644
--- a/package.json
+++ b/package.json
@@ -215,6 +215,24 @@
             "Add outlines based on both slide pages and Markdown headings."
           ]
         },
+        "markdown.marp.pptx.editable": {
+          "type": "string",
+          "enum": [
+            "off",
+            "on",
+            "smart"
+          ],
+          "default": "off",
+          "markdownDescription": "Sets whether make editable or not when exporting to PowerPoint document. You should install both of the browser and [LibreOffice Impress](https://libreoffice.org/) to export the editable PPTX. Please note that the editable PPTX output may fail to convert or be generated with an incomplete layout depending on the slide content.",
+          "markdownEnumDescriptions": [
+            "Always export non-editable PPTX. It has full visual reproducibillity and presenter note support.",
+            "Always export editable PPTX. Please note that the editable PPTX may fail to convert or be generated with an incomplete layout depending on the slide content.",
+            "Try to export editable PPTX first, and fallback to non-editable PPTX if failed."
+          ],
+          "tags": [
+            "experimental"
+          ]
+        },
         "markdown.marp.strictPathResolutionDuringExport": {
           "type": "boolean",
           "default": false,
diff --git a/src/__mocks__/vscode.ts b/src/__mocks__/vscode.ts
index 3e731da..c491043 100644
--- a/src/__mocks__/vscode.ts
+++ b/src/__mocks__/vscode.ts
@@ -13,6 +13,7 @@ const defaultConf: MockedConf = {
   'markdown.marp.outlineExtension': true,
   'markdown.marp.pdf.noteAnnotations': false,
   'markdown.marp.pdf.outlines': 'off',
+  'markdown.marp.pptx.editable': 'off',
   'window.zoomLevel': 0,
 
   // Legacy
diff --git a/src/commands/export.ts b/src/commands/export.ts
index 4ab96d9..f567b65 100644
--- a/src/commands/export.ts
+++ b/src/commands/export.ts
@@ -138,62 +138,88 @@ export const doExport = async (uri: Uri, document: TextDocument) => {
         )
       }
 
-      // Run Marp CLI
-      const conf = await createConfigFile(document, {
-        allowLocalFiles: !proxyServer,
-        pdfNotes:
-          ouputExt === '.pdf' &&
-          marpConfiguration().get<boolean>('pdf.noteAnnotations'),
-      })
-
-      try {
-        await marpCli(
-          ['-c', conf.path, input.path, '-o', outputPath],
-          { baseUrl },
-          {
-            onCLIError: ({ error, codes }) => {
-              if (error.errorCode === codes.NOT_FOUND_BROWSER) {
-                // Throw error with user-friendly instructions based on the current configuration
-                const browserOption = marpConfiguration().get<string>('browser')
-                const suggestBrowsers: string[] = []
-
-                switch (browserOption) {
-                  case 'chrome':
-                    suggestBrowsers.push(
-                      ...[
-                        browsers.chrome,
-                        process.platform === 'linux' ? browsers.chromium : '',
-                      ].filter((b) => !!b),
+      const pptxEditableSmart =
+        ouputExt === '.pptx' &&
+        marpConfiguration().get<string>('pptx.editable') === 'smart'
+
+      const runMarpCli = async ({
+        pptxEditable,
+      }: {
+        pptxEditable?: boolean
+      }) => {
+        const conf = await createConfigFile(document, {
+          allowLocalFiles: !proxyServer,
+          pdfNotes:
+            ouputExt === '.pdf' &&
+            marpConfiguration().get<boolean>('pdf.noteAnnotations'),
+          pptxEditable,
+        })
+
+        try {
+          await marpCli(
+            ['-c', conf.path, input.path, '-o', outputPath],
+            { baseUrl },
+            {
+              onCLIError: ({ error, codes }) => {
+                switch (error.errorCode) {
+                  case codes.NOT_FOUND_BROWSER: {
+                    // Throw error with user-friendly instructions based on the current configuration
+                    const suggestBrowsers: string[] = []
+
+                    switch (marpConfiguration().get<string>('browser')) {
+                      case 'chrome':
+                        suggestBrowsers.push(
+                          ...[
+                            browsers.chrome,
+                            process.platform === 'linux'
+                              ? browsers.chromium
+                              : '',
+                          ].filter((b) => !!b),
+                        )
+                        break
+                      case 'edge':
+                        suggestBrowsers.push(browsers.edge)
+                        break
+                      case 'firefox':
+                        suggestBrowsers.push(browsers.firefox)
+                        break
+                      default:
+                        suggestBrowsers.push(
+                          ...[
+                            browsers.chrome,
+                            process.platform === 'linux'
+                              ? browsers.chromium
+                              : '',
+                            browsers.edge,
+                            browsers.firefox,
+                          ].filter((b) => !!b),
+                        )
+                    }
+
+                    throw new MarpCLIError(
+                      `It requires to install a suitable browser, ${suggestBrowsers
+                        .join(', ')
+                        .replace(/, ([^,]*)$/, ' or $1')} for exporting.`,
                     )
-                    break
-                  case 'edge':
-                    suggestBrowsers.push(browsers.edge)
-                    break
-                  case 'firefox':
-                    suggestBrowsers.push(browsers.firefox)
-                    break
-                  default:
-                    suggestBrowsers.push(
-                      ...[
-                        browsers.chrome,
-                        process.platform === 'linux' ? browsers.chromium : '',
-                        browsers.edge,
-                        browsers.firefox,
-                      ].filter((b) => !!b),
+                  }
+                  case codes.NOT_FOUND_SOFFICE:
+                    throw new MarpCLIError(
+                      'It requires to install LibreOffice Impress for exporting to the editable PowerPoint document.',
                     )
                 }
-
-                throw new MarpCLIError(
-                  `It requires to install a suitable browser, ${suggestBrowsers
-                    .join(', ')
-                    .replace(/, ([^,]*)$/, ' or $1')} for exporting.`,
-                )
-              }
+              },
             },
-          },
-        )
+          )
+        } catch (e) {
+          if (pptxEditableSmart && pptxEditable === true) {
+            return await runMarpCli({ pptxEditable: false })
+          }
+          throw e
+        } finally {
+          conf.cleanup()
+        }
 
-        const shouldOpen = marpConfiguration().get<boolean>('exportAutoOpen')!
+        const shouldOpen = marpConfiguration().get<boolean>('exportAutoOpen')
 
         if (outputToLocalFS && shouldOpen) {
           env.openExternal(uri)
@@ -214,9 +240,9 @@ export const doExport = async (uri: Uri, document: TextDocument) => {
             `Marp slide deck was successfully exported to ${uri.toString()}.`,
           )
         }
-      } finally {
-        conf.cleanup()
       }
+
+      await runMarpCli({ pptxEditable: pptxEditableSmart ? true : undefined })
     } catch (e) {
       window.showErrorMessage(
         `Failure to export${(() => {
diff --git a/src/option.ts b/src/option.ts
index d0e3e49..4b42eea 100644
--- a/src/option.ts
+++ b/src/option.ts
@@ -115,10 +115,23 @@ export const marpCoreOptionForCLI = async (
   {
     allowLocalFiles = true,
     pdfNotes,
-  }: { allowLocalFiles?: boolean; pdfNotes?: boolean } = {},
+    pptxEditable: _pptxEditable,
+  }: {
+    allowLocalFiles?: boolean
+    pdfNotes?: boolean
+    pptxEditable?: boolean
+  } = {},
 ) => {
   const confMdPreview = workspace.getConfiguration('markdown.preview', uri)
 
+  const pptxEditable = (() => {
+    if (_pptxEditable !== undefined) return _pptxEditable
+
+    const v = marpConfiguration().get<'off' | 'on' | 'smart'>('pptx.editable')
+    if (v === 'on') return true
+    if (v === 'off') return false
+  })()
+
   let browser = marpConfiguration().get<'auto' | 'chrome' | 'edge' | 'firefox'>(
     'browser',
   )
@@ -180,8 +193,9 @@ export const marpCoreOptionForCLI = async (
       },
       math: math(),
     },
+    pptxEditable,
     themeSet: [] as string[],
-  } as ConfigForCLI
+  } satisfies Config as ConfigForCLI
 
   const workspaceFolder = workspace.getWorkspaceFolder(uri)
   const parentFolder = uri.scheme === 'file' && path.dirname(uri.fsPath)


Test patch:
diff --git a/src/commands/export.test.ts b/src/commands/export.test.ts
index dc933a4..17da0f1 100644
--- a/src/commands/export.test.ts
+++ b/src/commands/export.test.ts
@@ -318,6 +318,62 @@ describe('#doExport', () => {
     })
   })
 
+  describe('when enabled markdown.marp.pptx.editable', () => {
+    let marpCliMock: jest.SpyInstance
+
+    beforeEach(() => {
+      marpCliMock = jest.spyOn(marpCli, 'default').mockImplementation()
+    })
+
+    afterEach(() => marpCliMock?.mockRestore())
+
+    it('enables pptxEditable option while exporting PPTX when the setting is "on"', async () => {
+      setConfiguration({ 'markdown.marp.pptx.editable': 'on' })
+
+      const optionGeneratorSpy = jest.spyOn(option, 'marpCoreOptionForCLI')
+      await exportModule.doExport(saveURI('file', 'pptx'), document)
+      expect(optionGeneratorSpy).toHaveBeenCalledTimes(1)
+
+      const ret = await optionGeneratorSpy.mock.results[0].value
+      expect(ret).toStrictEqual(expect.objectContaining({ pptxEditable: true }))
+    })
+
+    it('enables pptxEditable option while exporting PPTX when the setting is "smart"', async () => {
+      setConfiguration({ 'markdown.marp.pptx.editable': 'smart' })
+
+      const optionGeneratorSpy = jest.spyOn(option, 'marpCoreOptionForCLI')
+      await exportModule.doExport(saveURI('file', 'pptx'), document)
+
+      expect(marpCliMock).toHaveBeenCalledTimes(1)
+      expect(optionGeneratorSpy).toHaveBeenCalledTimes(1)
+
+      const ret = await optionGeneratorSpy.mock.results[0].value
+      expect(ret).toStrictEqual(expect.objectContaining({ pptxEditable: true }))
+    })
+
+    it('retries exporting with disabling pptxEditable option when the setting is "smart" and failed the first export', async () => {
+      setConfiguration({ 'markdown.marp.pptx.editable': 'smart' })
+
+      marpCliMock.mockRejectedValueOnce(new Error('Failed to export'))
+
+      const optionGeneratorSpy = jest.spyOn(option, 'marpCoreOptionForCLI')
+      await exportModule.doExport(saveURI('file', 'pptx'), document)
+
+      expect(marpCliMock).toHaveBeenCalledTimes(2)
+      expect(optionGeneratorSpy).toHaveBeenCalledTimes(2)
+
+      const first = await optionGeneratorSpy.mock.results[0].value
+      expect(first).toStrictEqual(
+        expect.objectContaining({ pptxEditable: true }),
+      )
+
+      const second = await optionGeneratorSpy.mock.results[1].value
+      expect(second).toStrictEqual(
+        expect.objectContaining({ pptxEditable: false }),
+      )
+    })
+  })
+
   describe('when CLI was thrown CLIError with BROWSER_NOT_FOUND error code', () => {
     it.each`
       browser      | platform    | expected
@@ -377,6 +433,35 @@ describe('#doExport', () => {
     )
   })
 
+  describe('when CLI was thrown CLIError with NOT_FOUND_SOFFICE error code', () => {
+    it('throws MarpCLIError with the user-friendly message to suggest installing LibreOffice', async () => {
+      const runMarpCLI = jest
+        .spyOn(marpCli, 'default')
+        .mockImplementation(async (_, __, opts) => {
+          opts?.onCLIError?.({
+            error: new marpCliModule.CLIError(
+              'mocked error',
+              marpCliModule.CLIErrorCode.NOT_FOUND_SOFFICE,
+            ),
+            codes: marpCliModule.CLIErrorCode,
+          })
+        })
+
+      try {
+        await exportModule.doExport(saveURI(), document)
+
+        expect(window.showErrorMessage).toHaveBeenCalledTimes(1)
+        expect(window.showErrorMessage).toHaveBeenCalledWith(
+          expect.stringContaining(
+            'It requires to install LibreOffice Impress for exporting to the editable PowerPoint document.',
+          ),
+        )
+      } finally {
+        runMarpCLI.mockRestore()
+      }
+    })
+  })
+
   describe('when the save path has non-file scheme', () => {
     it('exports the document into temporally path and copy it to the save path', async () => {
       const marpCliMock = jest.spyOn(marpCli, 'default').mockImplementation()