Created
June 3, 2026 13:12
-
-
Save eduardoarandah/83b91d838dccbf9f38394693f5f47134 to your computer and use it in GitHub Desktop.
neovim print to pdf using markdown
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| -- markdown_to_pdf.lua | |
| -- Generates a PDF from a Markdown file in two steps: | |
| -- 1. Markdown -> HTML with pandoc (with embedded CSS) | |
| -- 2. HTML -> PDF with Google Chrome in headless mode | |
| -- The intermediate HTML file is deleted at the end, leaving only the PDF. | |
| -- | |
| -- Usage: :MarkdownToPDF (converts the current buffer) | |
| -- :MarkdownToPDF path.md (converts the given file) | |
| -- | |
| -- Requires Neovim 0.10+ (uses vim.system). | |
| -- | |
| -- Register the command in your init.lua: | |
| -- require("markdown_to_pdf").setup() | |
| local M = {} | |
| -- Path to the Chrome binary on macOS. | |
| local CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" | |
| -- Stylesheet to apply. Stored inside the Neovim config. | |
| local CSS_PATH = vim.fn.expand("~/.config/nvim/assets/print.css") | |
| --- Runs a command, reporting an error via vim.notify on failure. | |
| --- @param cmd table Command + arguments for vim.system. | |
| --- @param label string Name shown in the error message. | |
| --- @return boolean ok true if the command exited with code 0. | |
| local function run(cmd, label) | |
| local result = vim.system(cmd):wait() | |
| if result.code ~= 0 then | |
| vim.notify(label .. " failed: " .. (result.stderr or ""), vim.log.levels.ERROR) | |
| return false | |
| end | |
| return true | |
| end | |
| --- Converts Markdown to a self-contained HTML file with embedded CSS. | |
| --- @param md_path string Path to the input Markdown file. | |
| --- @param html_path string Path where the HTML will be generated. | |
| --- @return boolean ok | |
| local function markdown_to_html(md_path, html_path) | |
| -- -s standalone document with <head>. | |
| -- --embed-resources inlines the CSS as a <style> block, so the HTML is | |
| -- self-contained and is the only file left to delete. | |
| return run({ | |
| "pandoc", | |
| md_path, | |
| "-s", | |
| "--embed-resources", | |
| "--css=" .. CSS_PATH, | |
| "-o", | |
| html_path, | |
| }, "pandoc") | |
| end | |
| --- Converts a local HTML file to PDF with headless Chrome. | |
| --- @param html_path string Path to the input HTML. | |
| --- @param pdf_path string Path where the PDF will be generated. | |
| --- @return boolean ok | |
| local function html_to_pdf(html_path, pdf_path) | |
| -- file:// prints from disk, no server needed. | |
| -- --no-pdf-header-footer drops the date/URL Chrome adds to each page. | |
| return run({ | |
| CHROME, | |
| "--headless", | |
| "--disable-gpu", | |
| "--no-pdf-header-footer", | |
| "--print-to-pdf=" .. pdf_path, | |
| "file://" .. html_path, | |
| }, "Chrome") | |
| end | |
| --- Orchestrates the full conversion Markdown -> HTML -> PDF. | |
| --- @param md_path string Absolute path to the input Markdown file. | |
| local function convert(md_path) | |
| if vim.fn.filereadable(md_path) == 0 then | |
| vim.notify("File does not exist: " .. md_path, vim.log.levels.ERROR) | |
| return | |
| end | |
| -- Output files are written next to the Markdown source. | |
| local dir = vim.fn.fnamemodify(md_path, ":p:h") | |
| local stem = vim.fn.fnamemodify(md_path, ":t:r") -- filename without extension | |
| local html_path = dir .. "/" .. stem .. ".html" | |
| local pdf_path = dir .. "/" .. stem .. ".pdf" | |
| vim.notify("converting markdown to html...", vim.log.levels.INFO) | |
| if not markdown_to_html(md_path, html_path) then | |
| return | |
| end | |
| vim.notify("converting html to pdf...", vim.log.levels.INFO) | |
| if not html_to_pdf(html_path, pdf_path) then | |
| return | |
| end | |
| -- The HTML was only an intermediate step; the CSS is baked into the PDF. | |
| vim.fn.delete(html_path) | |
| vim.notify("PDF generated: " .. pdf_path, vim.log.levels.INFO) | |
| vim.fn.system("open " .. pdf_path) | |
| end | |
| --- Registers the :MarkdownToPDF command. Call from your init.lua: | |
| --- require("markdown_to_pdf").setup() | |
| function M.setup() | |
| vim.api.nvim_create_user_command("MarkdownToPDF", function(opts) | |
| -- Use the given file if any, otherwise the current buffer. | |
| local md_path = opts.args ~= "" and opts.args or vim.api.nvim_buf_get_name(0) | |
| convert(vim.fn.fnamemodify(md_path, ":p")) | |
| end, { | |
| nargs = "?", -- optional argument (file path) | |
| complete = "file", -- file path autocompletion with <Tab> | |
| desc = "Generates a PDF from Markdown (pandoc + headless Chrome)", | |
| }) | |
| end | |
| return M |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * github like style mejorado para print | |
| * https://github.com/iamcco/markdown.css/blob/master/dest/github/markdown.css | |
| */ | |
| :root { | |
| --color-text-primary: #000000; | |
| --color-text-secondary: #075b9e; | |
| --color-text-tertiary: #777; | |
| --color-text-link: #4078c0; | |
| --color-bg-primary: #fff; | |
| --color-bg-secondary: #fafbfc; | |
| --color-bg-tertiary: #f8f8f8; | |
| --color-border-primary: #dddddd; | |
| --color-border-secondary: #eaecef; | |
| --color-border-tertiary: #d1d5da; | |
| --color-kbd-foreground: #444d56; | |
| --color-markdown-blockquote-border: #dfe2e5; | |
| --color-markdown-table-border: #dfe2e5; | |
| --color-markdown-table-tr-border: #c6cbd1; | |
| --color-markdown-code-bg: #1b1f230d; | |
| } | |
| /* 1. Aseguramos que el contenedor raíz no tenga márgenes y ocupe el alto */ | |
| html, | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| height: 100%; | |
| } | |
| /*css for screen*/ | |
| html { | |
| font-size: 11px; | |
| } | |
| body ol ol, | |
| body ul ol, | |
| body ol ul, | |
| body ul ul, | |
| body ol ul ol, | |
| body ul ul ol, | |
| body ol ul ul, | |
| body ul ul ul { | |
| margin-top: 0; | |
| margin-bottom: 0; | |
| } | |
| body { | |
| font-family: "Helvetica Neue", Helvetica, "Segoe UI", Arial, freesans, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; | |
| color: var(--color-text-primary); | |
| line-height: 1.6; | |
| word-wrap: break-word; | |
| padding: 45px; | |
| background: var(--color-bg-primary); | |
| /* --- CAMBIOS PARA ALTURA COMPLETA --- */ | |
| min-height: 100vh; /* Ocupa el 100% del Viewport Height */ | |
| display: flex; /* Opcional: útil si quieres centrar contenido verticalmente */ | |
| flex-direction: column; | |
| box-sizing: border-box; /* Asegura que el padding no sume extra al 100vh */ | |
| } | |
| body > *:first-child { | |
| margin-top: 0 !important; | |
| } | |
| body > *:last-child { | |
| margin-bottom: 0 !important; | |
| } | |
| body .table-of-contents ol { | |
| list-style: none; | |
| } | |
| body .table-of-contents > ol { | |
| padding-left: 0; | |
| } | |
| body * { | |
| -webkit-box-sizing: border-box; | |
| -moz-box-sizing: border-box; | |
| box-sizing: border-box; | |
| } | |
| body h1, | |
| body h2, | |
| body h3, | |
| body h4, | |
| body h5, | |
| body h6 { | |
| margin-top: 1em; | |
| margin-bottom: 16px; | |
| font-weight: bold; | |
| line-height: 1.4; | |
| } | |
| body h1 { | |
| border-bottom: 1px solid var(--color-border-secondary); | |
| margin-bottom: 18px; | |
| } | |
| body h1 .anchor, | |
| body h2 .anchor, | |
| body h3 .anchor, | |
| body h4 .anchor, | |
| body h5 .anchor, | |
| body h6 .anchor { | |
| margin-left: -24px; | |
| visibility: hidden; | |
| } | |
| body h1:hover .anchor, | |
| body h2:hover .anchor, | |
| body h3:hover .anchor, | |
| body h4:hover .anchor, | |
| body h5:hover .anchor, | |
| body h6:hover .anchor { | |
| visibility: visible; | |
| } | |
| body p, | |
| body blockquote, | |
| body ul, | |
| body ol, | |
| body dl, | |
| body table, | |
| body pre { | |
| margin-top: 0; | |
| margin-bottom: 16px; | |
| } | |
| body h1 { | |
| margin: 0.67em 0; | |
| padding-bottom: 0.3em; | |
| font-size: 1.6em; | |
| line-height: 1.2; | |
| color: var(--color-text-primary); | |
| /* border-bottom: 1px solid var(--color-border-secondary); */ | |
| } | |
| body h2 { | |
| padding-bottom: 0.3em; | |
| font-size: 1.2em; | |
| line-height: 1.225; | |
| color: var(--color-text-primary); | |
| /* border-bottom: 1px solid var(--color-border-secondary); */ | |
| } | |
| body h3 { | |
| font-size: 1em; | |
| line-height: 1.43; | |
| color: var(--color-text-secondary); | |
| } | |
| body h4 { | |
| font-size: 0.8em; | |
| color: var(--color-text-tertiary); | |
| } | |
| body h5 { | |
| font-size: 0.6em; | |
| } | |
| body h6 { | |
| font-size: 0.5em; | |
| } | |
| body hr { | |
| margin-top: 20px; | |
| margin-bottom: 20px; | |
| height: 0; | |
| border: 0; | |
| border-top: 1px solid var(--color-border-primary, #ddd); | |
| } | |
| body ol, | |
| body ul { | |
| padding-left: 2em; | |
| } | |
| body ol ol, | |
| body ul ol { | |
| list-style-type: lower-roman; | |
| } | |
| body ol ul, | |
| body ul ul { | |
| list-style-type: circle; | |
| } | |
| body ol ul ul, | |
| body ul ul ul { | |
| list-style-type: square; | |
| } | |
| body ol { | |
| list-style-type: decimal; | |
| } | |
| body ul { | |
| list-style-type: disc; | |
| } | |
| body dl { | |
| margin-bottom: 1.3em; | |
| } | |
| body dl dt { | |
| font-weight: 700; | |
| } | |
| body dl dd { | |
| margin-left: 0; | |
| } | |
| body dl dd p { | |
| margin-bottom: 0.8em; | |
| } | |
| body blockquote { | |
| margin-left: 0; | |
| margin-right: 0; | |
| padding: 0 15px; | |
| color: var(--color-text-tertiary); | |
| border-left: 4px solid var(--color-markdown-blockquote-border); | |
| } | |
| body table { | |
| display: block; | |
| width: 100%; | |
| word-break: normal; | |
| word-break: keep-all; | |
| border-collapse: collapse; | |
| border-spacing: 0; | |
| } | |
| body table tr { | |
| background-color: var(--color-bg-primary); | |
| border-top: 1px solid var(--color-markdown-table-tr-border); | |
| } | |
| body table tr:nth-child(2n) { | |
| background-color: var(--color-bg-tertiary); | |
| } | |
| body table th, | |
| body table td { | |
| padding: 6px 13px; | |
| border: 1px solid var(--color-markdown-table-border); | |
| vertical-align: top; | |
| } | |
| body kbd { | |
| display: inline-block; | |
| padding: 5px 6px; | |
| font: | |
| 14px SFMono-Regular, | |
| Consolas, | |
| Liberation Mono, | |
| Menlo, | |
| monospace; | |
| line-height: 10px; | |
| color: var(--color-kbd-foreground); | |
| vertical-align: middle; | |
| background-color: var(--color-bg-secondary); | |
| border: 1px solid var(--color-border-tertiary); | |
| border-radius: 3px; | |
| box-shadow: inset 0 -1px 0 var(--color-border-tertiary); | |
| } | |
| body pre { | |
| word-wrap: normal; | |
| padding: 16px; | |
| font-size: 85%; | |
| line-height: 1.45; | |
| background-color: var(--color-bg-tertiary); | |
| -webkit-border-radius: 3px; | |
| border-radius: 3px; | |
| } | |
| body pre code { | |
| display: inline; | |
| max-width: initial; | |
| padding: 0; | |
| margin: 0; | |
| font-size: 100%; | |
| line-height: inherit; | |
| word-wrap: normal; | |
| white-space: pre; | |
| border: 0; | |
| -webkit-border-radius: 3px; | |
| border-radius: 3px; | |
| background-color: transparent; | |
| } | |
| body pre code:before, | |
| body pre code:after { | |
| content: normal; | |
| } | |
| body code { | |
| font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace; | |
| padding: 0; | |
| padding-top: 0.2em; | |
| padding-bottom: 0.2em; | |
| margin: 0; | |
| font-size: 85%; | |
| background-color: var(--color-markdown-code-bg); | |
| -webkit-border-radius: 3px; | |
| border-radius: 3px; | |
| } | |
| body code:before, | |
| body code:after { | |
| letter-spacing: -0.2em; | |
| content: "\00a0"; | |
| } | |
| body a { | |
| color: var(--color-text-link); | |
| text-decoration: none; | |
| background: transparent; | |
| } | |
| body img { | |
| max-width: 100%; | |
| max-height: 100%; | |
| } | |
| body figure { | |
| margin: 0; | |
| } | |
| body strong { | |
| font-weight: bold; | |
| } | |
| body em { | |
| font-style: italic; | |
| } | |
| body del { | |
| text-decoration: line-through; | |
| } | |
| .task-list-item { | |
| list-style-type: none; | |
| } | |
| .task-list-item input { | |
| font: | |
| 13px/1.4 Helvetica, | |
| arial, | |
| nimbussansl, | |
| liberationsans, | |
| freesans, | |
| clean, | |
| sans-serif, | |
| "Apple Color Emoji", | |
| "Segoe UI Emoji", | |
| "Segoe UI Symbol"; | |
| margin: 0 0.35em 0.25em -1.6em; | |
| vertical-align: middle; | |
| } | |
| .task-list-item input[disabled] { | |
| cursor: default; | |
| } | |
| .task-list-item input[type="checkbox"] { | |
| -webkit-box-sizing: border-box; | |
| -moz-box-sizing: border-box; | |
| box-sizing: border-box; | |
| padding: 0; | |
| } | |
| .task-list-item input[type="radio"] { | |
| -webkit-box-sizing: border-box; | |
| -moz-box-sizing: border-box; | |
| box-sizing: border-box; | |
| padding: 0; | |
| } | |
| body h1, | |
| body h2, | |
| body h3, | |
| body h4, | |
| body h4 { | |
| clear: both; | |
| } | |
| #page-header { | |
| display: none; | |
| } | |
| /* force page break on print */ | |
| .break { | |
| page-break-before: always; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment