From b220ad6bb33661181b024551016e3adacb55d514 Mon Sep 17 00:00:00 2001 From: 135yshr Date: Mon, 21 Sep 2026 22:38:14 +0900 Subject: [PATCH 1/2] :memo: docs: bring CLAUDE.md and the package comment up to date after PPTX CLAUDE.md still described slideModeCSS, removed when decks got their own stylesheet, listed formats without pptx, and said stdin needs -o only for pdf and docx. The converter package comment still said PDF printing went through the Playwright Python driver. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 6 +++--- internal/converter/converter.go | 12 ++++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0e54095..ecee7e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ Uses golangci-lint with config in `.golangci.yml`, in **v2 format** — the conf 3. **html.go** — assembles a self-contained HTML file with GitHub CSS, `@font-face` declarations, and inlined SVGs 4. **pdf.go** — a headless Chromium prints the HTML to PDF, driven **directly over the DevTools Protocol** with chromedp. There is no Python stage: `resolvePrintOptions` converts `-page-size` and the `-margin-*` flags into the inches `Page.printToPDF` requires (`paper.go`), and `renderPDF` navigates to the `file://` URL, awaits `document.fonts.ready` — a Promise, so `WithAwaitPromise` is required, and printing before it settles lays the page out with fallback font metrics — then prints. The browser comes from `ChromiumPath`, the same resolution handed to `mmdc` via the generated Puppeteer config, so one Chromium serves both stages and `CHROME_PATH` overrides both -**Slide mode** (`Config.Slides` from `-slides`, or front-matter `marp: true` via `Converter.slideMode`) swaps `parseMarkdown` for `parseSlides`: `renderSlides` renders the document's **top-level** nodes one at a time and starts a new slide at each top-level `ThematicBreak`, so a `---` nested in a code block, list or blockquote never splits and the breaks themselves are not rendered. Mermaid placeholders are then replaced slide by slide, taking the first match in document order so two slides holding the same diagram each get their own. `parsedDoc.slides` is nil in document mode; in slide mode `parsedDoc.HTML` still holds everything concatenated, for `firstHeading` and `copyImages`, while `parsedDoc.body` wraps each slide in `
`. Page breaks are `break-before` on every slide after the first (`break-after` would add a blank page at the end), and `slideModeCSS` inserts those rules after `baseCSS` but before the `-css` files. **slidesize.go** sizes the deck: `resolveSlideSize` reads front-matter `size` (`16:9` = 1280×720, the default, or `4:3` = 960×720 — CSS pixels, as in Marp) and rejects anything else by listing the presets; `slidePrintOptions` turns it into a zero-margin page at 96 px/in; `slideSizeCSS` fixes each `
` to that size with `overflow: hidden` and strips `baseCSS`'s body padding and 900px column, without which every slide would shift inward and its bottom would land on a second page. The CLI records the paper flags given **explicitly** in `Config.PaperFlagsSet` (`flag.Visit`, since their defaults are non-empty), and `Convert` rejects them for a deck — the check lives in the converter because `marp: true` is only known after the file is read. A deck uses **slidecss.go**'s `slideBaseCSS` **instead of** `baseCSS` (`buildSlideCSS` → `assembleCSS`, which keeps the `@font-face` rules first and `-css` last); it carries the page rules too: `break-before` on every slide after the first (`break-after` would add a trailing blank page), `min-height: 1px` so an empty slide keeps its page, and no body margin or padding. **directives.go** recognizes Marp directives: `parseDirectiveComment` accepts a top-level HTML block only when it is exactly one comment whose YAML body is a mapping and **every** key is a known Marp directive name (optionally `_`-prefixed) — so `` stays a note, while an unimplemented `` is still consumed rather than leaking into the notes. `renderSlides` feeds them to `slideDirectives`: front-matter `class` applies from the first slide, `class` from its slide on, `_class` to its slide only, and the classes land on the `
`, escaped. Every other top-level single-comment HTML block becomes a **presenter note** (`slide.notes`, trimmed, joined by a blank line) and is removed from the slide. Only top-level blocks are considered, so a comment inside a list item or paragraph renders as before, and document mode never touches comments. **slideoverflow.go** reports what `overflow: hidden` cuts off: for a deck, `renderPDF` runs `measureSlidesJS` (scroll size vs client size per `section.slide` **and per descendant that clips its own overflow** — a `pre` does, and a long code line cut off there never reaches the slide's scroll size; 1px of slack for sub-pixel rounding) after `document.fonts.ready` and before printing, in the same page load, with the page emulated as **print** media so the measurement matches what is printed. `Convert` writes `overflowWarnings` straight to `errOut` — the one message that does **not** wait for `-v`, since the content is missing from the output and nothing else would say so. Document mode is never measured. A Mermaid diagram on a slide is fitted by CSS alone (`slideBaseCSS`): the `.diagram-wrapper` is a flex item with `flex: 0 1 auto; min-height: 0`, and because a flexed item inside a fixed-height container has a definite size, the SVG's `max-height: 100%` resolves against it. mmdc's own `width="100%"` + inline `max-width: px` is what keeps a small diagram from being enlarged, and its `viewBox` keeps the aspect ratio. A diagram **nested** in a list item or blockquote is not a flex item of the slide and has no definite height to fit, so `slideSizeCSS` caps it at the slide's content height (height less `slidePaddingYPx` twice) and repeats `max-height: 100%` for direct children after it, so they still win. The tests stand in for mmdc with an SVG of that exact shape (`mmdcLikeSVG`), since the real binary is only in CI. +**Slide mode** (`Config.Slides` from `-slides`, or front-matter `marp: true` via `Converter.slideMode`) swaps `parseMarkdown` for `parseSlides`: `renderSlides` renders the document's **top-level** nodes one at a time and starts a new slide at each top-level `ThematicBreak`, so a `---` nested in a code block, list or blockquote never splits and the breaks themselves are not rendered. Mermaid placeholders are then replaced slide by slide, taking the first match in document order so two slides holding the same diagram each get their own. `parsedDoc.slides` is nil in document mode; in slide mode `parsedDoc.HTML` still holds everything concatenated, for `firstHeading` and `copyImages`, while `parsedDoc.body` wraps each slide in `
`. **slidesize.go** sizes the deck: `resolveSlideSize` reads front-matter `size` (`16:9` = 1280×720, the default, or `4:3` = 960×720 — CSS pixels, as in Marp) and rejects anything else by listing the presets; `slidePrintOptions` turns it into a zero-margin page at 96 px/in; `slideSizeCSS` fixes each `
` to that size and declares it in `@page` too, so the HTML output printed straight from a browser gets slide-sized pages rather than the printer's paper (the PDF path passes the same size through CDP). The CLI records the paper flags given **explicitly** in `Config.PaperFlagsSet` (`flag.Visit`, since their defaults are non-empty), and `Convert` rejects them for a deck — the check lives in the converter because `marp: true` is only known after the file is read. A deck uses **slidecss.go**'s `slideBaseCSS` **instead of** `baseCSS` (`buildSlideCSS` → `assembleCSS`, which keeps the `@font-face` rules first and `-css` last); it carries the page rules too: `break-before` on every slide after the first (`break-after` would add a trailing blank page), `overflow: hidden` so an over-full slide is clipped rather than continued onto a second page, `min-height: 1px` so an empty slide keeps its page, and no body margin or padding — without which every slide would shift inward and its bottom would land on a second page. **directives.go** recognizes Marp directives: `parseDirectiveComment` accepts a top-level HTML block only when it is exactly one comment whose YAML body is a mapping and **every** key is a known Marp directive name (optionally `_`-prefixed) — so `` stays a note, while an unimplemented `` is still consumed rather than leaking into the notes. `renderSlides` feeds them to `slideDirectives`: front-matter `class` applies from the first slide, `class` from its slide on, `_class` to its slide only, and the classes land on the `
`, escaped. Every other top-level single-comment HTML block becomes a **presenter note** (`slide.notes`, trimmed, joined by a blank line) and is removed from the slide. Only top-level blocks are considered, so a comment inside a list item or paragraph renders as before, and document mode never touches comments. **slideoverflow.go** reports what `overflow: hidden` cuts off: for a deck, `renderPDF` runs `measureSlidesJS` (scroll size vs client size per `section.slide` **and per descendant that clips its own overflow** — a `pre` does, and a long code line cut off there never reaches the slide's scroll size; 1px of slack for sub-pixel rounding) after `document.fonts.ready` and before printing, in the same page load, with the page emulated as **print** media so the measurement matches what is printed. `Convert` writes `overflowWarnings` straight to `errOut` — the one message that does **not** wait for `-v`, since the content is missing from the output and nothing else would say so. Document mode is never measured. A Mermaid diagram on a slide is fitted by CSS alone (`slideBaseCSS`): the `.diagram-wrapper` is a flex item with `flex: 0 1 auto; min-height: 0`, and because a flexed item inside a fixed-height container has a definite size, the SVG's `max-height: 100%` resolves against it. mmdc's own `width="100%"` + inline `max-width: px` is what keeps a small diagram from being enlarged, and its `viewBox` keeps the aspect ratio. A diagram **nested** in a list item or blockquote is not a flex item of the slide and has no definite height to fit, so `slideSizeCSS` caps it at the slide's content height (height less `slidePaddingYPx` twice) and repeats `max-height: 100%` for direct children after it, so they still win. The tests stand in for mmdc with an SVG of that exact shape (`mmdcLikeSVG`), since the real binary is only in CI. `FormatHTML` is the same pipeline stopping after step 3: `Convert` points `buildHTML` at the caller's output path instead of the working directory and returns, so Chromium never runs. Image paths are deliberately **not** rewritten or copied — the default output sits beside the input, where the Markdown's own relative paths already resolve. @@ -92,9 +92,9 @@ With several inputs, `renderConsole` renders each document **independently** thr **input.go** reads documents: `readInput` takes the bytes from a file or, for `StdinPath` (`-`), from the `Converter.stdin` seam, and treats whitespace-only input as **empty and an error for stdin only** — an empty pipe is invisible and nearly always an upstream failure, whereas an empty file is something the caller pointed at directly, and erroring on it would break the "single input behaves as before" regression guard. `inputDir` returns the directory relative paths resolve against: the file's own directory, or the **working directory** for stdin. **frontmatter.go** sits on top: `readDocument` wraps `readInput` and `splitFrontMatter` strips a leading YAML block, so the single-document pipelines and console rendering all see the same body — DOCX and console included, which never parse it. The block counts only when it opens on the first line, is closed (`---` or `...`), and parses to a **mapping**: an unclosed block, one further down, or one holding a scalar such as `---\nSome text\n---` is left in place, because each rendered as Markdown before front-matter existed and removing it would delete text. The YAML parser is `go.yaml.in/yaml/v3`, the maintained continuation of the archived `gopkg.in/yaml.v3`. The front-matter `title` is HTML-escaped by `parsedDoc.title` before it reaches `text/template`, which escapes nothing. -**converter.go** orchestrates all three pipelines and manages a temporary working directory for intermediate files. `Convert` takes a **slice** of inputs; only console format renders more than one. **Config** struct holds all runtime options including `InputFiles` (a slice; `-` means stdin), `Format` ("pdf"|"html"|"docx"|"console"), `CSSFiles`, `DOCXFont`, and the console options (`ConsoleWidth`, `ConsoleStyle`, `ConsolePager`, `MermaidRender`, `MermaidScale`). +**converter.go** orchestrates every pipeline and manages a temporary working directory for intermediate files. `Convert` takes a **slice** of inputs; only console format renders more than one. **Config** struct holds all runtime options including `InputFiles` (a slice; `-` means stdin), `Format` ("pdf"|"html"|"pptx"|"docx"|"console"), `CSSFiles`, `DOCXFont`, the slide options (`Slides`, `PaperFlagsSet`), and the console options (`ConsoleWidth`, `ConsoleStyle`, `ConsolePager`, `MermaidRender`, `MermaidScale`). -**internal/cli/** — the command line, in an importable package so more than one binary can share it (`cmd/md2pdf/main.go` is a thin `main`: the ldflags-injected `version`/`commit`/`date` and a call to `cli.Run`). `-doctor` (`doctor.go` + `converter.Diagnose` in `diagnose.go`) inspects the environment and reports which formats can run, exiting non-zero when one is blocked. It resolves every tool through the **same** functions the pipeline uses (`ChromiumPath`, the configured-or-PATH lookup for mmdc and pandoc, `resolveFontRegular`), so the report cannot claim a readiness conversion would not deliver. Font lookup lives in `internal/cli/fonts.go` and searches **directories** for any of several filenames, because Noto Sans CJK ships under different ones: `font-noto-sans-cjk` installs a single `NotoSansCJK.ttc` collection, `font-noto-sans-cjk-jp` installs per-weight `NotoSansCJKjp-*.otf`, and Debian packages `NotoSansCJK-Regular.ttc`. `~/Library/Fonts` is searched first, since that is where a macOS font cask installs — omitting it was why an installed font was reported missing, and why the PDF silently fell back to Hiragino. Finding the collection is only half of it: CSS has no syntax for a face **inside** a collection, so `url('…/NotoSansCJK.ttc')` hands back that file's first face — `Noto Sans CJK JP Thin`, one of 45 — whatever weight the `@font-face` rule declares, which rendered whole documents in hairlines with no bold. `localFaceNames` therefore names the wanted face (`local('Noto Sans CJK JP Bold')` and the PostScript spelling) **ahead of** the `url()` in `fontFace`'s `src` list, so the system font manager resolves the weight and the file stays as a fallback for a font the system does not know. Only a **weightless** collection needs this; `NotoSansCJK-Bold.ttc` is a collection too, but its first face is the weight it is named for. Dependencies are classified rather than lumped together: `pandoc` blocks docx unconditionally, a Chromium blocks pdf, `mmdc` is **conditional** — a document with no Mermaid blocks converts without it, verified against the binary — and the CJK font is **optional**, degrading output instead of preventing it. Only unconditional needs appear in `formatRequirements`. `inputs.go` owns the input contract: `resolveInputs` validates **every** positional argument before any rendering starts (so a typo in the third of three paths fails immediately, naming it), rejects more than one input for non-console formats, refuses `-` mixed with file paths, and fails fast via `stdinUsable` when `-` is given but standard input is a terminal — reading a terminal would block with no indication of what md2pdf is waiting for. `resolveOutputPath` makes `-o` mandatory for `pdf`/`docx` read from stdin, since there is no input filename to derive one from. `flags.go` handles argument parsing and auto-detection of font/mmdc paths; `resolveFormat` derives the output format from `-format` or the `-o` extension (`.html`/`.htm` imply html), `-css` is a repeatable `flag.Value` (`cssFlag`) whose files are stat-checked at parse time so a typo fails before any rendering, folds the `term`/`terminal` aliases onto `console`, and rejects `-o` combined with console. Console `-style` values are validated up front by `converter.ValidateConsoleStyle`, and `-mermaid-render` by `converter.ValidateMermaidRenderMode`. `-mermaid-scale` is validated by `converter.ValidateMermaidScale` and **rejected** when combined with `-mermaid-render ascii`/`source` or with a non-console format: only the image step has a size to scale, so accepting it elsewhere would leave a flag silently doing nothing. Pandoc is resolved in `docx.go` (`findPandoc`) unless `-pandoc` is provided. `cli.go` holds the pieces every binary shares: `Run`, the unexported `program` value that carries the invoked name, the injected streams and the `diagnose` seam, and `run`, which returns an exit code. `parseFlags` therefore **never calls `os.Exit`** — `-version` and `-doctor` report their status as an `exitError` and `Run` turns it into the process exit code, which is what makes `-doctor`'s 0/1 testable. +**internal/cli/** — the command line, in an importable package so more than one binary can share it (`cmd/md2pdf/main.go` is a thin `main`: the ldflags-injected `version`/`commit`/`date` and a call to `cli.Run`). `-doctor` (`doctor.go` + `converter.Diagnose` in `diagnose.go`) inspects the environment and reports which formats can run, exiting non-zero when one is blocked. It resolves every tool through the **same** functions the pipeline uses (`ChromiumPath`, the configured-or-PATH lookup for mmdc and pandoc, `resolveFontRegular`), so the report cannot claim a readiness conversion would not deliver. Font lookup lives in `internal/cli/fonts.go` and searches **directories** for any of several filenames, because Noto Sans CJK ships under different ones: `font-noto-sans-cjk` installs a single `NotoSansCJK.ttc` collection, `font-noto-sans-cjk-jp` installs per-weight `NotoSansCJKjp-*.otf`, and Debian packages `NotoSansCJK-Regular.ttc`. `~/Library/Fonts` is searched first, since that is where a macOS font cask installs — omitting it was why an installed font was reported missing, and why the PDF silently fell back to Hiragino. Finding the collection is only half of it: CSS has no syntax for a face **inside** a collection, so `url('…/NotoSansCJK.ttc')` hands back that file's first face — `Noto Sans CJK JP Thin`, one of 45 — whatever weight the `@font-face` rule declares, which rendered whole documents in hairlines with no bold. `localFaceNames` therefore names the wanted face (`local('Noto Sans CJK JP Bold')` and the PostScript spelling) **ahead of** the `url()` in `fontFace`'s `src` list, so the system font manager resolves the weight and the file stays as a fallback for a font the system does not know. Only a **weightless** collection needs this; `NotoSansCJK-Bold.ttc` is a collection too, but its first face is the weight it is named for. Dependencies are classified rather than lumped together: `pandoc` blocks docx unconditionally, a Chromium blocks pdf and pptx, `mmdc` is **conditional** — a document with no Mermaid blocks converts without it, verified against the binary — and the CJK font is **optional**, degrading output instead of preventing it. Only unconditional needs appear in `formatRequirements`. `inputs.go` owns the input contract: `resolveInputs` validates **every** positional argument before any rendering starts (so a typo in the third of three paths fails immediately, naming it), rejects more than one input for non-console formats, refuses `-` mixed with file paths, and fails fast via `stdinUsable` when `-` is given but standard input is a terminal — reading a terminal would block with no indication of what md2pdf is waiting for. `resolveOutputPath` makes `-o` mandatory for `pdf`/`html`/`pptx`/`docx` read from stdin, since there is no input filename to derive one from. `flags.go` handles argument parsing and auto-detection of font/mmdc paths; `resolveFormat` derives the output format from `-format` or the `-o` extension (`.html`/`.htm` imply html, `.pptx` pptx), `-css` is a repeatable `flag.Value` (`cssFlag`) whose files are stat-checked at parse time so a typo fails before any rendering, folds the `term`/`terminal` aliases onto `console`, and rejects `-o` combined with console. Console `-style` values are validated up front by `converter.ValidateConsoleStyle`, and `-mermaid-render` by `converter.ValidateMermaidRenderMode`. `-mermaid-scale` is validated by `converter.ValidateMermaidScale` and **rejected** when combined with `-mermaid-render ascii`/`source` or with a non-console format: only the image step has a size to scale, so accepting it elsewhere would leave a flag silently doing nothing. Pandoc is resolved in `docx.go` (`findPandoc`) unless `-pandoc` is provided. `cli.go` holds the pieces every binary shares: `Run`, the unexported `program` value that carries the invoked name, the injected streams and the `diagnose` seam, and `run`, which returns an exit code. `parseFlags` therefore **never calls `os.Exit`** — `-version` and `-doctor` report their status as an `exitError` and `Run` turns it into the process exit code, which is what makes `-doctor`'s 0/1 testable. **Multi-call dispatch.** The binary answers to a second name, `mdview`, under which it defaults to `-format console` (`name.go`). The format is resolved **`-format` > the `-o` extension > the name > `pdf`**, so `mdview -o out.pdf` still writes a PDF. `programName` reads **`os.Args[0]`, never `os.Executable`** — the latter resolves the symlink back to `md2pdf` and the mechanism would silently do nothing. `resolveFormat` rejects an `-o` path that names no format when the default is console, because `resolveOutputPath` returns no path for console output and the flag would otherwise be discarded in silence. Help text is assembled in `usage.go` from three pieces: a shared options/inputs body filled in with the default format, plus a synopsis and an examples list chosen per default format, so an `mdview` user is not shown a screen of `-format console` examples. `internal/converter` knows nothing about any of this and stays name-agnostic. diff --git a/internal/converter/converter.go b/internal/converter/converter.go index 82c2810..28a6a39 100644 --- a/internal/converter/converter.go +++ b/internal/converter/converter.go @@ -1,13 +1,17 @@ -// Package converter turns Markdown into PDF, DOCX, or styled terminal output. +// Package converter turns Markdown into PDF, HTML, PPTX, DOCX, or styled +// terminal output. // // The PDF pipeline consists of three stages: // 1. Parse Markdown and extract fenced Mermaid code blocks. // 2. Render each Mermaid block to an SVG file using the mmdc CLI. // 3. Build a self-contained GitHub-styled HTML file and print it to PDF -// using a headless Chromium browser (via the Playwright Python driver). +// with a headless Chromium driven over the DevTools Protocol. // -// DOCX output is produced directly from Markdown by pandoc, and console output -// is rendered to ANSI text by glamour; both bypass the HTML stage. +// HTML output stops after the third stage's HTML file. PPTX output renders the +// same HTML as a slide deck and captures each slide with that Chromium instead +// of printing it. DOCX output is produced directly from Markdown by pandoc, and +// console output is rendered to ANSI text by glamour; both bypass the HTML +// stage. package converter import ( From 9449d8cd237a876e4e2603c9e58a13ec55be490e Mon Sep 17 00:00:00 2001 From: 135yshr Date: Mon, 21 Sep 2026 22:41:27 +0900 Subject: [PATCH 2/2] :memo: docs: say HTML output keeps local images external Codex review on #90. Co-Authored-By: Claude Opus 5 (1M context) --- internal/converter/converter.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/converter/converter.go b/internal/converter/converter.go index 28a6a39..df00da4 100644 --- a/internal/converter/converter.go +++ b/internal/converter/converter.go @@ -7,7 +7,9 @@ // 3. Build a self-contained GitHub-styled HTML file and print it to PDF // with a headless Chromium driven over the DevTools Protocol. // -// HTML output stops after the third stage's HTML file. PPTX output renders the +// HTML output stops after building that HTML file and writes it to the output +// path. It is self-contained except for local images, which keep the relative +// paths the Markdown gave them rather than being copied. PPTX output renders the // same HTML as a slide deck and captures each slide with that Chromium instead // of printing it. DOCX output is produced directly from Markdown by pandoc, and // console output is rendered to ANSI text by glamour; both bypass the HTML