diff --git a/.circleci/config.yml b/.circleci/config.yml
index 5448e178..7f43abba 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -24,6 +24,12 @@ commands:
type: steps
default: []
steps:
+ - run:
+ name: Set up LibreOffice
+ command: |
+ sudo apt-get update
+ sudo apt-get install -y libreoffice-common libreoffice-java-common libreoffice-impress default-jre
+
- restore_cache:
keys:
- v3-dependencies-{{ .Environment.CIRCLE_JOB }}-{{ checksum "package-lock.json" }}-{{ .Branch }}
diff --git a/.github/workflows/test-win.yml b/.github/workflows/test-win.yml
index 68475e57..c08745d3 100644
--- a/.github/workflows/test-win.yml
+++ b/.github/workflows/test-win.yml
@@ -41,6 +41,9 @@ jobs:
npm ci
npx patch-package
+ - name: Set up LibreOffice
+ run: choco install libreoffice-fresh -y
+
# Retry tests up to 3 times due to flaky tests on Windows CI
# https://stackoverflow.com/a/59365905
- name: Jest
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 15f669bc..e6a29b2c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
### Added
- Introduce parallelism for batch conversion: `--parallel` / `-P` ([#509](https://github.com/marp-team/marp-cli/issues/509), [#628](https://github.com/marp-team/marp-cli/pull/628), [#629](https://github.com/marp-team/marp-cli/pull/629))
+- _[Experimental]_ `--pptx-editable` option to convert Markdown into editable PPTX ([#166](https://github.com/marp-team/marp-cli/issues/166), [#298](https://github.com/marp-team/marp-cli/issues/298), [#626](https://github.com/marp-team/marp-cli/pull/626))
### Fixed
diff --git a/README.md b/README.md
index 1c92ea41..afd920da 100644
--- a/README.md
+++ b/README.md
@@ -185,9 +185,23 @@ A created PPTX includes rendered Marp slide pages and the support of [Marpit pre
+#### _[EXPERIMENTAL]_ Generate editable pptx (`--pptx-editable`)
+
+A converted PPTX usually consists of pre-rendered background images, that is meaning **contents cannot to modify or re-use** in PowerPoint. If you want to generate editable PPTX for modifying texts, shapes, and images in GUI, you can pass `--pptx-editable` option together with `--pptx` option.
+
+```bash
+marp --pptx --pptx-editable slide-deck.md
+```
+
+> [!IMPORTANT]
+>
+> The experimental `--pptx-editable` option requires installing both of the browser and [LibreOffice Impress](https://www.libreoffice.org/).
+>
+> If the theme and inline styles are providing complex styles into the slide, **`--pptx-editable` may throw an error or output the incomplete result.** (e.g. `gaia` theme in Marp Core)
+
> [!WARNING]
>
-> A converted PPTX consists of pre-rendered images. Please note that **contents would not be able to modify** or re-use in PowerPoint.
+> Conversion to the editable PPTX results in **lower slide reproducibility compared to the conversion into regular PPTX and other formats.** Additionally, **presenter notes are not supported.** _We do not recommend to export the editable PPTX if maintaining the slide's appearance is important._
### Convert to PNG/JPEG image(s) 🌐
@@ -659,6 +673,7 @@ If you want to prevent looking up a configuration file, you can pass `--no-confi
| ┗ `pages` | boolean | `--pdf-outlines.pages` | Make PDF outlines from slide pages (`true` by default when `pdfOutlines` is enabled) |
| ┗ `headings` | boolean | `--pdf-outlines.headings` | Make PDF outlines from Markdown headings (`true` by default when `pdfOutlines` is enabled) |
| `pptx` | boolean | `--pptx` | Convert slide deck into PowerPoint document |
+| `pptxEditable` | boolean | `--pptx-editable` | _[EXPERIMENTAL]_ Generate editable PPTX when converting to PPTX |
| `preview` | boolean | `--preview` `-p` | Open preview window |
| `server` | boolean | `--server` `-s` | Enable server mode |
| `template` | `bare` \| `bespoke` | `--template` | Choose template (`bespoke` by default) |
@@ -701,7 +716,7 @@ This configuration will set the constructor option for Marp Core as specified:
### Auto completion
-For getting the power of auto completion for the config, such as [IntelliSense](https://code.visualstudio.com/docs/editor/intellisense), you can annotate the config object through JSDoc, with Marp CLI's `Config` type.
+When Marp CLI has been installed into the local project, for getting the power of auto completion for the config, such as [IntelliSense](https://code.visualstudio.com/docs/editor/intellisense), you can annotate the config object through JSDoc, with Marp CLI's `Config` type.
```javascript
/** @type {import('@marp-team/marp-cli').Config} */
@@ -712,7 +727,7 @@ const config = {
export default config
```
-Or you can use Vite-like `defineConfig` helper from Marp CLI instead.
+Or you can import Vite-like `defineConfig` helper from Marp CLI instead.
```javascript
import { defineConfig } from '@marp-team/marp-cli'
diff --git a/src/browser/browsers/chrome.ts b/src/browser/browsers/chrome.ts
index 9b7e4313..0a98af6e 100644
--- a/src/browser/browsers/chrome.ts
+++ b/src/browser/browsers/chrome.ts
@@ -2,10 +2,10 @@ import { launch } from 'puppeteer-core'
import type { Browser as PuppeteerBrowser, LaunchOptions } from 'puppeteer-core'
import { CLIErrorCode, error, isError } from '../../error'
import { isInsideContainer } from '../../utils/container'
+import { isSnapBrowser } from '../../utils/finder'
import { isWSL } from '../../utils/wsl'
import { Browser } from '../browser'
import type { BrowserKind, BrowserProtocol } from '../browser'
-import { isSnapBrowser } from '../finders/utils'
export class ChromeBrowser extends Browser {
static readonly kind: BrowserKind = 'chrome'
diff --git a/src/browser/finder.ts b/src/browser/finder.ts
index 340d2be0..a754e40a 100644
--- a/src/browser/finder.ts
+++ b/src/browser/finder.ts
@@ -1,10 +1,10 @@
import { CLIError, CLIErrorCode } from '../error'
import { debugBrowserFinder } from '../utils/debug'
+import { isExecutable, normalizeDarwinAppPath } from '../utils/finder'
import type { Browser } from './browser'
import { chromeFinder as chrome } from './finders/chrome'
import { edgeFinder as edge } from './finders/edge'
import { firefoxFinder as firefox } from './finders/firefox'
-import { isExecutable, normalizeDarwinAppPath } from './finders/utils'
export interface BrowserFinderResult {
path: string
diff --git a/src/browser/finders/chrome.ts b/src/browser/finders/chrome.ts
index ce871e33..0ff42540 100644
--- a/src/browser/finders/chrome.ts
+++ b/src/browser/finders/chrome.ts
@@ -5,16 +5,16 @@ import {
wsl,
} from 'chrome-launcher/dist/chrome-finder'
import { error, CLIErrorCode } from '../../error'
-import { getWSL2NetworkingMode } from '../../utils/wsl'
-import { ChromeBrowser } from '../browsers/chrome'
-import { ChromeCdpBrowser } from '../browsers/chrome-cdp'
-import type { BrowserFinder, BrowserFinderResult } from '../finder'
import {
findExecutableBinary,
getPlatform,
isExecutable,
normalizeDarwinAppPath,
-} from './utils'
+} from '../../utils/finder'
+import { getWSL2NetworkingMode } from '../../utils/wsl'
+import { ChromeBrowser } from '../browsers/chrome'
+import { ChromeCdpBrowser } from '../browsers/chrome-cdp'
+import type { BrowserFinder, BrowserFinderResult } from '../finder'
const chrome = (path: string): BrowserFinderResult => ({
path,
diff --git a/src/browser/finders/edge.ts b/src/browser/finders/edge.ts
index dc5e68e7..34ccb28e 100644
--- a/src/browser/finders/edge.ts
+++ b/src/browser/finders/edge.ts
@@ -1,5 +1,6 @@
import path from 'node:path'
import { error, CLIErrorCode } from '../../error'
+import { findExecutable, getPlatform } from '../../utils/finder'
import {
translateWindowsPathToWSL,
getWindowsEnv,
@@ -8,7 +9,6 @@ import {
import { ChromeBrowser } from '../browsers/chrome'
import { ChromeCdpBrowser } from '../browsers/chrome-cdp'
import type { BrowserFinder, BrowserFinderResult } from '../finder'
-import { findExecutable, getPlatform } from './utils'
const edge = (path: string): BrowserFinderResult => ({
path,
diff --git a/src/browser/finders/firefox.ts b/src/browser/finders/firefox.ts
index 55ca00e9..f59922eb 100644
--- a/src/browser/finders/firefox.ts
+++ b/src/browser/finders/firefox.ts
@@ -1,15 +1,15 @@
import path from 'node:path'
import { error, CLIErrorCode } from '../../error'
// import { getWSL2NetworkingMode } from '../../utils/wsl'
-import { FirefoxBrowser } from '../browsers/firefox'
-import type { BrowserFinder, BrowserFinderResult } from '../finder'
import {
getPlatform,
findExecutable,
findExecutableBinary,
isExecutable,
normalizeDarwinAppPath,
-} from './utils'
+} from '../../utils/finder'
+import { FirefoxBrowser } from '../browsers/firefox'
+import type { BrowserFinder, BrowserFinderResult } from '../finder'
const firefox = (path: string): BrowserFinderResult => ({
path,
diff --git a/src/config.ts b/src/config.ts
index 13978ab6..ecd4889c 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -14,7 +14,7 @@ import { error, isError } from './error'
import { TemplateOption } from './templates'
import { Theme, ThemeSet } from './theme'
import { isStandaloneBinary } from './utils/binary'
-import { isOfficialDockerImage } from './utils/container'
+import { isOfficialContainerImage } from './utils/container'
type Overwrite = Omit> & U
@@ -50,6 +50,7 @@ interface IMarpCLIArguments {
'pdfOutlines.pages'?: boolean
'pdfOutlines.headings'?: boolean
pptx?: boolean
+ pptxEditable?: boolean
preview?: boolean
server?: boolean
template?: string
@@ -213,7 +214,7 @@ export class MarpCLIConfig {
const preview = (() => {
const p = this.args.preview ?? this.conf.preview ?? false
- if (p && isOfficialDockerImage()) {
+ if (p && isOfficialContainerImage()) {
warn(
`Preview window cannot show within an official docker image. Preview option was ignored.`
)
@@ -282,6 +283,8 @@ export class MarpCLIConfig {
})()
: false
+ const pptxEditable = !!(this.args.pptxEditable || this.conf.pptxEditable)
+
const type = ((): ConvertType => {
// CLI options
if (this.args.pdf || this.conf.pdf) return ConvertType.pdf
@@ -313,6 +316,11 @@ export class MarpCLIConfig {
if (pdfNotes || pdfOutlines) return ConvertType.pdf
}
+ // Prefer PPTX than HTML if enabled PPTX option
+ if ((this.args.pptx ?? this.conf.pptx) !== false) {
+ if (pptxEditable) return ConvertType.pptx
+ }
+
return ConvertType.html
})()
@@ -358,6 +366,7 @@ export class MarpCLIConfig {
parallel,
pdfNotes,
pdfOutlines,
+ pptxEditable,
preview,
server,
template,
diff --git a/src/converter.ts b/src/converter.ts
index 89d5ac3d..d193bac5 100644
--- a/src/converter.ts
+++ b/src/converter.ts
@@ -1,3 +1,4 @@
+import path from 'node:path'
import { URL } from 'node:url'
import type { Marp, MarpOptions } from '@marp-team/marp-core'
import { Marpit, Options as MarpitOptions } from '@marp-team/marpit'
@@ -26,6 +27,7 @@ import {
import { engineTransition, EngineTransition } from './engine/transition-plugin'
import { error } from './error'
import { File, FileType } from './file'
+import { SOffice } from './soffice/soffice'
import templates, {
bare,
Template,
@@ -35,6 +37,7 @@ import templates, {
} from './templates/'
import { ThemeSet } from './theme'
import { debug } from './utils/debug'
+import { isReadable } from './utils/finder'
import { png2jpegViaPuppeteer } from './utils/jpeg'
import { pdfLib, setOutline } from './utils/pdf'
import { translateWSLPathToWindows } from './utils/wsl'
@@ -82,6 +85,7 @@ export interface ConverterOption {
pages: boolean
headings: boolean
}
+ pptxEditable?: boolean
preview?: boolean
jpegQuality?: number
server?: boolean
@@ -97,6 +101,10 @@ export interface ConvertFileOption {
onlyScanning?: boolean
}
+export interface ConvertPDFOption {
+ postprocess?: boolean
+}
+
export interface ConvertImageOption {
pages?: boolean | number[]
quality?: number
@@ -118,7 +126,9 @@ const stripBOM = (s: string) => (s.charCodeAt(0) === 0xfeff ? s.slice(1) : s)
export class Converter {
readonly options: ConverterOption
+ private _sOffice: SOffice | undefined = undefined
private _firefoxPDFConversionWarning = false
+ private _experimentalEditablePPTXWarning = false
constructor(opts: ConverterOption) {
this.options = opts
@@ -135,6 +145,11 @@ export class Converter {
return template
}
+ get sOffice(): SOffice {
+ if (!this._sOffice) this._sOffice = new SOffice()
+ return this._sOffice
+ }
+
async convert(
markdown: string,
file?: File,
@@ -235,9 +250,11 @@ export class Converter {
case ConvertType.pptx:
template = await useTemplate(true)
files.push(
- await this.convertFileToPPTX(template, file, {
- scale: this.options.imageScale ?? 2,
- })
+ this.options.pptxEditable
+ ? await this.convertFileToEditablePPTX(template, file)
+ : await this.convertFileToPPTX(template, file, {
+ scale: this.options.imageScale ?? 2,
+ })
)
break
case ConvertType.notes:
@@ -299,7 +316,8 @@ export class Converter {
private convertFileToNotes(tpl: TemplateResult, file: File): File {
const ret = file.convert(this.options.output, { extension: 'txt' })
- const comments = tpl.rendered.comments
+ const { comments } = tpl.rendered
+
if (comments.flat().length === 0) {
warn(`${file.relativePath()} contains no notes.`)
ret.buffer = Buffer.from('')
@@ -308,12 +326,14 @@ export class Converter {
comments.map((c) => c.join('\n\n')).join('\n\n---\n\n')
)
}
+
return ret
}
private async convertFileToPDF(
tpl: TemplateResult,
- file: File
+ file: File,
+ { postprocess = true }: ConvertPDFOption = {}
): Promise {
const html = new File(file.absolutePath)
html.buffer = Buffer.from(tpl.result)
@@ -327,7 +347,7 @@ export class Converter {
this._firefoxPDFConversionWarning = true
warn(
- 'Using Firefox to convert Markdown to PDF: The output may include some incompatible renderings compared to the PDF generated by Chrome.'
+ 'Using Firefox to convert Markdown: The output may include some incompatible renderings compared to the output generated by Chrome.'
)
}
@@ -354,61 +374,63 @@ export class Converter {
})
)
- // Apply PDF metadata and annotations
- const creationDate = new Date()
- const { PDFDocument, PDFHexString, PDFString } = await pdfLib()
- const pdfDoc = await PDFDocument.load(ret.buffer)
-
- pdfDoc.setCreator(CREATED_BY_MARP)
- pdfDoc.setProducer(CREATED_BY_MARP)
- pdfDoc.setCreationDate(creationDate)
- pdfDoc.setModificationDate(creationDate)
-
- if (tpl.rendered.title) pdfDoc.setTitle(tpl.rendered.title)
- if (tpl.rendered.description) pdfDoc.setSubject(tpl.rendered.description)
- if (tpl.rendered.author) pdfDoc.setAuthor(tpl.rendered.author)
- if (tpl.rendered.keywords)
- pdfDoc.setKeywords([tpl.rendered.keywords.join('; ')])
-
- if (this.options.pdfOutlines && tpl.rendered.outline) {
- await setOutline(
- pdfDoc,
- generatePDFOutlines(tpl.rendered.outline, {
- ...this.options.pdfOutlines,
- data: outlineData,
- size: tpl.rendered.size,
- })
- )
- }
-
- if (this.options.pdfNotes) {
- const pages = pdfDoc.getPages()
-
- for (let i = 0, len = pages.length; i < len; i += 1) {
- const notes = tpl.rendered.comments[i].join('\n\n')
-
- if (notes) {
- const noteAnnot = pdfDoc.context.obj({
- Type: 'Annot',
- Subtype: 'Text',
- Rect: [0, 20, 20, 20],
- Contents: PDFHexString.fromText(notes),
- T: tpl.rendered.author
- ? PDFHexString.fromText(tpl.rendered.author)
- : undefined,
- Name: 'Note',
- Subj: PDFString.of('Note'),
- C: [1, 0.92, 0.42], // RGB
- CA: 0.25, // Alpha
+ if (postprocess) {
+ // Apply PDF metadata and annotations
+ const creationDate = new Date()
+ const { PDFDocument, PDFHexString, PDFString } = await pdfLib()
+ const pdfDoc = await PDFDocument.load(ret.buffer)
+
+ pdfDoc.setCreator(CREATED_BY_MARP)
+ pdfDoc.setProducer(CREATED_BY_MARP)
+ pdfDoc.setCreationDate(creationDate)
+ pdfDoc.setModificationDate(creationDate)
+
+ if (tpl.rendered.title) pdfDoc.setTitle(tpl.rendered.title)
+ if (tpl.rendered.description) pdfDoc.setSubject(tpl.rendered.description)
+ if (tpl.rendered.author) pdfDoc.setAuthor(tpl.rendered.author)
+ if (tpl.rendered.keywords)
+ pdfDoc.setKeywords([tpl.rendered.keywords.join('; ')])
+
+ if (this.options.pdfOutlines && tpl.rendered.outline) {
+ await setOutline(
+ pdfDoc,
+ generatePDFOutlines(tpl.rendered.outline, {
+ ...this.options.pdfOutlines,
+ data: outlineData,
+ size: tpl.rendered.size,
})
+ )
+ }
+
+ if (this.options.pdfNotes) {
+ const pages = pdfDoc.getPages()
+
+ for (let i = 0, len = pages.length; i < len; i += 1) {
+ const notes = tpl.rendered.comments[i].join('\n\n')
+
+ if (notes) {
+ const noteAnnot = pdfDoc.context.obj({
+ Type: 'Annot',
+ Subtype: 'Text',
+ Rect: [0, 20, 20, 20],
+ Contents: PDFHexString.fromText(notes),
+ T: tpl.rendered.author
+ ? PDFHexString.fromText(tpl.rendered.author)
+ : undefined,
+ Name: 'Note',
+ Subj: PDFString.of('Note'),
+ C: [1, 0.92, 0.42], // RGB
+ CA: 0.25, // Alpha
+ })
- pages[i].node.addAnnot(pdfDoc.context.register(noteAnnot))
+ pages[i].node.addAnnot(pdfDoc.context.register(noteAnnot))
+ }
}
}
- }
- // Apply modified PDF to buffer
- ret.buffer = Buffer.from(await pdfDoc.save())
+ // Apply modified PDF to buffer
+ ret.buffer = Buffer.from(await pdfDoc.save())
+ }
return ret
}
@@ -575,6 +597,52 @@ export class Converter {
return ret
}
+ private async convertFileToEditablePPTX(
+ tpl: TemplateResult,
+ file: File
+ ): Promise {
+ // Experimental warning
+ if (this._experimentalEditablePPTXWarning === false) {
+ this._experimentalEditablePPTXWarning = true
+
+ warn(
+ `${chalk.yellow`[EXPERIMENTAL]`} Converting to editable PPTX is experimental feature. The output depends on LibreOffice and slide reproducibility is not fully guaranteed.`
+ )
+ }
+
+ // Convert to PDF
+ const pdf = await this.convertFileToPDF(tpl, file, { postprocess: false })
+
+ // Convert PDF to PPTX
+ await using tmpPdfFile = await pdf.saveTmpFile({ extension: '.pdf' })
+
+ await this.sOffice.spawn([
+ '--nolockcheck',
+ '--nologo',
+ '--headless',
+ '--norestore',
+ '--nofirststartwizard',
+ '--infilter=impress_pdf_import',
+ '--convert-to',
+ 'pptx:Impress Office Open XML:UTF8',
+ '--outdir',
+ path.dirname(tmpPdfFile.path),
+ tmpPdfFile.path,
+ ])
+
+ const tmpPptxFile = new File(`${tmpPdfFile.path.slice(0, -4)}.pptx`)
+
+ // soffice does not return the error exit code when the conversion fails, so we need to check the existence of the output file
+ if (!(await isReadable(tmpPptxFile.path))) {
+ error('LibreOffice could not convert PPTX internally.')
+ }
+
+ const ret = file.convert(this.options.output, { extension: 'pptx' })
+ ret.buffer = await tmpPptxFile.load()
+
+ return ret
+ }
+
private async generateEngine(
mergeOptions: MarpitOptions
): Promise {
diff --git a/src/error.ts b/src/error.ts
index 0d89f6aa..aa83abd9 100644
--- a/src/error.ts
+++ b/src/error.ts
@@ -22,6 +22,7 @@ export const CLIErrorCode = {
NOT_FOUND_BROWSER: 2,
LISTEN_PORT_IS_ALREADY_USED: 3,
CANNOT_SPAWN_SNAP_CHROMIUM: 4,
+ NOT_FOUND_SOFFICE: 5,
/** @deprecated NOT_FOUND_CHROMIUM is renamed to NOT_FOUND_BROWSER. */
NOT_FOUND_CHROMIUM: 2,
diff --git a/src/file.ts b/src/file.ts
index e4353085..8e79f168 100644
--- a/src/file.ts
+++ b/src/file.ts
@@ -9,6 +9,37 @@ import { generateTmpName } from './utils/tmp'
export const markdownExtensions = ['md', 'mdown', 'markdown', 'markdn']
+interface GenerateTmpFileInterfaceOptions {
+ extension?: `.${string}`
+}
+
+export const generateTmpFileInterface = async ({
+ extension,
+}: GenerateTmpFileInterfaceOptions = {}): Promise => {
+ const tmp = await generateTmpName(extension)
+
+ let cleaned = false
+
+ const cleanup = async () => {
+ if (cleaned) return
+
+ try {
+ await fs.promises.unlink(tmp)
+ debug('Cleaned up temporary file: %s', tmp)
+ cleaned = true
+ } catch (e) {
+ debug('Failed to clean up temporary file: %o', e)
+ }
+ }
+
+ return {
+ path: tmp,
+ cleanup,
+ [Symbol.dispose]: () => void cleanup(),
+ [Symbol.asyncDispose]: cleanup,
+ }
+}
+
export interface FileConvertOption {
extension?: string
page?: number
@@ -92,31 +123,13 @@ export class File {
async saveTmpFile({
extension,
- }: { extension?: `.${string}` } = {}): Promise {
- const tmp = await generateTmpName(extension)
-
- debug('Saving temporary file: %s', tmp)
- await this.saveToFile(tmp)
+ }: GenerateTmpFileInterfaceOptions = {}): Promise {
+ const tmp = await generateTmpFileInterface({ extension })
- const cleanup = async () => {
- try {
- await this.cleanup(tmp)
- } catch (e) {
- debug('Failed to clean up temporary file: %o', e)
- }
- }
-
- return {
- path: tmp,
- cleanup,
- [Symbol.dispose]: () => void cleanup(),
- [Symbol.asyncDispose]: cleanup,
- }
- }
+ debug('Saving temporary file: %s', tmp.path)
+ await this.saveToFile(tmp.path)
- private async cleanup(tmpPath: string) {
- await fs.promises.unlink(tmpPath)
- debug('Cleaned up temporary file: %s', tmpPath)
+ return tmp
}
private convertName(
diff --git a/src/marp-cli.ts b/src/marp-cli.ts
index 1ef4b1c4..b278d955 100644
--- a/src/marp-cli.ts
+++ b/src/marp-cli.ts
@@ -9,7 +9,7 @@ import { File, FileType } from './file'
import { Preview, fileToURI } from './preview'
import { Server } from './server'
import templates from './templates'
-import { isOfficialDockerImage } from './utils/container'
+import { isOfficialContainerImage } from './utils/container'
import { createYargs } from './utils/yargs'
import version from './version'
import watcher, { Watcher, notifier } from './watcher'
@@ -199,7 +199,7 @@ export const marpCli = async (
preview: {
alias: 'p',
describe: 'Open preview window',
- hidden: isOfficialDockerImage(),
+ hidden: isOfficialContainerImage(),
group: OptionGroup.Basic,
type: 'boolean',
},
@@ -222,6 +222,13 @@ export const marpCli = async (
group: OptionGroup.Converter,
type: 'boolean',
},
+ 'pptx-editable': {
+ describe:
+ '[EXPERIMENTAL] Generate editable PPTX when converting to PPTX',
+ group: OptionGroup.Converter,
+ hidden: isOfficialContainerImage(), // Container image does not include LibreOffice Impress
+ type: 'boolean',
+ },
notes: {
conflicts: ['image', 'images', 'pptx', 'pdf'],
describe: 'Convert slide deck notes into a text file',
diff --git a/src/soffice/finder.ts b/src/soffice/finder.ts
new file mode 100644
index 00000000..964eeefd
--- /dev/null
+++ b/src/soffice/finder.ts
@@ -0,0 +1,121 @@
+import path from 'node:path'
+import { CLIErrorCode, error } from '../error'
+import {
+ findExecutable,
+ findExecutableBinary,
+ getPlatform,
+ isExecutable,
+ normalizeDarwinAppPath,
+} from '../utils/finder'
+import { getWSL2NetworkingMode } from '../utils/wsl'
+
+const sOffice = (path: string) => ({ path }) as const
+
+export const findSOffice = async ({
+ preferredPath,
+}: { preferredPath?: string } = {}) => {
+ if (preferredPath) return sOffice(preferredPath)
+
+ if (process.env.SOFFICE_PATH) {
+ const nPath = await normalizeDarwinAppPath(process.env.SOFFICE_PATH)
+ if (nPath && (await isExecutable(nPath))) return sOffice(nPath)
+ }
+
+ const platform = await getPlatform()
+ const installation = await (async () => {
+ switch (platform) {
+ case 'darwin':
+ return await sOfficeFinderDarwin()
+ case 'win32':
+ return await sOfficeFinderWin32()
+ case 'wsl1':
+ return await sOfficeFinderWSL()
+ }
+
+ return await sOfficeFinderLinuxOrFallback()
+ })()
+
+ if (installation) return sOffice(installation)
+
+ error(
+ 'LibreOffice soffice binary could not be found.',
+ CLIErrorCode.NOT_FOUND_SOFFICE
+ )
+}
+
+const sOfficeFinderDarwin = async () =>
+ await findExecutable(['/Applications/LibreOffice.app/Contents/MacOS/soffice'])
+
+const sOfficeFinderWin32 = async () => {
+ const prefixes: string[] = []
+
+ const winDriveMatcher = /^[a-z]:\\/i
+ const winPossibleDrives = () => {
+ const possibleDriveSet = new Set(['c'])
+ const pathEnvs = process.env.PATH?.split(';') ?? []
+
+ for (const pathEnv of pathEnvs) {
+ if (winDriveMatcher.test(pathEnv)) {
+ possibleDriveSet.add(pathEnv[0].toLowerCase())
+ }
+ }
+
+ return Array.from(possibleDriveSet).sort()
+ }
+
+ for (const drive of winPossibleDrives()) {
+ for (const prefix of [
+ process.env.PROGRAMFILES,
+ process.env['PROGRAMFILES(X86)'],
+ ]) {
+ if (!prefix) continue
+ prefixes.push(`${drive}${prefix.slice(1)}`)
+ }
+ }
+
+ return await findExecutable(
+ prefixes.map((prefix) =>
+ path.join(prefix, 'LibreOffice', 'program', 'soffice.exe')
+ )
+ )
+}
+
+const sOfficeFinderWSL = async () => {
+ const prefixes: string[] = []
+
+ const winDriveMatcher = /^\/mnt\/[a-z]\//i
+ const winPossibleDrives = () => {
+ const possibleDriveSet = new Set(['c'])
+ const pathEnvs = process.env.PATH?.split(':') ?? []
+
+ for (const pathEnv of pathEnvs) {
+ if (winDriveMatcher.test(pathEnv)) {
+ possibleDriveSet.add(pathEnv[5].toLowerCase())
+ }
+ }
+
+ return Array.from(possibleDriveSet).sort()
+ }
+
+ for (const drive of winPossibleDrives()) {
+ prefixes.push(`/mnt/${drive}/Program Files`)
+ prefixes.push(`/mnt/${drive}/Program Files (x86)`)
+ }
+
+ return await findExecutable(
+ prefixes.map((prefix) =>
+ path.posix.join(prefix, 'LibreOffice', 'program', 'soffice.exe')
+ )
+ )
+}
+
+const sOfficeFinderLinuxOrFallback = async () => {
+ const ret = await findExecutableBinary(['soffice'])
+ if (ret) return ret
+
+ // WSL2 Fallback
+ if ((await getWSL2NetworkingMode()) === 'mirrored')
+ return await sOfficeFinderWSL()
+
+ return undefined
+}
diff --git a/src/soffice/soffice.ts b/src/soffice/soffice.ts
new file mode 100644
index 00000000..f33992d0
--- /dev/null
+++ b/src/soffice/soffice.ts
@@ -0,0 +1,123 @@
+import { spawn } from 'node:child_process'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { pathToFileURL } from 'node:url'
+import chalk from 'chalk'
+import { nanoid } from 'nanoid'
+import { error } from '../cli'
+import { debug } from '../utils/debug'
+import { createMemoizedPromiseContext } from '../utils/memoized-promise'
+import { getWindowsEnv, isWSL, translateWindowsPathToWSL } from '../utils/wsl'
+import { findSOffice } from './finder'
+
+const wslHostMatcher = /^\/mnt\/[a-z]\//
+
+let wslTmp: string | undefined
+
+export interface SOfficeOptions {
+ path?: string
+}
+
+export class SOffice {
+ preferredPath?: string
+ #profileDirName: string
+
+ private _path = createMemoizedPromiseContext()
+ private _profileDir = createMemoizedPromiseContext()
+
+ private static _spawnQueue: Promise = Promise.resolve()
+
+ constructor(opts: SOfficeOptions = {}) {
+ this.#profileDirName = `marp-cli-soffice-${nanoid(10)}`
+ this.preferredPath = opts.path
+ }
+
+ get path(): Promise {
+ return this._path.init(
+ async () =>
+ (await findSOffice({ preferredPath: this.preferredPath })).path
+ )
+ }
+
+ get profileDir(): Promise {
+ return this._profileDir.init(async () => await this.setProfileDir())
+ }
+
+ async spawn(args: string[]) {
+ return new Promise((resolve, reject) => {
+ SOffice._spawnQueue = SOffice._spawnQueue
+ .then(async () => {
+ const spawnArgs = [
+ `-env:UserInstallation=${pathToFileURL(await this.profileDir).toString()}`,
+ ...args,
+ ]
+
+ debug(`[soffice] Spawning soffice with args: %o`, spawnArgs)
+
+ const childProcess = spawn(await this.path, spawnArgs, {
+ stdio: 'pipe',
+ })
+
+ childProcess.stdout.on('data', (data) => {
+ debug(`[soffice:stdout] %s`, data.toString())
+ })
+
+ childProcess.stderr.on('data', (data) => {
+ const output = data.toString()
+
+ debug(`[soffice:stderr] %s`, output)
+ error(`${chalk.yellow`[soffice]`} ${output.trim()}`, {
+ singleLine: true,
+ })
+ })
+
+ return new Promise((resolve, reject) => {
+ childProcess.on('close', (code) => {
+ debug(`[soffice] soffice exited with code %d`, code)
+
+ if (code === 0) {
+ resolve()
+ } else {
+ reject(new Error(`soffice exited with code ${code}.`))
+ }
+ })
+ })
+ })
+ .then(resolve, reject)
+ })
+ }
+
+ private async binaryInWSLHost(): Promise {
+ return !!(await isWSL()) && wslHostMatcher.test(await this.path)
+ }
+
+ private async setProfileDir(): Promise {
+ let needToTranslateWindowsPathToWSL = false
+
+ const dir = await (async () => {
+ // In WSL environment, Marp CLI may use soffice on Windows. If soffice has
+ // located in host OS (Windows), we have to specify Windows path.
+ if (await this.binaryInWSLHost()) {
+ if (wslTmp === undefined) wslTmp = await getWindowsEnv('TMP')
+ if (wslTmp !== undefined) {
+ needToTranslateWindowsPathToWSL = true
+ return path.win32.resolve(wslTmp, this.#profileDirName)
+ }
+ }
+ return path.resolve(os.tmpdir(), this.#profileDirName)
+ })()
+
+ debug(`soffice data directory: %s`, dir)
+
+ // Ensure the data directory is created
+ const mkdirPath = needToTranslateWindowsPathToWSL
+ ? await translateWindowsPathToWSL(dir)
+ : dir
+
+ await fs.promises.mkdir(mkdirPath, { recursive: true })
+ debug(`Created data directory: %s`, mkdirPath)
+
+ return dir
+ }
+}
diff --git a/src/utils/container.ts b/src/utils/container.ts
index 565a2f39..79a7f3df 100644
--- a/src/utils/container.ts
+++ b/src/utils/container.ts
@@ -1,6 +1,7 @@
import _isInsideContainer from 'is-inside-container'
export const isInsideContainer = () =>
- isOfficialDockerImage() || (_isInsideContainer() && !process.env.MARP_TEST_CI)
+ isOfficialContainerImage() ||
+ (_isInsideContainer() && !process.env.MARP_TEST_CI)
-export const isOfficialDockerImage = () => !!process.env.MARP_USER
+export const isOfficialContainerImage = () => !!process.env.MARP_USER
diff --git a/src/browser/finders/utils.ts b/src/utils/finder.ts
similarity index 95%
rename from src/browser/finders/utils.ts
rename to src/utils/finder.ts
index e803f82c..5d6f9c8e 100644
--- a/src/browser/finders/utils.ts
+++ b/src/utils/finder.ts
@@ -2,8 +2,8 @@ import fs from 'node:fs'
import path from 'node:path'
import { parse as parsePlist } from 'fast-plist'
import nodeWhich from 'which'
-import { debugBrowserFinder } from '../../utils/debug'
-import { isWSL } from '../../utils/wsl'
+import { debugBrowserFinder } from './debug'
+import { isWSL } from './wsl'
export const getPlatform = async () =>
(await isWSL()) === 1 ? 'wsl1' : process.platform
@@ -20,6 +20,9 @@ export const isAccessible = async (path: string, mode?: number) => {
export const isExecutable = async (path: string) =>
await isAccessible(path, fs.constants.X_OK)
+export const isReadable = async (path: string) =>
+ await isAccessible(path, fs.constants.R_OK)
+
const findFirst = async (
paths: string[],
predicate: (path: string) => Promise
diff --git a/src/utils/tmp.ts b/src/utils/tmp.ts
index 689b7a93..a6ff916a 100644
--- a/src/utils/tmp.ts
+++ b/src/utils/tmp.ts
@@ -2,7 +2,7 @@ import os from 'node:os'
import path from 'node:path'
import { promisify } from 'node:util'
import { tmpName } from 'tmp'
-import { isOfficialDockerImage } from './container'
+import { isOfficialContainerImage } from './container'
const tmpNamePromise = promisify(tmpName)
@@ -10,7 +10,7 @@ const tmpNamePromise = promisify(tmpName)
// directory so always create tmp file to home directory if in Linux.
// (Except an official docker image)
const shouldPutTmpFileToHome =
- process.platform === 'linux' && !isOfficialDockerImage()
+ process.platform === 'linux' && !isOfficialContainerImage()
export const generateTmpName = async (extension?: `.${string}`) => {
let tmp: string = await tmpNamePromise({ postfix: extension })
diff --git a/test/browser/finders/chrome.ts b/test/browser/finders/chrome.ts
index b6a7154a..6a4e14f8 100644
--- a/test/browser/finders/chrome.ts
+++ b/test/browser/finders/chrome.ts
@@ -3,8 +3,8 @@ import * as chromeFinderModule from 'chrome-launcher/dist/chrome-finder'
import { ChromeBrowser } from '../../../src/browser/browsers/chrome'
import { ChromeCdpBrowser } from '../../../src/browser/browsers/chrome-cdp'
import { chromeFinder } from '../../../src/browser/finders/chrome'
-import * as utils from '../../../src/browser/finders/utils'
import { CLIError } from '../../../src/error'
+import * as utils from '../../../src/utils/finder'
import * as wsl from '../../../src/utils/wsl'
jest.mock('chrome-launcher/dist/chrome-finder')
diff --git a/test/browser/finders/edge.ts b/test/browser/finders/edge.ts
index e11fdaaf..101e07b0 100644
--- a/test/browser/finders/edge.ts
+++ b/test/browser/finders/edge.ts
@@ -2,8 +2,8 @@ import path from 'node:path'
import { ChromeBrowser } from '../../../src/browser/browsers/chrome'
import { ChromeCdpBrowser } from '../../../src/browser/browsers/chrome-cdp'
import { edgeFinder } from '../../../src/browser/finders/edge'
-import * as utils from '../../../src/browser/finders/utils'
import { CLIError } from '../../../src/error'
+import * as utils from '../../../src/utils/finder'
import * as wsl from '../../../src/utils/wsl'
afterEach(() => {
diff --git a/test/browser/finders/firefox.ts b/test/browser/finders/firefox.ts
index 516607b8..83880948 100644
--- a/test/browser/finders/firefox.ts
+++ b/test/browser/finders/firefox.ts
@@ -2,8 +2,8 @@ import path from 'node:path'
import which from 'which'
import { FirefoxBrowser } from '../../../src/browser/browsers/firefox'
import { firefoxFinder } from '../../../src/browser/finders/firefox'
-import * as utils from '../../../src/browser/finders/utils'
import { CLIError } from '../../../src/error'
+import * as utils from '../../../src/utils/finder'
jest.mock('which')
@@ -28,7 +28,7 @@ const executableMock = (name: string) =>
describe('Firefox finder', () => {
describe('with preferred path', () => {
- it('returns the preferred path as edge', async () => {
+ it('returns the preferred path as firefox', async () => {
const firefox = await firefoxFinder({
preferredPath: '/test/preferred/firefox',
})
@@ -152,9 +152,9 @@ describe('Firefox finder', () => {
it('finds possible executable path and returns the matched path', async () => {
const findExecutableSpy = jest.spyOn(utils, 'findExecutable')
- const edge = await firefoxFinder({})
+ const firefox = await firefoxFinder({})
- expect(edge).toStrictEqual({
+ expect(firefox).toStrictEqual({
path: '/Applications/Firefox.app/Contents/MacOS/firefox',
acceptedBrowsers: [FirefoxBrowser],
})
@@ -175,9 +175,9 @@ describe('Firefox finder', () => {
p === '/Applications/Firefox.app/Contents/MacOS/firefox'
)
- const edge = await firefoxFinder({})
+ const firefox = await firefoxFinder({})
- expect(edge).toStrictEqual({
+ expect(firefox).toStrictEqual({
path: '/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox',
acceptedBrowsers: [FirefoxBrowser],
})
@@ -222,9 +222,9 @@ describe('Firefox finder', () => {
it('finds possible executable path and returns the matched path', async () => {
const findExecutableSpy = jest.spyOn(utils, 'findExecutable')
- const edge = await firefoxFinder({})
+ const firefox = await firefoxFinder({})
- expect(edge).toStrictEqual({
+ expect(firefox).toStrictEqual({
path: firefoxPath,
acceptedBrowsers: [FirefoxBrowser],
})
@@ -321,9 +321,9 @@ describe('Firefox finder', () => {
it('finds possible executable path and returns the matched path', async () => {
const findExecutableSpy = jest.spyOn(utils, 'findExecutable')
- const edge = await firefoxFinder({})
+ const firefox = await firefoxFinder({})
- expect(edge).toStrictEqual({
+ expect(firefox).toStrictEqual({
path: '/mnt/c/Program Files/Mozilla Firefox/firefox.exe',
acceptedBrowsers: [FirefoxBrowser],
})
diff --git a/test/converter.ts b/test/converter.ts
index 18cc2d3d..c6eb6231 100644
--- a/test/converter.ts
+++ b/test/converter.ts
@@ -12,8 +12,10 @@ import { TimeoutError } from 'puppeteer-core'
import { fromBuffer as yauzlFromBuffer } from 'yauzl'
import { BrowserManager } from '../src/browser/manager'
import { Converter, ConvertType, ConverterOption } from '../src/converter'
-import { CLIError } from '../src/error'
+import { CLIError, CLIErrorCode } from '../src/error'
import { File, FileType } from '../src/file'
+import * as sofficeFinder from '../src/soffice/finder'
+import { SOffice } from '../src/soffice/soffice'
import { bare as bareTpl } from '../src/templates'
import { ThemeSet } from '../src/theme'
import { WatchNotifier } from '../src/watcher'
@@ -590,7 +592,7 @@ describe('Converter', () => {
async () => {
const file = new File(onePath)
- const fileCleanup = jest.spyOn(File.prototype as any, 'cleanup')
+ const unlink = jest.spyOn(fs.promises, 'unlink')
const fileSave = jest
.spyOn(File.prototype, 'save')
.mockImplementation()
@@ -610,7 +612,7 @@ describe('Converter', () => {
expect(fileTmp).toHaveBeenCalledWith(
expect.objectContaining({ extension: '.html' })
)
- expect(fileCleanup).toHaveBeenCalledWith(
+ expect(unlink).toHaveBeenCalledWith(
expect.stringContaining(os.tmpdir())
)
expect(fileSave).toHaveBeenCalled()
@@ -1061,6 +1063,76 @@ describe('Converter', () => {
timeout
)
})
+
+ describe('with pptxEditable option', () => {
+ beforeEach(() => {
+ // Don't mock writeFile to use actually saved tmp file for conversion
+ writeFileSpy.mockRestore()
+ })
+
+ it(
+ 'converts markdown file into PDF -> PPTX through soffice',
+ async () => {
+ const cvt = converter({ pptxEditable: true })
+ const editablePptxSpy = jest.spyOn(
+ cvt as any,
+ 'convertFileToEditablePPTX'
+ )
+ const unlink = jest.spyOn(fs.promises, 'unlink')
+ const fileSave = jest
+ .spyOn(File.prototype, 'save')
+ .mockImplementation()
+ const fileTmp = jest.spyOn(File.prototype, 'saveTmpFile')
+ const warn = jest.spyOn(console, 'warn').mockImplementation()
+
+ await cvt.convertFile(new File(onePath))
+ expect(editablePptxSpy).toHaveBeenCalled()
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'Converting to editable PPTX is experimental feature'
+ )
+ )
+ expect(fileTmp).toHaveBeenCalledWith(
+ expect.objectContaining({ extension: '.pdf' })
+ )
+ expect(unlink).toHaveBeenCalledWith(
+ expect.stringContaining(os.tmpdir())
+ )
+ expect(fileSave).toHaveBeenCalled()
+
+ const savedFile = fileSave.mock.instances[0] as unknown as File
+ expect(savedFile).toBeInstanceOf(File)
+ expect(savedFile.path).toBe('test.pptx')
+
+ // ZIP PK header for Office Open XML
+ expect(savedFile.buffer?.toString('ascii', 0, 2)).toBe('PK')
+ expect(savedFile.buffer?.toString('hex', 2, 4)).toBe('0304')
+ },
+ timeoutLarge
+ )
+
+ it('throws an error when soffice is not found', async () => {
+ const err = new CLIError('Error', CLIErrorCode.NOT_FOUND_SOFFICE)
+
+ jest.spyOn(console, 'warn').mockImplementation()
+ jest.spyOn(sofficeFinder, 'findSOffice').mockRejectedValue(err)
+
+ const cvt = converter({ pptxEditable: true })
+ await expect(() =>
+ cvt.convertFile(new File(onePath))
+ ).rejects.toThrow(err)
+ })
+
+ it('throws an error when soffice is spawned but does not generate a converted file', async () => {
+ jest.spyOn(console, 'warn').mockImplementation()
+ jest.spyOn(SOffice.prototype, 'spawn').mockResolvedValue()
+
+ const cvt = converter({ pptxEditable: true })
+ await expect(() =>
+ cvt.convertFile(new File(onePath))
+ ).rejects.toThrow('LibreOffice could not convert PPTX internally.')
+ })
+ })
})
describe('when convert type is PNG', () => {
diff --git a/test/marp-cli.ts b/test/marp-cli.ts
index cde7b4da..8ec7e0ca 100644
--- a/test/marp-cli.ts
+++ b/test/marp-cli.ts
@@ -199,7 +199,9 @@ describe('Marp CLI', () => {
describe('when CLI is running in an official Docker image', () => {
it('does not output help about --preview option', async () => {
- jest.spyOn(container, 'isOfficialDockerImage').mockReturnValue(true)
+ jest
+ .spyOn(container, 'isOfficialContainerImage')
+ .mockReturnValue(true)
expect(await run()).toBe(0)
expect(log).toHaveBeenCalledWith(
@@ -208,6 +210,28 @@ describe('Marp CLI', () => {
})
})
})
+
+ describe('PPTX editable option', () => {
+ it('outputs help about --pptx-editable option', async () => {
+ expect(await run()).toBe(0)
+ expect(log).toHaveBeenCalledWith(
+ expect.stringContaining('--pptx-editable')
+ )
+ })
+
+ describe('when CLI is running in an official Docker image', () => {
+ it('does not output help about --pptx-editable option', async () => {
+ jest
+ .spyOn(container, 'isOfficialContainerImage')
+ .mockReturnValue(true)
+
+ expect(await run()).toBe(0)
+ expect(log).toHaveBeenCalledWith(
+ expect.not.stringContaining('--pptx-editable')
+ )
+ })
+ })
+ })
})
}
@@ -409,7 +433,9 @@ describe('Marp CLI', () => {
describe('when CLI is running in an official Docker image', () => {
it('ignores --preview option with warning', async () => {
- jest.spyOn(container, 'isOfficialDockerImage').mockReturnValue(true)
+ jest
+ .spyOn(container, 'isOfficialContainerImage')
+ .mockReturnValue(true)
const warn = jest.spyOn(cli, 'warn').mockImplementation()
@@ -1423,7 +1449,9 @@ describe('Marp CLI', () => {
describe('when CLI is running in an official Docker image', () => {
it('ignores --preview option with warning', async () => {
- jest.spyOn(container, 'isOfficialDockerImage').mockReturnValue(true)
+ jest
+ .spyOn(container, 'isOfficialContainerImage')
+ .mockReturnValue(true)
await marpCli([onePath, '--preview', '--no-output'])
@@ -1537,6 +1565,20 @@ describe('Marp CLI', () => {
})
})
+ describe('with --pptx-editable options', () => {
+ it('prefers PPTX than HTML if not specified conversion type', async () => {
+ const cmd = [onePath, '--pptx-editable']
+ expect((await conversion(...cmd)).options.type).toBe(ConvertType.pptx)
+
+ // This option is actually not for defining conversion type so other
+ // options to set conversion type are always prioritized.
+ const cmdPptx = [onePath, '--pptx-editable', '--pdf']
+ expect((await conversion(...cmdPptx)).options.type).toBe(
+ ConvertType.pdf
+ )
+ })
+ })
+
describe('with PUPPETEER_TIMEOUT env', () => {
beforeEach(() => {
process.env.PUPPETEER_TIMEOUT = '12345'
diff --git a/test/soffice/finder.ts b/test/soffice/finder.ts
new file mode 100644
index 00000000..13322856
--- /dev/null
+++ b/test/soffice/finder.ts
@@ -0,0 +1,303 @@
+import path from 'node:path'
+import which from 'which'
+import { CLIError } from '../../src/error'
+import { findSOffice } from '../../src/soffice/finder'
+import * as utils from '../../src/utils/finder'
+import * as wsl from '../../src/utils/wsl'
+
+jest.mock('which')
+
+const mockedWhich = jest.mocked(which<{ nothrow: true }>)
+
+afterEach(() => {
+ jest.resetAllMocks()
+ jest.restoreAllMocks()
+ mockedWhich.mockReset()
+ mockedWhich.mockRestore()
+})
+
+const sofficePathRest = ['LibreOffice', 'program', 'soffice.exe']
+
+const itExceptWin = process.platform === 'win32' ? it.skip : it
+
+const executableMock = (name: string) =>
+ path.join(__dirname, `../utils/_executable_mocks`, name)
+
+describe('SOffice finder', () => {
+ describe('with preferred path', () => {
+ it('returns the preferred path as edge', async () => {
+ const soffice = await findSOffice({
+ preferredPath: '/test/preferred/soffice',
+ })
+
+ expect(soffice).toStrictEqual({
+ path: '/test/preferred/soffice',
+ })
+ })
+ })
+
+ describe('with SOFFICE_PATH environment variable', () => {
+ const originalEnv = { ...process.env }
+ const regularResolution = new Error('Starting regular resolution')
+
+ beforeEach(() => {
+ jest.resetModules()
+ jest.spyOn(utils, 'getPlatform').mockRejectedValue(regularResolution)
+ })
+
+ afterEach(() => {
+ process.env = { ...originalEnv }
+ })
+
+ it('return the path for executable specified in SOFFICE_PATH', async () => {
+ process.env.SOFFICE_PATH = executableMock('empty')
+
+ expect(await findSOffice({})).toStrictEqual({
+ path: process.env.SOFFICE_PATH,
+ })
+ })
+
+ itExceptWin(
+ 'processes regular resolution if SOFFICE_PATH is not executable',
+ async () => {
+ process.env.SOFFICE_PATH = executableMock('non-executable')
+
+ await expect(findSOffice({})).rejects.toThrow(regularResolution)
+ }
+ )
+
+ it('processes regular resolution if SOFFICE_PATH is not found', async () => {
+ process.env.SOFFICE_PATH = executableMock('not-found')
+
+ await expect(findSOffice({})).rejects.toThrow(regularResolution)
+ })
+
+ it('prefers the preferred path over SOFFICE_PATH', async () => {
+ process.env.SOFFICE_PATH = executableMock('empty')
+
+ expect(
+ await findSOffice({ preferredPath: '/test/preferred/soffice' })
+ ).toStrictEqual({
+ path: '/test/preferred/soffice',
+ })
+ })
+ })
+
+ describe('with Linux', () => {
+ beforeEach(() => {
+ jest.spyOn(utils, 'getPlatform').mockResolvedValue('linux')
+ })
+
+ it('finds possible binaries from PATH by using which command, and return resolved path', async () => {
+ mockedWhich.mockImplementation(async (command) => {
+ if (command === 'soffice') return executableMock('empty')
+ return null
+ })
+
+ const soffice = await findSOffice({})
+
+ expect(soffice).toStrictEqual({ path: executableMock('empty') })
+ expect(which).toHaveBeenCalledWith('soffice', { nothrow: true })
+ })
+
+ it('throws error if the path was not resolved', async () => {
+ mockedWhich.mockResolvedValue(null)
+
+ await expect(findSOffice({})).rejects.toThrow(CLIError)
+ expect(which).toHaveBeenCalled()
+ })
+
+ it('throws error if the which command has rejected by error', async () => {
+ mockedWhich.mockRejectedValue(new Error('Unexpected error'))
+
+ await expect(findSOffice({})).rejects.toThrow(CLIError)
+ expect(which).toHaveBeenCalled()
+ })
+
+ it('fallbacks to WSL resolution if in WSL 2 with mirrored network mode', async () => {
+ jest.spyOn(wsl, 'getWSL2NetworkingMode').mockResolvedValue('mirrored')
+
+ mockedWhich.mockImplementation(async () => null)
+
+ jest
+ .spyOn(utils, 'isExecutable')
+ .mockImplementation(
+ async (p) =>
+ p === '/mnt/c/Program Files/LibreOffice/program/soffice.exe'
+ )
+
+ expect(await findSOffice({})).toStrictEqual({
+ path: '/mnt/c/Program Files/LibreOffice/program/soffice.exe',
+ })
+ expect(which).toHaveBeenCalled()
+ })
+
+ it('throws error if in WSL 2 with NAT mode', async () => {
+ jest.spyOn(wsl, 'getWSL2NetworkingMode').mockResolvedValue('nat')
+ mockedWhich.mockImplementation(async () => null)
+
+ await expect(findSOffice({})).rejects.toThrow(CLIError)
+ })
+ })
+
+ describe('with macOS', () => {
+ beforeEach(() => {
+ jest.spyOn(utils, 'getPlatform').mockResolvedValue('darwin')
+ jest
+ .spyOn(utils, 'isExecutable')
+ .mockImplementation(
+ async (p) =>
+ p === '/Applications/LibreOffice.app/Contents/MacOS/soffice'
+ )
+ })
+
+ it('finds possible executable path and returns the matched path', async () => {
+ const findExecutableSpy = jest.spyOn(utils, 'findExecutable')
+ const soffice = await findSOffice({})
+
+ expect(soffice).toStrictEqual({
+ path: '/Applications/LibreOffice.app/Contents/MacOS/soffice',
+ })
+ expect(findExecutableSpy).toHaveBeenCalledWith([
+ '/Applications/LibreOffice.app/Contents/MacOS/soffice',
+ ])
+ })
+
+ it('throws error if no executable path is found', async () => {
+ jest.spyOn(utils, 'isExecutable').mockResolvedValue(false)
+ await expect(findSOffice({})).rejects.toThrow(CLIError)
+ })
+ })
+
+ describe('with Windows', () => {
+ const winProgramFiles = ['c:', 'Mock', 'Program Files']
+ const winProgramFilesX86 = ['c:', 'Mock', 'Program Files (x86)']
+ const sofficePath = path.join(...winProgramFiles, ...sofficePathRest)
+
+ const originalEnv = { ...process.env }
+
+ beforeEach(() => {
+ jest.resetModules()
+
+ jest.spyOn(utils, 'getPlatform').mockResolvedValue('win32')
+ jest
+ .spyOn(utils, 'isExecutable')
+ .mockImplementation(async (p) => p === sofficePath)
+
+ process.env = {
+ ...originalEnv,
+ PATH: undefined,
+ PROGRAMFILES: path.join(...winProgramFiles),
+ 'PROGRAMFILES(X86)': path.join(...winProgramFilesX86),
+ }
+ })
+
+ afterEach(() => {
+ process.env = { ...originalEnv }
+ })
+
+ it('finds possible executable path and returns the matched path', async () => {
+ const findExecutableSpy = jest.spyOn(utils, 'findExecutable')
+ const soffice = await findSOffice({})
+
+ expect(soffice).toStrictEqual({ path: sofficePath })
+ expect(findExecutableSpy).toHaveBeenCalledWith([
+ path.join(...winProgramFiles, ...sofficePathRest),
+ path.join(...winProgramFilesX86, ...sofficePathRest),
+ ])
+ })
+
+ it('skips inaccessible directories to find', async () => {
+ process.env['PROGRAMFILES(X86)'] = '' // No WOW64
+
+ const findExecutableSpy = jest.spyOn(utils, 'findExecutable')
+ await findSOffice({})
+
+ expect(findExecutableSpy).toHaveBeenCalledWith([
+ path.join(...winProgramFiles, ...sofficePathRest),
+ ])
+ })
+
+ it('finds from detected drives when the PATH environment has paths starting with any drive letters', async () => {
+ process.env.PATH = 'z:\\Mock;D:\\Mock;d:\\Mock\\Mock;'
+
+ const findExecutableSpy = jest.spyOn(utils, 'findExecutable')
+ await findSOffice({})
+
+ expect(findExecutableSpy).toHaveBeenCalledWith([
+ path.join('c:', ...winProgramFiles.slice(1), ...sofficePathRest),
+ path.join('c:', ...winProgramFilesX86.slice(1), ...sofficePathRest),
+ path.join('d:', ...winProgramFiles.slice(1), ...sofficePathRest),
+ path.join('d:', ...winProgramFilesX86.slice(1), ...sofficePathRest),
+ path.join('z:', ...winProgramFiles.slice(1), ...sofficePathRest),
+ path.join('z:', ...winProgramFilesX86.slice(1), ...sofficePathRest),
+ ])
+ })
+
+ it('throws error if no executable path is found', async () => {
+ process.env.PROGRAMFILES = ''
+ process.env['PROGRAMFILES(X86)'] = ''
+
+ const findExecutableSpy = jest.spyOn(utils, 'findExecutable')
+ await expect(findSOffice({})).rejects.toThrow(CLIError)
+
+ expect(findExecutableSpy).toHaveBeenCalledWith([])
+ })
+ })
+
+ describe('with WSL1', () => {
+ const originalEnv = { ...process.env }
+
+ beforeEach(() => {
+ jest.resetModules()
+
+ jest.spyOn(utils, 'getPlatform').mockResolvedValue('wsl1')
+ jest
+ .spyOn(utils, 'isExecutable')
+ .mockImplementation(
+ async (p) =>
+ p === '/mnt/c/Program Files/LibreOffice/program/soffice.exe'
+ )
+
+ process.env = { ...originalEnv, PATH: undefined }
+ })
+
+ afterEach(() => {
+ process.env = { ...originalEnv }
+ })
+
+ it('finds possible executable path and returns the matched path', async () => {
+ const findExecutableSpy = jest.spyOn(utils, 'findExecutable')
+ const soffice = await findSOffice({})
+
+ expect(soffice).toStrictEqual({
+ path: '/mnt/c/Program Files/LibreOffice/program/soffice.exe',
+ })
+ expect(findExecutableSpy).toHaveBeenCalledWith([
+ path.posix.join('/mnt/c/Program Files', ...sofficePathRest),
+ path.posix.join('/mnt/c/Program Files (x86)', ...sofficePathRest),
+ ])
+ })
+
+ it('finds from detected drives when the PATH environment has paths starting with any drive letters', async () => {
+ process.env.PATH = '/mnt/z/Mock:/mnt/d/Mock:/mnt/d/Mock/Mock'
+
+ const findExecutableSpy = jest.spyOn(utils, 'findExecutable')
+ await findSOffice({})
+
+ expect(findExecutableSpy).toHaveBeenCalledWith([
+ path.posix.join('/mnt/c/Program Files', ...sofficePathRest),
+ path.posix.join('/mnt/c/Program Files (x86)', ...sofficePathRest),
+ path.posix.join('/mnt/d/Program Files', ...sofficePathRest),
+ path.posix.join('/mnt/d/Program Files (x86)', ...sofficePathRest),
+ path.posix.join('/mnt/z/Program Files', ...sofficePathRest),
+ path.posix.join('/mnt/z/Program Files (x86)', ...sofficePathRest),
+ ])
+ })
+
+ it('throws error if no executable path is found', async () => {
+ jest.spyOn(utils, 'isExecutable').mockResolvedValue(false)
+ await expect(findSOffice({})).rejects.toThrow(CLIError)
+ })
+ })
+})
diff --git a/test/soffice/soffice.ts b/test/soffice/soffice.ts
new file mode 100644
index 00000000..070db0c2
--- /dev/null
+++ b/test/soffice/soffice.ts
@@ -0,0 +1,162 @@
+import * as childProcess from 'node:child_process'
+import EventEmitter from 'node:events'
+import fs from 'node:fs'
+import * as cli from '../../src/cli'
+import { SOffice } from '../../src/soffice/soffice'
+import * as wsl from '../../src/utils/wsl'
+
+const defaultSpawnSetting = { code: 0, delay: 50 }
+const spawnSetting = { ...defaultSpawnSetting }
+
+jest.mock('node:child_process', () => ({
+ ...jest.requireActual('node:child_process'),
+ spawn: jest.fn(),
+}))
+
+beforeEach(() => {
+ jest.spyOn(childProcess, 'spawn').mockImplementation((): any => {
+ const mockedChildProcess = Object.assign(new EventEmitter(), {
+ stdout: new EventEmitter(),
+ stderr: new EventEmitter(),
+ })
+
+ setTimeout(() => {
+ mockedChildProcess.stdout.emit('data', Buffer.from('mocked stdout'))
+ }, 100)
+
+ setTimeout(() => {
+ if (spawnSetting.code !== 0) {
+ mockedChildProcess.stderr.emit('data', Buffer.from('mocked stderr'))
+ }
+ mockedChildProcess.emit('close', spawnSetting.code)
+ }, spawnSetting.delay)
+
+ return mockedChildProcess
+ })
+
+ spawnSetting.code = defaultSpawnSetting.code
+ spawnSetting.delay = defaultSpawnSetting.delay
+})
+
+afterEach(() => {
+ jest.resetAllMocks()
+ jest.restoreAllMocks()
+})
+
+describe('SOffice class', () => {
+ describe('#spawn', () => {
+ it('spawns soffice with specified args', async () => {
+ const spawnSpy = jest.spyOn(childProcess, 'spawn')
+ const soffice = new SOffice()
+ await soffice.spawn(['--help'])
+
+ expect(spawnSpy).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.arrayContaining(['--help']),
+ { stdio: 'pipe' }
+ )
+ })
+
+ it('throws error if soffice exits with non-zero code', async () => {
+ spawnSetting.code = 123
+
+ jest.spyOn(cli, 'error').mockImplementation()
+
+ const spawnSpy = jest.spyOn(childProcess, 'spawn')
+ const soffice = new SOffice()
+ await expect(() => soffice.spawn(['--help'])).rejects.toThrow(
+ 'soffice exited with code 123.'
+ )
+
+ expect(spawnSpy).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.arrayContaining(['--help']),
+ { stdio: 'pipe' }
+ )
+ })
+
+ it('spawns soffice in serial even if run the method multi times in parallel', async () => {
+ spawnSetting.delay = 300
+
+ const finishedTimes: number[] = []
+ const soffice = new SOffice()
+
+ await Promise.all([
+ soffice.spawn(['--help']).then(() => finishedTimes.push(Date.now())),
+ soffice.spawn(['-h']).then(() => finishedTimes.push(Date.now())),
+ ])
+
+ expect(finishedTimes).toHaveLength(2)
+ expect(
+ Math.abs(finishedTimes[1] - finishedTimes[0])
+ ).toBeGreaterThanOrEqual(spawnSetting.delay - 10)
+ })
+ })
+
+ describe('get #profileDir', () => {
+ it('returns the profile directory', async () => {
+ const mkdir = jest
+ .spyOn(fs.promises, 'mkdir')
+ .mockResolvedValue(undefined)
+
+ const soffice = new SOffice()
+ const profileDir = await soffice.profileDir
+
+ expect(profileDir).toContain('marp-cli-soffice-')
+ expect(mkdir).toHaveBeenCalledWith(profileDir, { recursive: true })
+ })
+
+ it('returns the profile directory with Windows TMP if the binary is in WSL host', async () => {
+ const mkdir = jest
+ .spyOn(fs.promises, 'mkdir')
+ .mockResolvedValue(undefined)
+
+ jest.spyOn(wsl, 'getWindowsEnv').mockResolvedValue('C:\\Windows\\Temp')
+ jest
+ .spyOn(wsl, 'translateWindowsPathToWSL')
+ .mockResolvedValue('/mnt/c/Windows/Temp/test')
+
+ const soffice = new SOffice()
+ jest.spyOn(soffice as any, 'binaryInWSLHost').mockResolvedValue(true)
+
+ const profileDir = await soffice.profileDir
+ expect(profileDir).toContain('C:\\Windows\\Temp')
+ expect(profileDir).toContain('marp-cli-soffice-')
+ expect(mkdir).toHaveBeenCalledWith('/mnt/c/Windows/Temp/test', {
+ recursive: true,
+ })
+ })
+ })
+
+ describe('private #binaryInWSLHost', () => {
+ it('always returns false if the current environment is not WSL', async () => {
+ jest.spyOn(wsl, 'isWSL').mockResolvedValue(0)
+
+ const soffice: any = new SOffice({
+ path: '/mnt/c/Program Files/LibreOffice/program/soffice.exe',
+ })
+
+ expect(await soffice.binaryInWSLHost()).toBe(false)
+ })
+
+ it('returns true if the current environment is WSL and the browser path is located in the host OS', async () => {
+ jest.spyOn(wsl, 'isWSL').mockResolvedValue(1)
+
+ const soffice: any = new SOffice({
+ path: '/mnt/c/Program Files/LibreOffice/program/soffice.exe',
+ })
+
+ expect(await soffice.binaryInWSLHost()).toBe(true)
+ })
+
+ it('returns false if the current environment is WSL and the browser path is not located in the host OS', async () => {
+ jest.spyOn(wsl, 'isWSL').mockResolvedValue(1)
+
+ const soffice: any = new SOffice({
+ path: '/usr/lib/libreoffice/program/libreoffice',
+ })
+
+ expect(await soffice.binaryInWSLHost()).toBe(false)
+ })
+ })
+})
diff --git a/test/templates/bespoke.ts b/test/templates/bespoke.ts
index 5368e449..679a06a7 100644
--- a/test/templates/bespoke.ts
+++ b/test/templates/bespoke.ts
@@ -1539,7 +1539,7 @@ describe("Bespoke template's browser context", () => {
})
})
- describe('[Experimental] Transition', () => {
+ describe('Transition', () => {
beforeEach(() => {
transitionUtils._resetResolvedKeyframes()
jest
diff --git a/test/browser/finders/utils.ts b/test/utils/finder.ts
similarity index 92%
rename from test/browser/finders/utils.ts
rename to test/utils/finder.ts
index d6f6a4f4..d567c19a 100644
--- a/test/browser/finders/utils.ts
+++ b/test/utils/finder.ts
@@ -1,6 +1,6 @@
import path from 'node:path'
-import { getPlatform, isSnapBrowser } from '../../../src/browser/finders/utils'
-import * as wsl from '../../../src/utils/wsl'
+import { getPlatform, isSnapBrowser } from '../../src/utils/finder'
+import * as wsl from '../../src/utils/wsl'
afterEach(() => {
jest.resetAllMocks()
@@ -8,7 +8,7 @@ afterEach(() => {
})
const executableMock = (name: string) =>
- path.join(__dirname, `../../utils/_executable_mocks`, name)
+ path.join(__dirname, '_executable_mocks', name)
describe('#getPlatform', () => {
it('returns current platform in non WSL environment', async () => {