Skip to content

Commit

Permalink
Merge pull request #369 from marp-team/pdf-metadata-and-annotations
Browse files Browse the repository at this point in the history
Apply metadata and presenter notes to PDF
  • Loading branch information
yhatt authored Aug 9, 2021
2 parents 8936815 + 3db15a2 commit 4f1e7a0
Show file tree
Hide file tree
Showing 9 changed files with 187 additions and 22 deletions.
27 changes: 16 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ npx @marp-team/marp-cli@latest -w slide-deck.md
npx @marp-team/marp-cli@latest -s ./slides
```

> :information_source: You have to install [Google Chrome], [Chromium], [Microsoft Edge], or a Chromium based browser to convert slide deck into PDF, PPTX, and image(s).
> :information_source: You have to install [Google Chrome], [Chromium], or [Microsoft Edge] to convert slide deck into PDF, PPTX, and image(s).
[google chrome]: https://www.google.com/chrome/
[chromium]: https://www.chromium.org/
Expand Down Expand Up @@ -132,6 +132,10 @@ marp slide-deck.md -o converted.pdf
[google chrome canary]: https://www.google.com/chrome/canary/

If your slide deck had included [Marpit presenter notes] as HTML comment, you can add note annotations to the lower left by using `--pdf-notes` option together with `--pdf`.

[marpit presenter notes]: https://marpit.marp.app/usage?id=presenter-notes

### Convert to PowerPoint document (`--pptx`)

Do you want more familiar way to present and share your deck? PPTX conversion to create PowerPoint document is available by passing `--pptx` option or specify the output path with PPTX extension.
Expand All @@ -141,7 +145,7 @@ marp --pptx slide-deck.md
marp slide-deck.md -o converted.pptx
```

A created PPTX includes rendered Marp slide pages and the support of [Marpit presenter notes](https://marpit.marp.app/usage?id=presenter-notes). It can open with PowerPoint, Keynote, Google Slides, LibreOffice Impress, and so on...
A created PPTX includes rendered Marp slide pages and the support of [Marpit presenter notes]. It can open with PowerPoint, Keynote, Google Slides, LibreOffice Impress, and so on...

<p align="center">
<img src="https://raw.githubusercontent.com/marp-team/marp-cli/main/docs/images/pptx.png" height="300" />
Expand Down Expand Up @@ -274,20 +278,20 @@ marp --template bare --engine @marp-team/marpit slide-deck.md

## Metadata

We recommend setting metadata of the slide deck if you want to host the outputted HTML on the web. To optimize the converted web page for SEO and social sharing, passed meta values will use in `<title>`, `<link>`, and `<meta>` tags.
Through [global directives] or CLI options, you can set metadata for a converted HTML, PDF, and PPTX slide deck.

| [Global directives] | CLI option | Description | Metadata |
| :-----------------: | :-------------: | :------------------------------------ | :-------------------------------------------- |
| `title` | `--title` | Define title of the slide deck. | `<title>`, `og:title` |
| `description` | `--description` | Define description of the slide deck. | `<meta name="description">`, `og:description` |
| `url` | `--url` | Define [canonical URL]. | `<link rel="canonical">`, `og:url` |
| `image` | `--og-image` | Define [Open Graph] image URL. | `og:image` |
| [Global directives] | CLI option | Description | Available in |
| :-----------------: | :-------------: | :------------------------------ | :-------------- |
| `title` | `--title` | Define title of the slide deck | HTML, PDF, PPTX |
| `description` | `--description` | Define description of the slide | HTML, PDF, PPTX |
| `url` | `--url` | Define [canonical URL] \* | HTML |
| `image` | `--og-image` | Define [Open Graph] image URL | HTML |

> \*: If could not parse a specified value as valid, the URL will be ignored.
[canonical url]: https://en.wikipedia.org/wiki/Canonical_link_element
[open graph]: http://ogp.me/

> :information_source: The passed canonical URL will be ignored when cannot parse as valid URL.
### By [global directives]

Marp CLI supports _additional [global directives]_ to specify metadata in Markdown. You can define meta values in Markdown front-matter.
Expand Down Expand Up @@ -457,6 +461,7 @@ If you want to prevent looking up a configuration file, you can pass `--no-confi
| `options` | object | | The base options for the constructor of engine |
| `output` | string | `--output` `-o` | Output file path (or directory when input-dir is passed) |
| `pdf` | boolean | `--pdf` | Convert slide deck into PDF |
| `pdfNotes` | boolean | `--pdf-notes` | Add [presenter notes][marpit presenter notes] to PDF as annotations |
| `preview` | boolean | `--preview` `-p` | Open preview window |
| `server` | boolean | `--server` `-s` | Enable server mode |
| `template` | `bare` \| `bespoke` | `--template` | Choose template (`bespoke` by default) |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@
"cosmiconfig": "^7.0.0",
"express": "^4.17.1",
"import-from": "^4.0.0",
"pdf-lib": "^1.16.0",
"pptxgenjs": "^3.7.1",
"puppeteer-core": "10.1.0",
"serve-index": "^1.9.1",
Expand Down
7 changes: 7 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface IMarpCLIArguments {
ogImage?: string
output?: string | false
pdf?: boolean
pdfNotes?: boolean
pptx?: boolean
preview?: boolean
server?: boolean
Expand Down Expand Up @@ -149,12 +150,17 @@ export class MarpCLIConfig {

// Detect from filename
const lowerOutput = (output || '').toLowerCase()
if (lowerOutput.endsWith('.html') || lowerOutput.endsWith('.htm'))
return ConvertType.html
if (lowerOutput.endsWith('.pdf')) return ConvertType.pdf
if (lowerOutput.endsWith('.png')) return ConvertType.png
if (lowerOutput.endsWith('.pptx')) return ConvertType.pptx
if (lowerOutput.endsWith('.jpg') || lowerOutput.endsWith('.jpeg'))
return ConvertType.jpeg

// Prefer PDF than HTML if enabled presenter notes for PDF
if (this.args.pdfNotes || this.conf.pdfNotes) return ConvertType.pdf

return ConvertType.html
})()

Expand Down Expand Up @@ -216,6 +222,7 @@ export class MarpCLIConfig {
lang: this.conf.lang || (await osLocale()).replace(/@/g, '-'),
options: this.conf.options || {},
pages: !!(this.args.images || this.conf.images),
pdfNotes: !!(this.args.pdfNotes || this.conf.pdfNotes),
watch: (this.args.watch ?? this.conf.watch) || preview || server || false,
}
}
Expand Down
47 changes: 45 additions & 2 deletions src/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
import { isChromeInWSLHost, resolveWSLPathToHost } from './utils/wsl'
import { notifier } from './watcher'

const CREATED_BY_MARP = 'Created by Marp'

export enum ConvertType {
html = 'html',
pdf = 'pdf',
Expand Down Expand Up @@ -55,6 +57,7 @@ export interface ConverterOption {
options: MarpitOptions
output?: string | false
pages?: boolean | number[]
pdfNotes?: boolean
preview?: boolean
jpegQuality?: number
server?: boolean
Expand Down Expand Up @@ -254,6 +257,46 @@ export class Converter {
return await page.pdf({ printBackground: true, preferCSSPageSize: true })
})

// Apply PDF metadata and annotations
const creationDate = new Date()
const { PDFDocument, PDFHexString, PDFString } = await import('pdf-lib')
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 (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),
// Title: PDFString.of('Author'), // TODO: Set author
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))
}
}
}

// Apply modified PDF to buffer
ret.buffer = Buffer.from(await pdfDoc.save())

return ret
}

Expand Down Expand Up @@ -334,8 +377,8 @@ export class Converter {
const pptx = new (await import('pptxgenjs')).default()
const layoutName = `${tpl.rendered.size.width}x${tpl.rendered.size.height}`

pptx.author = 'Created by Marp'
pptx.company = 'Created by Marp'
pptx.author = CREATED_BY_MARP
pptx.company = CREATED_BY_MARP

pptx.defineLayout({
name: layoutName,
Expand Down
9 changes: 7 additions & 2 deletions src/marp-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,13 @@ export const marpCli = async (
},
'jpeg-quality': {
defaultDescription: '85',
describe: 'Setting JPEG image quality',
describe: 'Set JPEG image quality',
group: OptionGroup.Converter,
type: 'number',
},
'allow-local-files': {
describe:
'Allow to access local files from Markdown while converting PDF and image (NOT SECURE)',
'Allow to access local files from Markdown while converting PDF, PPTX, or image (NOT SECURE)',
group: OptionGroup.Converter,
type: 'boolean',
},
Expand Down Expand Up @@ -200,6 +200,11 @@ export const marpCli = async (
group: OptionGroup.Meta,
type: 'string',
},
'pdf-notes': {
describe: 'Add presenter notes to PDF as annotations',
group: OptionGroup.Meta,
type: 'boolean',
},
engine: {
describe: 'Select Marpit based engine by module name or path',
group: OptionGroup.Marp,
Expand Down
2 changes: 2 additions & 0 deletions test/_files/3.markdown
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
### three

<!-- presenter note -->
50 changes: 49 additions & 1 deletion test/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Marp from '@marp-team/marp-core'
import { Options } from '@marp-team/marpit'
import cheerio from 'cheerio'
import { imageSize } from 'image-size'
import { PDFDocument, PDFDict, PDFName, PDFString, PDFHexString } from 'pdf-lib'
import { Page } from 'puppeteer-core/lib/cjs/puppeteer/common/Page'
import yauzl from 'yauzl'
import { Converter, ConvertType, ConverterOption } from '../src/converter'
Expand Down Expand Up @@ -275,7 +276,7 @@ describe('Converter', () => {
})

describe('when convert type is PDF', () => {
const pdfInstance = (opts = {}) =>
const pdfInstance = (opts: Partial<ConverterOption> = {}) =>
instance({ ...opts, type: ConvertType.pdf })

it(
Expand All @@ -295,6 +296,26 @@ describe('Converter', () => {
puppeteerTimeoutMs
)

describe('with meta global directives', () => {
it(
'assigns meta info thorugh pdf-lib',
async () => {
const write = (<any>fs).__mockWriteFile()

await pdfInstance({
output: 'test.pdf',
globalDirectives: { title: 'title', description: 'description' },
}).convertFile(new File(onePath))

const pdf = await PDFDocument.load(write.mock.calls[0][1])

expect(pdf.getTitle()).toBe('title')
expect(pdf.getSubject()).toBe('description')
},
puppeteerTimeoutMs
)
})

describe('with allowLocalFiles option as true', () => {
it(
'converts with using temporally file',
Expand Down Expand Up @@ -330,6 +351,33 @@ describe('Converter', () => {
puppeteerTimeoutMs
)
})

describe('with pdfNotes option as true', () => {
it(
'assigns presenter notes as annotation of PDF',
async () => {
const write = (<any>fs).__mockWriteFile()

await pdfInstance({
output: 'test.pdf',
pdfNotes: true,
}).convertFile(new File(threePath))

const pdf = await PDFDocument.load(write.mock.calls[0][1])
const annotaionRef = pdf.getPage(0).node.Annots()?.get(0)
const annotation = pdf.context.lookup(annotaionRef, PDFDict)

const kv = (name: string) => annotation.get(PDFName.of(name))

expect(kv('Subtype')).toBe(PDFName.of('Text')) // Annotation type
expect(kv('Name')).toBe(PDFName.of('Note')) // The kind of icon
expect(kv('Contents')).toStrictEqual(
PDFHexString.fromText('presenter note')
)
},
puppeteerTimeoutMs
)
})
})

describe('when convert type is PPTX', () => {
Expand Down
38 changes: 34 additions & 4 deletions test/marp-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,12 +517,17 @@ describe('Marp CLI', () => {
.spyOn(Converter.prototype, 'convertFiles')
.mockImplementation()

jest.spyOn(cli, 'info').mockImplementation()
const cliInfo = jest.spyOn(cli, 'info').mockImplementation()

await marpCli(cmd)
expect(cvtFiles).toHaveBeenCalled()
try {
await marpCli(cmd)
expect(cvtFiles).toHaveBeenCalled()

return <any>cvtFiles.mock.instances[0]
return <any>cvtFiles.mock.instances[0]
} finally {
cvtFiles.mockRestore()
cliInfo.mockRestore()
}
}

it('converts file', async () => {
Expand Down Expand Up @@ -671,6 +676,17 @@ describe('Marp CLI', () => {
expect(stdout).toHaveBeenCalledTimes(1)
})

it('converts file with HTML type when extension is .html', async () => {
// --pdf-notes is required to prefer PDF over HTML in the default type
const cmd = [onePath, '--pdf-notes', '-o', 'example.html']
expect((await conversion(...cmd)).options.type).toBe(ConvertType.html)
})

it('converts file with HTML type when extension is .htm', async () => {
const cmd = [onePath, '--pdf-notes', '-o', 'example.htm']
expect((await conversion(...cmd)).options.type).toBe(ConvertType.html)
})

it('converts file with PDF type when extension is .pdf', async () => {
const cmd = [onePath, '-o', 'example.pdf']
expect((await conversion(...cmd)).options.type).toBe(ConvertType.pdf)
Expand Down Expand Up @@ -858,6 +874,20 @@ describe('Marp CLI', () => {
})
})
})

describe('with --pdf-notes option', () => {
it('prefers PDF than HTML if not specified conversion type', async () => {
const cmd = [onePath, '--pdf-notes']
expect((await conversion(...cmd)).options.type).toBe(ConvertType.pdf)

// This option is actually not for defining conversion type so other
// options to set conversion type are always prioritized.
const cmdPptx = [onePath, '--pdf-notes', '--pptx']
expect((await conversion(...cmdPptx)).options.type).toBe(
ConvertType.pptx
)
})
})
})

describe('with passing directory', () => {
Expand Down
Loading

0 comments on commit 4f1e7a0

Please sign in to comment.