Repository: marp-team/marp-vscode
Pull request #278 (2021-08-21 13:37:30)

Add `markdown.marp.pdf.noteAnnotations` config
Added `markdown.marp.pdf.noteAnnotations` configuration to set whether add presenter notes to exported PDF as note annotations. If enabled, enables Marp CLI's `pdfNotes` option while exporting to PDF.

`pdfNotes: true` changes the priority of export types, so this configuration does never set when tried exporting to non-PDF types.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a72d5f7..27d8eb1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@
 - Auto completion for [`size` global directive](https://github.com/marp-team/marp-core#size-global-directive) ([#276](https://github.com/marp-team/marp-vscode/pull/276))
 - `unknown-size` diagnostic: Notify if the specified size preset was not defined in a theme ([#276](https://github.com/marp-team/marp-vscode/pull/276))
 - Auto-trigger suggestion for value of supported directives ([#277](https://github.com/marp-team/marp-vscode/pull/277))
+- `markdown.marp.pdf.noteAnnotations` config: Add presenter notes to exported PDF as note annotations ([#278](https://github.com/marp-team/marp-vscode/pull/278))
 
 ### Changed
 
diff --git a/package.json b/package.json
index aec5c74..e443d3b 100644
--- a/package.json
+++ b/package.json
@@ -163,6 +163,11 @@
           "default": true,
           "description": "Enables the outline extension for Marp Markdown. If enabled, VS Code's outline view will reflect slide splitters, and you can fold regions of the slide content in the editor."
         },
+        "markdown.marp.pdf.noteAnnotations": {
+          "type": "boolean",
+          "default": false,
+          "markdownDescription": "Adds [presenter notes](https://marpit.marp.app/usage?id=presenter-notes) to exported PDF as note annotations."
+        },
         "markdown.marp.themes": {
           "type": "array",
           "default": [],
diff --git a/src/__mocks__/vscode.ts b/src/__mocks__/vscode.ts
index f1cb9fa..e12c869 100644
--- a/src/__mocks__/vscode.ts
+++ b/src/__mocks__/vscode.ts
@@ -9,6 +9,7 @@ const defaultConf: MockedConf = {
   'markdown.marp.enableHtml': false,
   'markdown.marp.exportType': 'pdf',
   'markdown.marp.outlineExtension': true,
+  'markdown.marp.pdf.noteAnnotations': false,
   'window.zoomLevel': 0,
 }
 
diff --git a/src/commands/export.ts b/src/commands/export.ts
index ae36e84..eeaf60c 100644
--- a/src/commands/export.ts
+++ b/src/commands/export.ts
@@ -99,6 +99,8 @@ export const doExport = async (uri: Uri, document: TextDocument) => {
 
     try {
       let outputPath = uri.fsPath
+
+      const ouputExt = path.extname(uri.path)
       const outputToLocalFS = uri.scheme === 'file'
 
       // NOTE: It may return `undefined` if VS Code does not know about the
@@ -110,13 +112,16 @@ export const doExport = async (uri: Uri, document: TextDocument) => {
       if (!outputToLocalFS) {
         outputPath = path.join(
           os.tmpdir(),
-          `marp-vscode-tmp-${nanoid()}${path.extname(uri.path)}`
+          `marp-vscode-tmp-${nanoid()}${ouputExt}`
         )
       }
 
       // Run Marp CLI
       const conf = await createConfigFile(document, {
         allowLocalFiles: !proxyServer,
+        pdfNotes:
+          ouputExt === '.pdf' &&
+          marpConfiguration().get<boolean>('pdf.noteAnnotations'),
       })
 
       try {
@@ -168,16 +173,18 @@ export const saveDialog = async (document: TextDocument) => {
 
   // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
   const defaultType = marpConfiguration().get<string>('exportType')!
+  const ext = extensions[defaultType] ? `.${extensions[defaultType][0]}` : ''
   const baseTypes = Object.keys(extensions)
   const types = [...new Set<string>([defaultType, ...baseTypes])]
 
   const saveURI = await window.showSaveDialog({
-    defaultUri: Uri.file(fsPath.slice(0, -path.extname(fsPath).length)),
+    defaultUri: Uri.file(fsPath.slice(0, -path.extname(fsPath).length) + ext),
     filters: types.reduce((f, t) => {
       if (baseTypes.includes(t)) f[descriptions[t]] = extensions[t]
       return f
     }, {}),
     saveLabel: 'Export',
+    title: 'Export slide deck',
   })
 
   if (saveURI) {
diff --git a/src/option.ts b/src/option.ts
index bec05a2..77f6aba 100644
--- a/src/option.ts
+++ b/src/option.ts
@@ -56,12 +56,16 @@ export const marpCoreOptionForPreview = (
 
 export const marpCoreOptionForCLI = async (
   { uri }: TextDocument,
-  { allowLocalFiles = true }: { allowLocalFiles?: boolean } = {}
+  {
+    allowLocalFiles = true,
+    pdfNotes,
+  }: { allowLocalFiles?: boolean; pdfNotes?: boolean } = {}
 ) => {
   const confMdPreview = workspace.getConfiguration('markdown.preview', uri)
 
   const baseOpts = {
     allowLocalFiles,
+    pdfNotes,
     html: enableHtml() || undefined,
     options: {
       markdown: {


Test patch:
diff --git a/src/commands/export.test.ts b/src/commands/export.test.ts
index 08e2c25..b6f6bbd 100644
--- a/src/commands/export.test.ts
+++ b/src/commands/export.test.ts
@@ -1,5 +1,6 @@
 import { commands, env, window, workspace } from 'vscode'
 import * as marpCli from '../marp-cli'
+import * as option from '../option'
 import { createWorkspaceProxyServer } from '../workspace-proxy-server'
 import * as exportModule from './export'
 
@@ -111,7 +112,7 @@ describe('#saveDialog', () => {
 
     expect(window.showSaveDialog).toHaveBeenCalledWith(
       expect.objectContaining({
-        defaultUri: expect.objectContaining({ fsPath: '/tmp/test' }),
+        defaultUri: expect.objectContaining({ fsPath: '/tmp/test.pdf' }),
       })
     )
   })
@@ -122,7 +123,12 @@ describe('#saveDialog', () => {
     await exportModule.saveDialog(document)
     expect(window.showSaveDialog).toHaveBeenCalled()
 
-    const { filters } = (window.showSaveDialog as jest.Mock).mock.calls[0][0]
+    const { defaultUri, filters } = (window.showSaveDialog as jest.Mock).mock
+      .calls[0][0]
+
+    expect(defaultUri).toStrictEqual(
+      expect.objectContaining({ fsPath: '/tmp/test.pptx' })
+    )
     expect(Object.values(filters)[0]).toStrictEqual(['pptx'])
   })
 
@@ -149,43 +155,66 @@ describe('#saveDialog', () => {
 })
 
 describe('#doExport', () => {
-  const saveURI: any = {
-    scheme: 'file',
-    path: '/tmp/to.pdf',
-    fsPath: '/tmp/to.pdf',
+  const saveURI = (scheme = 'file', ext = 'pdf'): any => {
+    const instance = {
+      scheme,
+      path: `/tmp/to.${ext}`,
+      fsPath: `/tmp/to.${ext}`,
+      toString: () => `${scheme}://${instance.path}`,
+    }
+    return instance
   }
+
   const document: any = {
     uri: { scheme: 'file', path: '/tmp/md.md', fsPath: '/tmp/md.md' },
   }
 
   it('exports passed document via Marp CLI and opens it', async () => {
     const runMarpCLI = jest.spyOn(marpCli, 'default').mockImplementation()
+    const uri = saveURI()
 
-    await exportModule.doExport(saveURI, document)
+    await exportModule.doExport(uri, document)
     expect(runMarpCLI).toHaveBeenCalled()
-    expect(env.openExternal).toHaveBeenCalledWith(saveURI)
+    expect(env.openExternal).toHaveBeenCalledWith(uri)
   })
 
   it('shows warning when Marp CLI throws error', async () => {
     jest.spyOn(marpCli, 'default').mockRejectedValue(new Error('ERROR'))
 
-    await exportModule.doExport(saveURI, document)
+    await exportModule.doExport(saveURI(), document)
     expect(window.showErrorMessage).toHaveBeenCalledWith(
       expect.stringContaining('[Error] ERROR')
     )
   })
 
-  describe('when the save path has non-file scheme', () => {
-    const saveURI = (scheme: string, ext: string): any => {
-      const instance = {
-        scheme,
-        path: `/tmp/to.${ext}`,
-        fsPath: `/tmp/to.${ext}`,
-        toString: () => `${scheme}://${instance.path}`,
-      }
-      return instance
-    }
+  describe('when enabled markdown.marp.pdf.noteAnnotations', () => {
+    beforeEach(() => {
+      setConfiguration({ 'markdown.marp.pdf.noteAnnotations': true })
+      jest.spyOn(marpCli, 'default').mockImplementation()
+    })
+
+    it('enables pdfNotes option while exporting PDF', async () => {
+      const optionGeneratorSpy = jest.spyOn(option, 'marpCoreOptionForCLI')
+      await exportModule.doExport(saveURI('file', 'pdf'), document)
 
+      expect(optionGeneratorSpy).toHaveBeenCalledWith(
+        document,
+        expect.objectContaining({ pdfNotes: true })
+      )
+    })
+
+    it('disables pdfNotes option while exporting to other extensions', async () => {
+      const optionGeneratorSpy = jest.spyOn(option, 'marpCoreOptionForCLI')
+      await exportModule.doExport(saveURI('file', 'pptx'), document)
+
+      expect(optionGeneratorSpy).toHaveBeenCalledWith(
+        document,
+        expect.objectContaining({ pdfNotes: false })
+      )
+    })
+  })
+
+  describe('when the save path has non-file scheme', () => {
     it('exports the document into temporally path and copy it to the save path', async () => {
       jest.spyOn(marpCli, 'default').mockImplementation()
 
@@ -253,7 +282,7 @@ describe('#doExport', () => {
         .spyOn(workspace, 'getWorkspaceFolder')
         .mockReturnValue(virtualWorkspace)
 
-      await exportModule.doExport(saveURI, vfsDocument)
+      await exportModule.doExport(saveURI(), vfsDocument)
       expect(createWorkspaceProxyServer).toHaveBeenCalledWith(virtualWorkspace)
 
       // dispose method
@@ -264,18 +293,12 @@ describe('#doExport', () => {
     })
 
     it('does not open workspace proxy server while exporting to html', async () => {
-      const saveURIHtml: any = {
-        scheme: 'file',
-        path: '/tmp/to.html',
-        fsPath: '/tmp/to.html',
-      }
-
       jest.spyOn(marpCli, 'default').mockImplementation()
       jest
         .spyOn(workspace, 'getWorkspaceFolder')
         .mockReturnValue(virtualWorkspace)
 
-      await exportModule.doExport(saveURIHtml, vfsDocument)
+      await exportModule.doExport(saveURI('file', 'html'), vfsDocument)
       expect(createWorkspaceProxyServer).not.toHaveBeenCalled()
     })
   })
diff --git a/src/option.test.ts b/src/option.test.ts
index bb5d26a..df239a4 100644
--- a/src/option.test.ts
+++ b/src/option.test.ts
@@ -23,8 +23,16 @@ describe('Option', () => {
     it('returns basic options', async () => {
       const opts = await subject({ uri: untitledUri })
 
-      // --allow-local-files
       expect(opts.allowLocalFiles).toBe(true)
+      expect(opts.pdfNotes).toBeUndefined()
+
+      const custom = await subject(
+        { uri: untitledUri },
+        { allowLocalFiles: false, pdfNotes: true }
+      )
+
+      expect(custom.allowLocalFiles).toBe(false)
+      expect(custom.pdfNotes).toBe(true)
     })
 
     it('enables HTML by preference', async () => {