Skip to content

Instantly share code, notes, and snippets.

@eduardoarandah
Created June 3, 2026 13:12
Show Gist options
  • Select an option

  • Save eduardoarandah/83b91d838dccbf9f38394693f5f47134 to your computer and use it in GitHub Desktop.

Select an option

Save eduardoarandah/83b91d838dccbf9f38394693f5f47134 to your computer and use it in GitHub Desktop.
neovim print to pdf using markdown
-- 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
/*
* 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