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 () => {