Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Convert markdown files to other formats. Pure Go by default, extensible to new o
Currently supported:

- **PDF** (`.pdf`) — syntax-highlighted code blocks
- **HTML** (`.html`) — self-contained; local images embedded as data URIs (and remote images too with `-flatten`); syntax-highlighted code blocks. Diagrams render via inlined mermaid.js (or as static images with `-flatten`, e.g. for Google Docs import), or as inline SVG for D2 (rendered in-process, no browser)
- **HTML** (`.html`) — self-contained; local images embedded as data URIs (and remote images too with `-flatten`); syntax-highlighted code blocks. Diagrams render via inlined mermaid.js (or as pre-drawn inline SVG with `-flatten`, no JS runtime needed), or as inline SVG for D2 (rendered in-process, no browser)
- **Plain text** (`.txt`)
- **EPUB** (`.epub`) — EPUB3 ebook (validates with epubcheck). Shares the HTML renderer, so syntax-highlighted code carries over, and the stylesheet has a `prefers-color-scheme: dark` variant for readers' dark mode. Diagrams (Mermaid, D2, PlantUML) are inlined as SVG in a **light and a dark theme**, toggled by the reader's color scheme, so they stay legible in both (Mermaid needs a browser at convert time, like the PDF diagram path). A navigation TOC is built from the document's headings; `dc:title`/`dc:creator` come from `-title`/`-author`. Local images are packaged into the archive
- **DOCX** (`.docx`) — Word document, hand-built Office Open XML (pure Go, no new dependency). Named heading styles drive Word's Navigation pane / auto-TOC; native list numbering; GFM tables, blockquotes, thematic breaks; bold/italic/inline-code and hyperlinks; local images embedded as sized inline drawings; syntax-highlighted code as a bordered, shaded paragraph (chroma github theme). Diagrams (`-render`) are rasterized to embedded PNGs via a headless browser, falling back to a code block without one. Title/author metadata comes from `-title`/`-author`
Expand Down Expand Up @@ -110,7 +110,7 @@ md2 -f epub input.md # writes input.epub (EPUB3 ebook)
md2 -f docx input.md # writes input.docx (Word document)
md2 -f epub -author "Jane Doe" -title "My Manual" input.md # set title/author metadata (any format)
md2 -f pdf,html input.md # writes input.pdf and input.html
md2 -f html -render mermaid -flatten input.md # self-contained html, diagrams as images (Google Docs)
md2 -f html -render mermaid -flatten input.md # self-contained html, diagrams pre-drawn as svg
md2 -f html -render plantuml input.md # render plantuml diagrams via a PlantUML server
md2 -f html -css extra.css input.md # append custom CSS after the built-in stylesheet
md2 -o report.pdf input.md # explicit output (format from extension)
Expand All @@ -133,7 +133,7 @@ Flags:
- `-o` output file. Default: the input's name with the format extension. Cannot be combined with multiple formats. **Required when merging multiple inputs** (several files, or a directory) into one document.
- `-f` output format(s), comma-separated. Default: inferred from `-o` extension, else `pdf`. Duplicates are ignored.
- `-render` diagram renderer(s) to enable, comma-separated (`mermaid`, `d2`, `plantuml`), or `all`. Default: none — diagrams render as plain code unless enabled.
- `-flatten` (HTML only) flatten diagrams to static images instead of inlining mermaid.js, **and** fetch remote `http(s)` images and embed them as data URIs, for a fully self-contained file with no JS runtime or external assets needed to view it (e.g. importing into Google Docs). Requires a browser for diagrams, and — new in this flag — **network access at convert time for any document that references remote images** (so a doc with remote images no longer converts in an airgapped/offline environment under `-flatten`). A remote image that can't be fetched is left as a live reference with a warning, not a hard failure.
- `-flatten` (HTML only) draw diagrams once in a headless browser and keep the rendered inline SVG instead of inlining mermaid.js, **and** fetch remote `http(s)` images and embed them as data URIs, for a fully self-contained file with no JS runtime or external assets needed to view it (e.g. importing into Google Docs). Requires a browser for diagrams, and — new in this flag — **network access at convert time for any document that references remote images** (so a doc with remote images no longer converts in an airgapped/offline environment under `-flatten`). A remote image that can't be fetched is left as a live reference with a warning, not a hard failure.
- `-user-agent` `User-Agent` header sent when `-flatten` fetches remote images to embed. Default: a browser-like string, since some hosts reject the default Go client UA. Override for hosts with specific requirements.
- `-keep-diagram-source` keep the original diagram source in the output in addition to the rendered diagram: the rendered diagram is emitted first, immediately followed by the source as a code block. Default: off — a diagram replaces its source.
- `-plantuml-server` base URL of the PlantUML server used to render `plantuml` diagrams to SVG at build time. Default: the public `https://www.plantuml.com/plantuml`. PlantUML has no pure-Go renderer, so md2 encodes the diagram source and fetches the rendered SVG from this server (inlining it, so the output stays self-contained). This means the diagram source is sent to the server over the network — point it at a self-hosted server for offline or private use.
Expand Down Expand Up @@ -201,7 +201,7 @@ Alice -> Bob: hello

```sh
md2 -f html -render mermaid input.md # enable mermaid (interactive)
md2 -f html -render mermaid -flatten input.md # diagrams as static images
md2 -f html -render mermaid -flatten input.md # diagrams pre-drawn as static svg
md2 -f html -render d2 input.md # enable D2 (inline SVG)
md2 -f html -render plantuml input.md # enable PlantUML (server-rendered SVG)
md2 -f pdf -render all input.md # enable every supported renderer
Expand All @@ -214,10 +214,12 @@ Three renderers are supported, with different rendering models:
[mermaid](https://mermaid.js.org) library is inlined into the output (no
network access needed to view it) and the block renders to SVG in the browser
— interactive, but needing a JS runtime to display. With `-flatten`, md2
renders the document in a headless browser and replaces each diagram with a
static PNG image, producing a self-contained file that displays anywhere —
including a Google Docs import (upload the `.html` to Drive, then
"Open with > Google Docs"), which runs no JavaScript. `-flatten` also fetches
draws the document once in a headless browser and keeps each diagram as the
rendered inline SVG (the same mechanism the PDF path uses), dropping the
library: a static file that displays with no JavaScript, at any zoom. Note
that a Google Docs import (upload the `.html` to Drive, then "Open with >
Google Docs") does not render inline SVG — use `-f docx`, which embeds
diagrams as PNGs, for that route. `-flatten` also fetches
any remote `http(s)` images and embeds them as data URIs (needing network
access at convert time), so the output has no external asset dependencies at
all. In **PDF**, a mermaid
Expand Down Expand Up @@ -305,7 +307,7 @@ with no `.md` files is an error. The input must be *either* a single directory
| Format | Extension | Engine |
|--------|-----------|--------|
| `pdf` | `.pdf` | goldmark-pdf (pure Go), browser fallback (go-rod) |
| `html` | `.html` | goldmark (GFM), styled standalone document; local images embedded as data URIs; diagrams as mermaid.js (or, with `-flatten`, static PNGs via go-rod) or in-process D2 inline SVG |
| `html` | `.html` | goldmark (GFM), styled standalone document; local images embedded as data URIs; diagrams as mermaid.js (or, with `-flatten`, pre-drawn inline SVG via go-rod) or in-process D2 inline SVG |
| `txt` | `.txt` | goldmark AST walker, markup stripped, structure kept |
| `epub` | `.epub` | stdlib `archive/zip` EPUB3 container (pure Go); chapter shares the HTML pipeline; per-heading nested TOC; local images packaged; light/dark diagram variants |
| `docx` | `.docx` | stdlib `archive/zip` OOXML package (pure Go); goldmark AST → WordprocessingML; named heading styles (Navigation pane), native list numbering, tables, syntax-highlighted code in a bordered box (chroma `github` colors as run colors), local images embedded; enabled diagrams (`-render`) rasterized to embedded PNGs via headless browser |
Expand Down
7 changes: 4 additions & 3 deletions internal/cli/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func newFlagSet(o *options) *flag.FlagSet {
fs.StringVar(&o.output, "o", "", "output file (default: input's name with new extension; required when merging multiple inputs)")
fs.StringVar(&o.format, "f", "", fmt.Sprintf("output format(s), comma-separated %v (default: from -o extension, else pdf)", converter.Formats()))
fs.StringVar(&o.render, "render", "", fmt.Sprintf("diagram renderer(s) to enable, comma-separated %v or \"all\" (default: none)", htmlconv.SupportedDiagrams()))
fs.BoolVar(&o.flatten, "flatten", false, "flatten HTML diagrams to static images instead of inlining mermaid.js, and embed remote http(s) images as data URIs (self-contained, e.g. for Google Docs; needs a browser, and network access for docs with remote images)")
fs.BoolVar(&o.flatten, "flatten", false, "flatten HTML diagrams to pre-drawn inline SVG instead of inlining mermaid.js, and embed remote http(s) images as data URIs (self-contained, no JS runtime needed; needs a browser, and network access for docs with remote images)")
fs.StringVar(&o.userAgent, "user-agent", htmlconv.RemoteUserAgent, "User-Agent header sent when -flatten fetches remote images to embed")
fs.BoolVar(&o.keepDiagramSource, "keep-diagram-source", false, "keep the original diagram source in the output in addition to the rendered diagram (rendered first, then the source block)")
fs.StringVar(&o.cssPath, "css", "", "path to a CSS file appended after the built-in stylesheet in HTML output; also forces the browser-rendered PDF path, since the pure-Go PDF renderer has no CSS support")
Expand Down Expand Up @@ -71,8 +71,9 @@ func (o *options) apply() error {
htmlconv.PlantUMLServer = o.plantumlServer
}

// -flatten renders HTML diagrams to static images rather than inlining
// mermaid.js, for a self-contained file (e.g. importable into Google Docs).
// -flatten draws HTML diagrams once in a headless browser and keeps the
// rendered SVG rather than inlining mermaid.js, for a self-contained file
// that needs no JS runtime to view.
htmlconv.Flatten = o.flatten

// -user-agent overrides the User-Agent sent when -flatten embeds remote
Expand Down
115 changes: 58 additions & 57 deletions internal/converter/chrome/chrome.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
package chrome

import (
"encoding/base64"
"fmt"
"io"
"math"
Expand All @@ -25,11 +24,11 @@ import (

// Install the browser-backed hooks into the packages that need them. Those
// packages cannot import chrome (chrome imports them), so the hooks are wired
// here instead: the html -flatten diagram rasterizer, and the epub mermaid
// here instead: the html -flatten diagram flattener, and the epub mermaid
// renderer (an ebook reader has no JS runtime, so mermaid is pre-rendered to
// inline SVG).
func init() {
htmlconv.Rasterizer = Rasterize
htmlconv.DiagramFlattener = FlattenDiagrams
epub.MermaidRenderer = RenderMermaidSVG
docx.DiagramRasterizer = RenderDiagramPNG
}
Expand Down Expand Up @@ -135,12 +134,16 @@ func waitMermaid(page *rod.Page) {
}
}

// Rasterize loads a rendered HTML document in a headless browser, lets the
// inlined mermaid script draw every diagram to SVG, replaces each
// <pre class="mermaid"> with an <img> holding a PNG snapshot, strips the now-
// useless scripts, and returns the resulting static, self-contained HTML. It is
// installed as html.Rasterizer to back the -flatten path.
func Rasterize(doc []byte) (out []byte, err error) {
// FlattenDiagrams loads a rendered HTML document in a headless browser, lets the
// inlined mermaid script draw every diagram, strips the now-useless scripts, and
// returns the resulting static, self-contained HTML. It is installed as
// html.DiagramFlattener to back the -flatten path.
//
// This is the PDF path's mechanism: the browser draws each diagram as vector SVG
// and that SVG is what the output keeps. Nothing is measured, clipped or
// snapshotted, so no diagram can come out cut off at an edge, and it stays
// resolution-independent — where a raster snapshot has to pick a scale.
func FlattenDiagrams(doc []byte) (out []byte, err error) {
err = withPage(func(page *rod.Page) error {
if err := page.SetViewport(&proto.EmulationSetDeviceMetricsOverride{
Width: 1280, Height: 1024,
Expand All @@ -160,35 +163,14 @@ func Rasterize(doc []byte) (out []byte, err error) {
}

// Mermaid renders diagrams to SVG asynchronously; wait for it to settle
// before snapshotting so we capture the diagrams, not empty placeholders.
// A document without mermaid blocks (e.g. only d2, already inline SVG) has
// nothing to wait for, so skip the wait rather than eat its timeout.
// before reading the document back, so the output carries the drawn
// diagrams and not empty placeholders. A document without mermaid blocks
// (e.g. only d2, already inline SVG) has nothing to wait for, so skip the
// wait rather than eat its timeout.
if len(els) > 0 {
waitMermaid(page)
}

// Force a white page background so diagram snapshots carry an opaque white
// backdrop rather than transparency, keeping them legible wherever they land.
if _, err := page.Eval(`() => { document.body.style.background = '#fff'; }`); err != nil {
return fmt.Errorf("set background: %w", err)
}
for _, el := range els {
png, err := snapshotDiagram(page, el)
if err != nil {
return err
}
uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
// Replace the rendered <pre class="mermaid"> with a plain <img>. rod
// binds `this` to the element, so the arrow function can act on it.
if _, err := el.Eval(`(src) => {
const img = document.createElement('img');
img.src = src;
this.replaceWith(img);
}`, uri); err != nil {
return fmt.Errorf("inline diagram: %w", err)
}
}

// The mermaid library and init script are dead weight in a static document.
if _, err := page.Eval(`() => {
document.querySelectorAll('script').forEach((s) => s.remove());
Expand Down Expand Up @@ -315,19 +297,19 @@ const maxDiagramViewport = 8192
// (softer, but whole) rather than clipped.
const maxCaptureSide = 16384

// snapshotDiagram captures a single rendered diagram as a PNG.
// snapshotDiagram captures a single rendered diagram as a PNG, for the DOCX
// converter (Word needs a raster; HTML keeps the SVG instead — see
// FlattenDiagrams).
//
// The viewport is first resized to the diagram's natural size (from its
// viewBox): mermaid emits width:100% SVGs, so in a window-sized viewport a
// large diagram is squeezed to the window width — snapshotted blurry, and, when
// the clip then reaches past the viewport, cut off on the right and bottom by
// browsers that do not honour CaptureBeyondViewport. Laying it out at its own
// size keeps the whole diagram on screen and crisp.
//
// The capture itself uses an explicit clip (rod's Element.Screenshot grabs only
// the viewport and then crops) with CaptureBeyondViewport still set, which
// covers a diagram too big for maxDiagramViewport. Beyond that limit the device
// scale is lowered rather than the capture cut short — see captureScale.
// The diagram is pinned to the top-left of a viewport sized to it and scaled to
// fit, so the capture region is the whole viewport: (0,0,w,h). That is the same
// rectangle under every screenshot coordinate convention, whether or not the
// browser honours CaptureBeyondViewport and wherever the page happens to be
// scrolled — the clip cannot drift onto the surrounding page or off an edge of
// the diagram, which is what cut the top, bottom and right off earlier
// snapshots. It also fixes resolution: mermaid emits width:100% SVGs, so in a
// window-sized viewport a large diagram was laid out squeezed and snapshotted
// blurry (a 9400px-wide flowchart at 2368x12).
func snapshotDiagram(page *rod.Page, pre *rod.Element) ([]byte, error) {
// Prefer the rendered <svg>: it has a tight bounding box, avoiding the wide
// whitespace of the centered <pre>. Fall back to the <pre> if mermaid did
Expand All @@ -340,27 +322,46 @@ func snapshotDiagram(page *rod.Page, pre *rod.Element) ([]byte, error) {
nat, err := target.Eval(`() => {
const vb = this.viewBox && this.viewBox.baseVal;
const r = this.getBoundingClientRect();
const w = (vb && vb.width) ? vb.width : r.width;
const h = (vb && vb.height) ? vb.height : r.height;
// Leave room for whatever the page puts around the diagram (body margins,
// padding), so the resized viewport really does give it its natural width.
const gutter = Math.max(0, window.innerWidth - r.width);
return {w: w + gutter, h: h + gutter};
return {
w: (vb && vb.width) ? vb.width : r.width,
h: (vb && vb.height) ? vb.height : r.height,
scalable: !!(vb && vb.width && vb.height),
};
}`)
if err != nil {
return nil, fmt.Errorf("measure diagram: %w", err)
}
natW, natH := nat.Value.Get("w").Num(), nat.Value.Get("h").Num()
width := viewportDim(natW)

// A diagram past the viewport bound is drawn smaller so it still fits whole.
// Only a diagram with a viewBox can be scaled by CSS width alone without
// distorting it; without one the browser gets the natural size and the
// viewport bound does the clamping.
fit := 1.0
if nat.Value.Get("scalable").Bool() {
fit = math.Min(1, math.Min(maxDiagramViewport/natW, maxDiagramViewport/natH))
}
w, h := natW*fit, natH*fit

if err := page.SetViewport(&proto.EmulationSetDeviceMetricsOverride{
Width: width,
Height: viewportDim(natH),
DeviceScaleFactor: captureScale(natW, natH),
Width: viewportDim(w),
Height: viewportDim(h),
DeviceScaleFactor: captureScale(w, h),
}); err != nil {
return nil, fmt.Errorf("size viewport to diagram: %w", err)
}

box, err := measureAfterResize(target, width)
// Pin the diagram alone at the viewport origin, on an opaque white backdrop
// so it stays legible wherever it lands. Fixed positioning takes it out of
// the page flow, so nothing around it can shift it or bleed into the shot.
if _, err := target.Eval(`(w, scalable) => {
this.style.cssText = 'position:fixed;left:0;top:0;margin:0;padding:0;background:#fff;max-width:none;max-height:none'
+ (scalable ? ';width:' + w + 'px;height:auto' : '');
}`, w, nat.Value.Get("scalable").Bool()); err != nil {
return nil, fmt.Errorf("pin diagram: %w", err)
}

box, err := measureAfterResize(target, viewportDim(w))
if err != nil {
return nil, err
}
Expand Down
Loading