pdfmd-cli 3.11.1__tar.gz → 3.15.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pdfmd-cli
3
- Version: 3.11.1
3
+ Version: 3.15.0
4
4
  Summary: One command from Markdown to a good-looking PDF: a Pandoc wrapper with smart defaults, project-file discovery and a multi-engine fallback chain
5
5
  Author: Ali Perdekhan
6
6
  License-Expression: MIT
@@ -20,6 +20,8 @@ Dynamic: license-file
20
20
 
21
21
  # pdfmd
22
22
 
23
+ [![Test](https://github.com/aliperdehan/pdfmd/actions/workflows/publish.yml/badge.svg)](https://github.com/aliperdehan/pdfmd/actions/workflows/publish.yml) [![PyPI](https://img.shields.io/pypi/v/pdfmd-cli)](https://pypi.org/project/pdfmd-cli/)
24
+
23
25
  **One command from Markdown to a good-looking PDF.** `pdfmd` wraps
24
26
  [Pandoc](https://pandoc.org) and fills in everything you would otherwise
25
27
  have to remember: sensible fonts and margins, the right Markdown dialect,
@@ -92,6 +94,16 @@ brew install --cask mactex-no-gui # optional: full LaTeX (large)
92
94
  sudo apt install pipx pandoc texlive-xetex # Debian/Ubuntu
93
95
  ```
94
96
 
97
+ ```powershell
98
+ py -m pip install --user pipx; py -m pipx ensurepath # Windows
99
+ winget install --id JohnMacFarlane.Pandoc; winget install --id Typst.Typst
100
+ ```
101
+
102
+ Every push is tested on Windows, macOS and Linux (Pandoc + Typst: single
103
+ files, CSV tables, a book, HTML output); see the
104
+ [Test and publish workflow](https://github.com/aliperdehan/pdfmd/actions/workflows/publish.yml).
105
+ LaTeX engines aren't part of that automated test on Windows yet.
106
+
95
107
  Optional extras: [Quarto](https://quarto.org) for `.qmd` files,
96
108
  `pandoc-crossref` for `@fig:`/`@tbl:` references, and LibreOffice for
97
109
  Office files.
@@ -1,5 +1,7 @@
1
1
  # pdfmd
2
2
 
3
+ [![Test](https://github.com/aliperdehan/pdfmd/actions/workflows/publish.yml/badge.svg)](https://github.com/aliperdehan/pdfmd/actions/workflows/publish.yml) [![PyPI](https://img.shields.io/pypi/v/pdfmd-cli)](https://pypi.org/project/pdfmd-cli/)
4
+
3
5
  **One command from Markdown to a good-looking PDF.** `pdfmd` wraps
4
6
  [Pandoc](https://pandoc.org) and fills in everything you would otherwise
5
7
  have to remember: sensible fonts and margins, the right Markdown dialect,
@@ -72,6 +74,16 @@ brew install --cask mactex-no-gui # optional: full LaTeX (large)
72
74
  sudo apt install pipx pandoc texlive-xetex # Debian/Ubuntu
73
75
  ```
74
76
 
77
+ ```powershell
78
+ py -m pip install --user pipx; py -m pipx ensurepath # Windows
79
+ winget install --id JohnMacFarlane.Pandoc; winget install --id Typst.Typst
80
+ ```
81
+
82
+ Every push is tested on Windows, macOS and Linux (Pandoc + Typst: single
83
+ files, CSV tables, a book, HTML output); see the
84
+ [Test and publish workflow](https://github.com/aliperdehan/pdfmd/actions/workflows/publish.yml).
85
+ LaTeX engines aren't part of that automated test on Windows yet.
86
+
75
87
  Optional extras: [Quarto](https://quarto.org) for `.qmd` files,
76
88
  `pandoc-crossref` for `@fig:`/`@tbl:` references, and LibreOffice for
77
89
  Office files.
@@ -449,7 +449,7 @@ Automatic source backups (--backup, v3.8.0; formats v3.9.0):
449
449
  # unreliable 1.x history from those gaps, versioning restarts at 2.0.0 here
450
450
  # (2026-09-16, the author's call) as an honest baseline: this is where real
451
451
  # changelog tracking begins, not a claim about how many changes preceded it.
452
- PDFMD_VERSION = "3.11.1"
452
+ PDFMD_VERSION = "3.15.0"
453
453
  import argparse
454
454
  import filecmp
455
455
  from fnmatch import fnmatchcase
@@ -1301,7 +1301,8 @@ def resolve_soffice() -> str | None:
1301
1301
  fine for the user every day at a terminal prompt. Checked, in order:
1302
1302
  `soffice` on PATH, `libreoffice` on PATH (the usual Linux package
1303
1303
  name -- often a symlink to the same binary), then the standard macOS
1304
- app-bundle path directly.
1304
+ app-bundle path directly, or on Windows the default install folders
1305
+ under %ProgramFiles% / %ProgramFiles(x86)%.
1305
1306
  """
1306
1307
  for name in ("soffice", "libreoffice"):
1307
1308
  found = which(name)
@@ -1311,6 +1312,12 @@ def resolve_soffice() -> str | None:
1311
1312
  mac_path = Path("/Applications/LibreOffice.app/Contents/MacOS/soffice")
1312
1313
  if mac_path.exists():
1313
1314
  return str(mac_path)
1315
+ if sys.platform == "win32":
1316
+ # The Windows installer doesn't add LibreOffice to PATH.
1317
+ for variable in ("ProgramFiles", "ProgramFiles(x86)"):
1318
+ base = os.environ.get(variable)
1319
+ if base and (Path(base) / "LibreOffice" / "program" / "soffice.exe").exists():
1320
+ return str(Path(base) / "LibreOffice" / "program" / "soffice.exe")
1314
1321
  return None
1315
1322
 
1316
1323
 
@@ -1578,14 +1585,39 @@ def wrap_latex_header_includes(text: str) -> str:
1578
1585
 
1579
1586
 
1580
1587
  @contextmanager
1581
- def prepared_latex_inputs(paths: list[Path], latex_engine: bool) -> Iterator[list[Path]]:
1582
- """Yield temporary, corrected inputs for LaTeX-family renders when needed."""
1588
+ def prepared_latex_inputs(paths: list[Path], latex_engine: bool,
1589
+ typst_engine: bool = False, doc_count: int = 1) -> Iterator[list[Path]]:
1590
+ """Yield temporary, corrected inputs for LaTeX-family renders when
1591
+ needed -- or, with `typst_engine` instead, to make a margin setting
1592
+ written for the OTHER engine family reach Typst's own template (see
1593
+ fix_typst_margin()). The two are mutually exclusive; at most one
1594
+ corrector ever applies to a given render, same as `latex_engine` alone
1595
+ before this.
1596
+
1597
+ `paths[:doc_count]` are the main document(s) being rendered -- a
1598
+ single `title_source` everywhere except report/book mode, where every
1599
+ chapter file counts (``doc_count=len(files)``, passed explicitly by
1600
+ those two call sites; every other call site keeps the default of 1).
1601
+ Every path after that is a linked --metadata-file, which Pandoc
1602
+ accepts EITHER fenced (``---``/``...``-delimited, same as a
1603
+ document's own front matter) OR completely bare (no delimiters at
1604
+ all -- see the Pandoc manual's own "metadata file" section).
1605
+ fix_typst_margin() is told which is which (`is_metadata`) so it can
1606
+ safely treat a bare metadata file as pure YAML, while a bare MAIN
1607
+ DOCUMENT (no delimiters at all) is definitely markdown body text,
1608
+ never YAML, and is always left alone regardless.
1609
+ """
1583
1610
  temporary_paths: list[Path] = []
1584
1611
  prepared: list[Path] = []
1585
1612
  try:
1586
- for path in paths:
1613
+ for index, path in enumerate(paths):
1587
1614
  text = path.read_text(encoding="utf-8-sig")
1588
- corrected = wrap_latex_header_includes(text) if latex_engine else text
1615
+ if latex_engine:
1616
+ corrected = wrap_latex_header_includes(text)
1617
+ elif typst_engine:
1618
+ corrected = fix_typst_margin(text, is_metadata=(index >= doc_count))
1619
+ else:
1620
+ corrected = text
1589
1621
  if corrected == text:
1590
1622
  prepared.append(path)
1591
1623
  continue
@@ -1942,6 +1974,189 @@ def has_geometry(md_path: Path, variables: list[str], metadata_files: list[Path]
1942
1974
  for preamble in preamble_files)
1943
1975
 
1944
1976
 
1977
+ # Shared by frontmatter_margin_geometry_options() and fix_typst_margin()
1978
+ # below to translate a margin setting written for ONE engine family into
1979
+ # the variable the OTHER one actually reads -- geometry: (LaTeX) and
1980
+ # margin: (Typst) each mean nothing at all to the other's template, so a
1981
+ # document written/tested against one engine silently loses its margin
1982
+ # the moment it's rendered through the other (has_geometry() already
1983
+ # stops pdfmd's own DEFAULT_MARGIN from stepping on either, which is
1984
+ # correct, but does nothing to make the document's OWN setting reach the
1985
+ # engine that can't read it natively). Both directions normalize through
1986
+ # one canonical shape: a {top, bottom, left, right} dict of raw dimension
1987
+ # strings, physical sides only -- Typst's binding-aware `inside`/`outside`
1988
+ # and `rest` have no LaTeX equivalent at all, so a `margin:` mapping using
1989
+ # any of those three is left completely alone rather than guessed at.
1990
+ MARGIN_SIDE_ALIASES = {
1991
+ "x": ("left", "right"),
1992
+ "y": ("top", "bottom"),
1993
+ "top": ("top",),
1994
+ "bottom": ("bottom",),
1995
+ "left": ("left",),
1996
+ "right": ("right",),
1997
+ }
1998
+
1999
+ # geometry package option names -- includes the older t/b/l/r-margin
2000
+ # spellings alongside the more common ones actually seen in practice.
2001
+ GEOMETRY_SIDE_ALIASES = {
2002
+ "margin": ("top", "bottom", "left", "right"),
2003
+ "hmargin": ("left", "right"),
2004
+ "vmargin": ("top", "bottom"),
2005
+ "top": ("top",), "tmargin": ("top",),
2006
+ "bottom": ("bottom",), "bmargin": ("bottom",),
2007
+ "left": ("left",), "lmargin": ("left",),
2008
+ "right": ("right",), "rmargin": ("right",),
2009
+ }
2010
+
2011
+
2012
+ def parse_margin_sides(value) -> dict[str, str] | None:
2013
+ """Normalize a parsed (via PyYAML) Typst-shaped ``margin:`` value into
2014
+ a ``{top, bottom, left, right}`` dict of raw dimension strings -- or
2015
+ None when `value` is a scalar-shaped None/absent, or a mapping that
2016
+ uses ``inside``/``outside``/``rest`` (Typst's own binding-aware
2017
+ margins, with no LaTeX equivalent) or any other key this doesn't
2018
+ recognize, or isn't a scalar or mapping at all.
2019
+
2020
+ A scalar (already the case fix_typst_margin() itself exists to
2021
+ correct on the Typst side) is uniform on all four sides.
2022
+ """
2023
+ if isinstance(value, (str, int, float)) and not isinstance(value, bool):
2024
+ side_value = str(value)
2025
+ return {"top": side_value, "bottom": side_value, "left": side_value, "right": side_value}
2026
+ if not isinstance(value, dict):
2027
+ return None
2028
+ sides: dict[str, str] = {}
2029
+ for key, side_value in value.items():
2030
+ aliases = MARGIN_SIDE_ALIASES.get(str(key).strip().casefold())
2031
+ if not aliases:
2032
+ return None
2033
+ for side in aliases:
2034
+ sides[side] = str(side_value)
2035
+ return sides or None
2036
+
2037
+
2038
+ def parse_geometry_sides(value) -> dict[str, str] | None:
2039
+ """Normalize a parsed (via PyYAML) ``geometry:`` value -- a bare
2040
+ "key=val" scalar, a comma-joined "key=val,key=val" scalar, or a YAML
2041
+ list of such strings, the three shapes Pandoc's own LaTeX template
2042
+ accepts -- into a ``{top, bottom, left, right}`` dict of raw dimension
2043
+ strings, or None when it contains anything this can't safely
2044
+ interpret: an option with no ``key=`` at all, or a key that isn't
2045
+ about a margin (``includehead``, ``showframe``, ...) -- the geometry
2046
+ package has many options that aren't page margins, and guessing wrong
2047
+ on those would be worse than leaving Typst's own defaults in place.
2048
+ """
2049
+ if isinstance(value, str):
2050
+ items = [value]
2051
+ elif isinstance(value, list) and all(isinstance(item, str) for item in value):
2052
+ items = value
2053
+ else:
2054
+ return None
2055
+ sides: dict[str, str] = {}
2056
+ for item in items:
2057
+ for part in item.split(","):
2058
+ part = part.strip()
2059
+ if not part:
2060
+ continue
2061
+ if "=" not in part:
2062
+ return None
2063
+ key, _, side_value = part.partition("=")
2064
+ aliases = GEOMETRY_SIDE_ALIASES.get(key.strip().casefold())
2065
+ if not aliases:
2066
+ return None
2067
+ for side in aliases:
2068
+ sides[side] = side_value.strip()
2069
+ return sides or None
2070
+
2071
+
2072
+ def sides_to_geometry_options(sides: dict[str, str]) -> list[str]:
2073
+ """Format a {top,bottom,left,right} dict as the geometry: option list
2074
+ Pandoc's LaTeX template expects -- collapsed to one ``margin=...``
2075
+ when all four sides given are equal, one option per side otherwise
2076
+ (only the sides actually present -- a mapping that only set ``x:``
2077
+ leaves top/bottom to LaTeX's own default, same as it would for Typst).
2078
+ """
2079
+ if len(sides) == 4 and len(set(sides.values())) == 1:
2080
+ return [f"margin={next(iter(sides.values()))}"]
2081
+ return [f"{side}={sides[side]}" for side in ("top", "bottom", "left", "right") if side in sides]
2082
+
2083
+
2084
+ def frontmatter_margin_geometry_options(md_path: Path, variables: list[str],
2085
+ metadata_files: list[Path] = ()) -> list[str] | None:
2086
+ """Return the geometry: option list a document's own (or a linked
2087
+ --metadata-file's) ``margin:`` value translates to for LaTeX-family
2088
+ engines -- or None when there's nothing to translate.
2089
+
2090
+ ``margin:`` is a real Pandoc variable for the TYPST template only (see
2091
+ fix_typst_margin() below) -- Pandoc's LaTeX template never reads it at
2092
+ all, only ``geometry:`` does. has_geometry() already treats a bare
2093
+ ``margin:`` (in the document's own front matter OR a metadata file --
2094
+ it checks both) as "a margin setting exists" (correctly, so
2095
+ DEFAULT_MARGIN isn't injected on top of it), but nothing translated
2096
+ that value into the one variable LaTeX's own template actually
2097
+ consumes -- so a document with ONLY ``margin: 2.54cm`` (or a
2098
+ ``top:``/``bottom:``/``left:``/``right:``/``x:``/``y:`` breakdown) and
2099
+ no ``geometry:`` silently kept LaTeX's own much wider article-class
2100
+ default margins on every LaTeX-family engine, the requested value
2101
+ never taking effect at all -- confirmed directly (2026-09-28) alongside
2102
+ the Typst-side bug the scalar case triggers (see fix_typst_margin()).
2103
+
2104
+ Same precedence pdf-engine resolution uses (see
2105
+ frontmatter_pdfmd_options()'s callers): the document's own front
2106
+ matter is checked first; if it sets no ``margin:`` at all, each
2107
+ linked --metadata-file is checked next, in order, so a shared
2108
+ metadata.yaml can set ``margin:`` once for every document that finds
2109
+ it -- leaving it out entirely, the way this function's own first
2110
+ version did, meant a document with margin ONLY in a shared metadata
2111
+ file lost it silently on both engine families at once, worse than the
2112
+ bug this function exists to fix. A real ``geometry:`` anywhere -- the
2113
+ document's own front matter, any metadata file, or -V -- always wins,
2114
+ untouched, checked before any ``margin:`` in any source, same as
2115
+ has_geometry().
2116
+
2117
+ Returns None when: no ``margin:`` in any source; a real ``geometry:``
2118
+ is set anywhere; PyYAML isn't installed (falls back to the
2119
+ scalar-only regex this function used before parse_margin_sides()
2120
+ existed, since a real parse is needed for the mapping case -- checked
2121
+ per source, same order); or every ``margin:`` found uses Typst's
2122
+ ``inside``/``outside``/``rest`` keys, which have no LaTeX equivalent
2123
+ (see parse_margin_sides()).
2124
+ """
2125
+ if any(variable.startswith(("geometry=", "geometry:")) for variable in variables):
2126
+ return None
2127
+ text = md_path.read_text(encoding="utf-8-sig")
2128
+ front_matter = re.match(r"^---\s*\n(.*?)\n---\s*(?:\n|$)", text, re.DOTALL)
2129
+ blocks = [front_matter.group(1)] if front_matter else []
2130
+ for metadata_file in metadata_files:
2131
+ meta_text = metadata_file.read_text(encoding="utf-8-sig")
2132
+ # A --metadata-file may be fenced (---...---, same as front matter)
2133
+ # or completely bare -- Pandoc accepts either. yaml.safe_load()
2134
+ # chokes on the fenced shape as-is (a second "---" reads as a
2135
+ # second YAML document, "expected a single document in the
2136
+ # stream"), so unwrap it the same way md_path's own front matter
2137
+ # is, and only fall back to the raw text when it isn't fenced.
2138
+ meta_front_matter = re.match(r"^---\s*\n(.*?)\n---\s*(?:\n|$)", meta_text, re.DOTALL)
2139
+ blocks.append(meta_front_matter.group(1) if meta_front_matter else meta_text)
2140
+ if any(re.search(r"^geometry\s*:", block, re.MULTILINE) for block in blocks):
2141
+ return None
2142
+ for block in blocks:
2143
+ if yaml is None:
2144
+ match = re.search(r"^margin\s*:[ \t]*(\S.*?)[ \t]*$", block, re.MULTILINE)
2145
+ if not match:
2146
+ continue
2147
+ value = match.group(1).strip().strip("'\"")
2148
+ return [f"margin={value}"] if value else None
2149
+ try:
2150
+ data = yaml.safe_load(block)
2151
+ except yaml.YAMLError:
2152
+ continue
2153
+ if not isinstance(data, dict) or "margin" not in data:
2154
+ continue
2155
+ sides = parse_margin_sides(data["margin"])
2156
+ return sides_to_geometry_options(sides) if sides else None
2157
+ return None
2158
+
2159
+
1945
2160
  def pagesize_typo_value(md_path: Path, variables: list[str]) -> str | None:
1946
2161
  """Return a document's own ``pagesize:`` value, IF ``papersize:`` isn't
1947
2162
  already set anywhere Pandoc would see it -- a -V variable or the
@@ -2024,6 +2239,112 @@ def typst_papersize_translation(value: str | None) -> str | None:
2024
2239
  return TYPST_PAPERSIZE_ALIASES.get(value.strip().casefold())
2025
2240
 
2026
2241
 
2242
+ TYPST_MARGIN_RE = re.compile(r"^(?P<indent>[ \t]*)margin(?P<colon>[ \t]*:[ \t]*)(?P<val>\S.*?)[ \t]*$",
2243
+ re.MULTILINE)
2244
+
2245
+
2246
+ def sides_to_typst_margin_yaml(sides: dict[str, str], indent: str, newline: str) -> str:
2247
+ """Format a {top,bottom,left,right} dict as the YAML ``margin:``
2248
+ mapping Pandoc's Typst template needs -- collapsed to ``x:``/``y:``
2249
+ when all four sides given are equal, one line per side otherwise
2250
+ (only the sides actually present, in a fixed top/bottom/left/right
2251
+ order for a stable, readable rewrite)."""
2252
+ if len(sides) == 4 and len(set(sides.values())) == 1:
2253
+ value = next(iter(sides.values()))
2254
+ return f"{indent}margin:{newline}{indent} x: {value}{newline}{indent} y: {value}"
2255
+ lines = [f"{indent}margin:"]
2256
+ lines += [f"{indent} {side}: {sides[side]}" for side in ("top", "bottom", "left", "right")
2257
+ if side in sides]
2258
+ return newline.join(lines)
2259
+
2260
+
2261
+ def fix_typst_margin(text: str, is_metadata: bool = False) -> str:
2262
+ """Make a document's or linked --metadata-file's own page-margin
2263
+ setting reach Pandoc's Typst template, whichever engine family it was
2264
+ actually written for.
2265
+
2266
+ Two distinct problems, both from the same root cause -- ``margin:``
2267
+ is a real Pandoc variable for the TYPST template only, and
2268
+ ``geometry:`` for the LATEX template only, neither reads the other's
2269
+ key at all:
2270
+
2271
+ 1. A bare scalar ``margin:`` (``margin: 2.54cm``) is rewritten into
2272
+ the ``x:``/``y:`` mapping Typst's template requires -- it always
2273
+ renders margin as ``($for(margin/pairs)$...$endfor$)``, iterating
2274
+ key/value PAIRS a scalar has none of, so it comes out as the
2275
+ literal, invalid ``margin: (: ,)``: a Typst syntax error
2276
+ ("unexpected comma") instead of a page margin. Confirmed directly
2277
+ (2026-09-28): a document with ``margin: 2.54cm`` and
2278
+ ``pdfmd-options: {engine: typst}`` failed outright with exactly
2279
+ that error, while rendering fine (albeit at the WRONG margin --
2280
+ see frontmatter_margin_geometry_options()) on every LaTeX-family
2281
+ engine.
2282
+
2283
+ 2. A document with ONLY a ``geometry:`` (no ``margin:`` at all) --
2284
+ written for and tested against a LaTeX-family engine -- reaches
2285
+ Typst with no margin variable set at all: Typst's template never
2286
+ reads ``geometry:``, so it silently falls back to its own built-in
2287
+ default (1.25in) instead of erroring, the same "wrong margin, no
2288
+ warning" failure frontmatter_margin_geometry_options() fixes in the
2289
+ other direction. Parsed via parse_geometry_sides() and added as a
2290
+ new ``margin:`` mapping -- ``geometry:`` itself is left in place;
2291
+ it's inert for Typst, not harmful.
2292
+
2293
+ Neither fix touches a ``margin:`` that's already a YAML mapping --
2294
+ Pandoc's ``/pairs`` filter already turns that straight into valid
2295
+ Typst key/value pairs, and it may use Typst's own ``inside``/
2296
+ ``outside``/``rest`` keys, which parse_margin_sides() can't safely
2297
+ reinterpret but Typst's own template needs no help with anyway.
2298
+
2299
+ `is_metadata` marks a linked --metadata-file (passed by
2300
+ prepared_latex_inputs() using its own `doc_count`), which Pandoc
2301
+ accepts EITHER fenced (front-matter-shaped, handled the same as a
2302
+ document below) OR completely bare -- no ``---``/``...`` delimiters
2303
+ at all, just the YAML directly. A bare MAIN document (`is_metadata`
2304
+ False) is always left alone instead: with no front matter at all,
2305
+ that's markdown BODY text, never YAML, and must never be parsed as
2306
+ such.
2307
+ """
2308
+ front_matter = re.match(r"^---\s*\n(.*?)\n---\s*(?:\n|$)", text, re.DOTALL)
2309
+ if front_matter:
2310
+ block = front_matter.group(1)
2311
+ prefix = text[:front_matter.start(1)]
2312
+ suffix = text[front_matter.end(1):]
2313
+ elif is_metadata:
2314
+ block = text
2315
+ prefix = ""
2316
+ suffix = ""
2317
+ else:
2318
+ return text
2319
+ newline = "\r\n" if "\r\n" in block else "\n"
2320
+
2321
+ if yaml is not None:
2322
+ try:
2323
+ data = yaml.safe_load(block)
2324
+ except yaml.YAMLError:
2325
+ data = None
2326
+ if isinstance(data, dict) and "margin" not in data and "geometry" in data:
2327
+ sides = parse_geometry_sides(data["geometry"])
2328
+ if not sides:
2329
+ return text
2330
+ insertion = sides_to_typst_margin_yaml(sides, "", newline) + newline
2331
+ return prefix + insertion + block + suffix
2332
+ if isinstance(data, dict) and isinstance(data.get("margin"), dict):
2333
+ return text # already a mapping -- Typst's template handles it as-is
2334
+
2335
+ def replace(match: re.Match) -> str:
2336
+ value = match.group("val").strip().strip("'\"")
2337
+ if not value or value.startswith(("{", "[")):
2338
+ return match.group(0)
2339
+ indent = match.group("indent")
2340
+ return f"{indent}margin:{newline}{indent} x: {value}{newline}{indent} y: {value}"
2341
+
2342
+ fixed_block = TYPST_MARGIN_RE.sub(replace, block, count=1)
2343
+ if fixed_block == block:
2344
+ return text
2345
+ return prefix + fixed_block + suffix
2346
+
2347
+
2027
2348
  def has_monofont(md_path: Path, variables: list[str]) -> bool:
2028
2349
  if any(variable.startswith("monofont=") for variable in variables):
2029
2350
  return True
@@ -2475,6 +2796,51 @@ def frontmatter_extra_preambles(md_path: Path) -> list[Path]:
2475
2796
  return resolved
2476
2797
 
2477
2798
 
2799
+ def frontmatter_extra_lua_filters(md_path: Path) -> list[Path]:
2800
+ """Read ``pdfmd-options: lua-filter: ...`` -- a document naming its own
2801
+ Pandoc Lua filter(s) explicitly, instead of relying on
2802
+ find_lua_filters()'s fixed auto-discovery names (``<doc-stem>.lua`` or
2803
+ ``nulabreport.lua``).
2804
+
2805
+ Accepts a bare string or a YAML list of strings; each path is resolved
2806
+ relative to `md_path`'s own directory (not cwd -- a document should be
2807
+ runnable from any directory), same convention as
2808
+ frontmatter_extra_preambles. A path that doesn't exist is a hard
2809
+ SystemExit, not a silent skip: naming a filter explicitly means it's
2810
+ required, unlike auto-discovery's own best-effort search.
2811
+
2812
+ Merged with (not a replacement for) whatever find_lua_filters() already
2813
+ auto-discovered -- see this function's one call site in convert_one,
2814
+ which appends these AFTER the auto-discovered ones and dedupes by
2815
+ resolved path. Both land on the command line after --citeproc (see
2816
+ run()'s own comment on that ordering) -- required for a filter, like
2817
+ fullcite.lua, that consumes citeproc's resolved bibliography div.
2818
+
2819
+ Reuses the existing "lua" --no-auto KIND, same as
2820
+ frontmatter_extra_preambles reuses "preamble": a pdfmd-options.lua-filter
2821
+ key is written IN the document, so --no-auto lua (or a bare --no-auto)
2822
+ suppresses it the same as auto-discovery.
2823
+
2824
+ Added 2026-09-28 for a real case: an annotated-bibliography.md using a
2825
+ citeproc-dependent filter (fullcite.lua) that isn't named after the
2826
+ document's own stem, so find_lua_filters() alone never picked it up --
2827
+ the document had to name it explicitly instead.
2828
+ """
2829
+ value = frontmatter_pdfmd_options(md_path).get("lua-filter")
2830
+ if value is None:
2831
+ return []
2832
+ names = [value] if isinstance(value, str) else (
2833
+ [str(item) for item in value] if isinstance(value, list) else [])
2834
+ resolved = []
2835
+ for name in names:
2836
+ path = (md_path.parent / name).resolve()
2837
+ if not path.is_file():
2838
+ raise SystemExit(f"{md_path}: pdfmd-options.lua-filter names {name!r}, "
2839
+ f"which doesn't exist at {path}")
2840
+ resolved.append(path)
2841
+ return resolved
2842
+
2843
+
2478
2844
  # --stamp / pdfmd-options.stamp: appends (or updates) a "BUILD NOTES" HTML
2479
2845
  # comment at/near the end of a document recording what compiled it and when
2480
2846
  # -- a lab-notebook-style provenance note, invisible in the rendered output
@@ -3446,6 +3812,12 @@ def default_output_path(path: Path, target_format: str) -> Path:
3446
3812
 
3447
3813
  def open_file(path: Path) -> None:
3448
3814
  """Open a finished file with the platform's default viewer, for --open."""
3815
+ if sys.platform == "win32":
3816
+ try:
3817
+ os.startfile(path) # Windows' own "open with default app"
3818
+ except OSError as error:
3819
+ print(f"WARN --open: could not open {path}: {error}", file=sys.stderr)
3820
+ return
3449
3821
  opener = "open" if sys.platform == "darwin" else "xdg-open" if sys.platform.startswith("linux") else None
3450
3822
  if opener is None or not which(opener):
3451
3823
  print(f"WARN --open: no '{opener or 'file opener'}' found for this platform; "
@@ -3697,7 +4069,7 @@ def run_tex_engine(engine: str, tex_path: Path, output_dir: Path,
3697
4069
  # points bibtex back at the source directory to find the .bib
3698
4070
  # file, since it has no --input-directory flag of its own.
3699
4071
  bib_env = os.environ.copy()
3700
- bib_env["BIBINPUTS"] = f"{tex_path.parent}:{bib_env.get('BIBINPUTS', '')}"
4072
+ bib_env["BIBINPUTS"] = f"{tex_path.parent}{os.pathsep}{bib_env.get('BIBINPUTS', '')}"
3701
4073
  bib_cmd = (["biber", f"--input-directory={tex_path.parent}", tex_path.stem] if bib_tool == "biber"
3702
4074
  else ["bibtex", tex_path.stem])
3703
4075
  bib_result = run(bib_cmd, output_dir, env=bib_env)
@@ -3872,11 +4244,15 @@ def convert_via_soffice_bridge(md_path: Path, output: Path, effective_from: str
3872
4244
  cmd += resource_path_option(md_path.parent, pandoc_cwd, metadata_files)
3873
4245
  for variable in variables:
3874
4246
  cmd += ["-V", variable]
3875
- cmd += pandoc_options
3876
4247
  cmd += crossref_filter_args(md_path, pandoc_options, no_auto, str(md_path))
3877
4248
  cmd += csv_table_filter_args(md_path, no_auto, csv_filter)
3878
4249
  if contains_citations(md_path) and "--citeproc" not in pandoc_options and not CITEPROC_DISABLED:
3879
4250
  cmd.append("--citeproc")
4251
+ # pandoc_options after --citeproc: any --lua-filter/--filter a caller
4252
+ # passes through needs resolved citations already in the AST, same
4253
+ # invariant as the auto-discovered lua_filters below (see run()'s
4254
+ # matching comment in convert_one).
4255
+ cmd += pandoc_options
3880
4256
  for lua_filter in lua_filters:
3881
4257
  cmd += ["--lua-filter", str(lua_filter)]
3882
4258
  log_cmd(cmd, pandoc_cwd, verbose)
@@ -4102,6 +4478,10 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4102
4478
  if reader_reason:
4103
4479
  note("READER", reader_reason)
4104
4480
  lua_filters = [] if auto_disabled(no_auto, "lua") else find_lua_filters(md_path, metadata_files)
4481
+ if not auto_disabled(no_auto, "lua"):
4482
+ for extra_filter in frontmatter_extra_lua_filters(md_path):
4483
+ if extra_filter not in lua_filters:
4484
+ lua_filters.append(extra_filter)
4105
4485
  for lua_filter in lua_filters:
4106
4486
  note("LUA", str(lua_filter))
4107
4487
 
@@ -4153,6 +4533,11 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4153
4533
  if is_tex_target and geometry_needed:
4154
4534
  note("MARGIN", f"{md_path}: no geometry/margin set; "
4155
4535
  f"using geometry:margin={DEFAULT_MARGIN} on LaTeX-family targets")
4536
+ margin_options = (None if auto_disabled(no_auto, "margin")
4537
+ else frontmatter_margin_geometry_options(md_path, variables, metadata_files))
4538
+ if is_tex_target and margin_options:
4539
+ note("MARGIN", f"{md_path}: margin: isn't a Pandoc variable LaTeX-family targets "
4540
+ f"read (geometry: is) -- using geometry:{','.join(margin_options)}")
4156
4541
  if is_tex_target and monofont_needed:
4157
4542
  note("MONOFONT", f"{md_path}: has code but no monofont set; "
4158
4543
  f"using {default_monofont()} on LaTeX-family targets")
@@ -4163,7 +4548,8 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4163
4548
  f"(papersize: is) -- using papersize={pagesize_typo} on LaTeX-family "
4164
4549
  "targets. Set papersize: yourself, or --no-auto papersize, to silence "
4165
4550
  "this and keep the Letter default")
4166
- with prepared_latex_inputs([title_source, *metadata_files], is_tex_target) as prepared, \
4551
+ with prepared_latex_inputs([title_source, *metadata_files], is_tex_target,
4552
+ typst_engine=(target_format == "typst")) as prepared, \
4167
4553
  document_header_file(md_path, (bool(preamble_files) or bool(pdf_meta_snippet_text))
4168
4554
  and is_tex_target) as header_file, \
4169
4555
  pdf_metadata_header_file(pdf_meta_snippet_text if is_tex_target else None) as pdf_meta_file:
@@ -4199,6 +4585,9 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4199
4585
  cmd += ["-V", f"mainfont={first_font}"]
4200
4586
  if geometry_needed:
4201
4587
  cmd += ["-V", f"geometry:margin={DEFAULT_MARGIN}"]
4588
+ elif margin_options:
4589
+ for margin_option in margin_options:
4590
+ cmd += ["-V", f"geometry:{margin_option}"]
4202
4591
  if monofont_needed:
4203
4592
  cmd += ["-V", f"monofont={default_monofont()}"]
4204
4593
  if pagesize_typo:
@@ -4217,7 +4606,6 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4217
4606
  cmd += ["-V", f"papersize={typst_size}"]
4218
4607
  if shift_heading:
4219
4608
  cmd += ["--shift-heading-level-by=-1"]
4220
- cmd += pandoc_options
4221
4609
  if preamble_files and is_tex_target:
4222
4610
  for preamble_file in preamble_files:
4223
4611
  cmd += ["--include-in-header", str(preamble_file)]
@@ -4243,6 +4631,11 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4243
4631
  cmd.append(f"--{citation_engine}")
4244
4632
  else:
4245
4633
  cmd.append("--citeproc")
4634
+ # pandoc_options after the citation-engine flag above: a
4635
+ # caller-supplied --lua-filter/--filter needs resolved
4636
+ # citations already in the AST (same invariant as the
4637
+ # auto-discovered lua_filters below).
4638
+ cmd += pandoc_options
4246
4639
  if is_tex_target and tablewidth_auto:
4247
4640
  cmd += ["--lua-filter", str(width_filter)]
4248
4641
  for lua_filter in lua_filters:
@@ -4263,6 +4656,11 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4263
4656
  if geometry_needed:
4264
4657
  note("MARGIN", f"{md_path}: no geometry/margin set; "
4265
4658
  f"using geometry:margin={DEFAULT_MARGIN} on LaTeX-family engines")
4659
+ margin_options = (None if auto_disabled(no_auto, "margin")
4660
+ else frontmatter_margin_geometry_options(md_path, variables, metadata_files))
4661
+ if margin_options:
4662
+ note("MARGIN", f"{md_path}: margin: isn't a Pandoc variable LaTeX-family engines "
4663
+ f"read (geometry: is) -- using geometry:{','.join(margin_options)}")
4266
4664
  monofont_needed = (not auto_disabled(no_auto, "monofont")
4267
4665
  and has_code_spans(md_path.read_text(encoding="utf-8-sig"))
4268
4666
  and not has_monofont(md_path, variables))
@@ -4330,7 +4728,8 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4330
4728
  remaining = [e for e in engines[engine_index + 1:] if ENGINE_FAMILY.get(e, e) not in failed_families]
4331
4729
  report_engine_failure(str(md_path), engine, result, remaining, debug)
4332
4730
  continue
4333
- with prepared_latex_inputs([title_source, *metadata_files], engine in LATEX_ENGINES) as prepared, \
4731
+ with prepared_latex_inputs([title_source, *metadata_files], engine in LATEX_ENGINES,
4732
+ typst_engine=(engine == "typst")) as prepared, \
4334
4733
  document_header_file(md_path, (bool(preamble_files) or bool(pdf_meta_snippet_text))
4335
4734
  and engine in LATEX_ENGINES) as header_file, \
4336
4735
  pdf_metadata_header_file(pdf_meta_snippet_text if engine in LATEX_ENGINES else None) as pdf_meta_file:
@@ -4354,6 +4753,9 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4354
4753
  cmd += ["-V", f"mainfontfallback={fallback_font()}"]
4355
4754
  if geometry_needed and engine in LATEX_ENGINES:
4356
4755
  cmd += ["-V", f"geometry:margin={DEFAULT_MARGIN}"]
4756
+ elif margin_options and engine in LATEX_ENGINES:
4757
+ for margin_option in margin_options:
4758
+ cmd += ["-V", f"geometry:{margin_option}"]
4357
4759
  if monofont_needed and engine in LATEX_ENGINES:
4358
4760
  cmd += ["-V", f"monofont={default_monofont()}"]
4359
4761
  if pagesize_typo and engine in LATEX_ENGINES:
@@ -4372,7 +4774,6 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4372
4774
  cmd += ["-V", f"papersize={typst_size}"]
4373
4775
  if shift_heading:
4374
4776
  cmd += ["--shift-heading-level-by=-1"]
4375
- cmd += pandoc_options
4376
4777
  if preamble_files and engine in LATEX_ENGINES:
4377
4778
  for preamble_file in preamble_files:
4378
4779
  cmd += ["--include-in-header", str(preamble_file)]
@@ -4389,6 +4790,15 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4389
4790
  cmd += crossref_filter_args(md_path, pandoc_options, no_auto, str(md_path))
4390
4791
  if contains_citations(md_path) and "--citeproc" not in pandoc_options and not CITEPROC_DISABLED:
4391
4792
  cmd.append("--citeproc")
4793
+ # pandoc_options after --citeproc, same reason as below: a
4794
+ # caller-supplied --lua-filter/--filter needs resolved
4795
+ # citations already in the AST. (This used to sit before
4796
+ # the --citeproc append above, which silently broke any
4797
+ # citeproc-dependent filter passed via extra CLI args --
4798
+ # e.g. `--lua-filter=some.lua` landed ahead of --citeproc
4799
+ # on the actual pandoc command line. Confirmed via
4800
+ # --verbose CMD output before this fix.)
4801
+ cmd += pandoc_options
4392
4802
  # Lua filters go last: pandoc applies --citeproc and filters in
4393
4803
  # command-line order, and a filter that renders cell contents to
4394
4804
  # LaTeX needs the citations already resolved. csv-table has to
@@ -4618,7 +5028,7 @@ def build_parser() -> argparse.ArgumentParser:
4618
5028
  "with --engine when a document's rendered output doesn't match what the "
4619
5029
  "Markdown predicts and the short reason alone isn't enough to tell why")
4620
5030
  parser.add_argument("--open", action="store_true",
4621
- help="open the finished file (via macOS 'open'/Linux 'xdg-open') once "
5031
+ help="open the finished file (via macOS 'open', Linux 'xdg-open' or Windows' default app) once "
4622
5032
  "conversion succeeds. Applies to single-file and report/book mode, "
4623
5033
  "each of which produce exactly one output; ignored in batch mode "
4624
5034
  "(-b), which would otherwise pop open every file in the directory")
@@ -4935,13 +5345,22 @@ def main() -> None:
4935
5345
  if report_geometry_needed:
4936
5346
  report_note("MARGIN", "REPORT: no geometry/margin set; "
4937
5347
  f"using geometry:margin={DEFAULT_MARGIN} on LaTeX-family targets")
5348
+ report_margin_options = (None if auto_disabled(report_no_auto, "margin")
5349
+ else frontmatter_margin_geometry_options(files[0], variables,
5350
+ metadata_files))
5351
+ if is_tex_target and report_margin_options:
5352
+ report_note("MARGIN", "REPORT: margin: isn't a Pandoc variable LaTeX-family "
5353
+ "targets read (geometry: is) -- using "
5354
+ f"geometry:{','.join(report_margin_options)}")
4938
5355
  report_monofont_needed = (is_tex_target and not auto_disabled(report_no_auto, "monofont")
4939
5356
  and any(has_code_spans(file.read_text(encoding="utf-8-sig")) for file in files)
4940
5357
  and not has_monofont(files[0], variables))
4941
5358
  if report_monofont_needed:
4942
5359
  report_note("MONOFONT", "REPORT: has code but no monofont set; "
4943
5360
  f"using {default_monofont()} on LaTeX-family targets")
4944
- with prepared_latex_inputs([*files, *metadata_files], is_tex_target) as prepared, \
5361
+ with prepared_latex_inputs([*files, *metadata_files], is_tex_target,
5362
+ typst_engine=(target_format == "typst"),
5363
+ doc_count=len(files)) as prepared, \
4945
5364
  document_header_file(files[0], (bool(report_preambles) or bool(report_pdf_meta_snippet_text))
4946
5365
  and is_tex_target) as report_header_file, \
4947
5366
  pdf_metadata_header_file(report_pdf_meta_snippet_text) as report_pdf_meta_file:
@@ -4969,11 +5388,13 @@ def main() -> None:
4969
5388
  cmd += ["-V", f"mainfont={report_first_font}"]
4970
5389
  if report_geometry_needed:
4971
5390
  cmd += ["-V", f"geometry:margin={DEFAULT_MARGIN}"]
5391
+ elif report_margin_options:
5392
+ for margin_option in report_margin_options:
5393
+ cmd += ["-V", f"geometry:{margin_option}"]
4972
5394
  if report_monofont_needed:
4973
5395
  cmd += ["-V", f"monofont={default_monofont()}"]
4974
5396
  for variable in variables:
4975
5397
  cmd += ["-V", variable]
4976
- cmd += pandoc_options
4977
5398
  if report_preambles and is_tex_target:
4978
5399
  for preamble in report_preambles:
4979
5400
  cmd += ["--include-in-header", str(preamble)]
@@ -4991,6 +5412,9 @@ def main() -> None:
4991
5412
  and "--citeproc" not in pandoc_options
4992
5413
  and not CITEPROC_DISABLED):
4993
5414
  cmd.append("--citeproc")
5415
+ # pandoc_options after --citeproc: see convert_one's
5416
+ # matching comment.
5417
+ cmd += pandoc_options
4994
5418
  if is_tex_target and not auto_disabled(report_no_auto, "tablewidth"):
4995
5419
  cmd += ["--lua-filter", str(width_filter)]
4996
5420
  log_cmd(cmd, pandoc_cwd, args.verbose)
@@ -5014,6 +5438,12 @@ def main() -> None:
5014
5438
  if geometry_needed:
5015
5439
  report_note("MARGIN", "REPORT: no geometry/margin set; "
5016
5440
  f"using geometry:margin={DEFAULT_MARGIN} on LaTeX-family engines")
5441
+ margin_options = (None if auto_disabled(report_no_auto, "margin")
5442
+ else frontmatter_margin_geometry_options(files[0], variables, metadata_files))
5443
+ if margin_options:
5444
+ report_note("MARGIN", "REPORT: margin: isn't a Pandoc variable LaTeX-family "
5445
+ "engines read (geometry: is) -- using "
5446
+ f"geometry:{','.join(margin_options)}")
5017
5447
  monofont_needed = (not auto_disabled(report_no_auto, "monofont")
5018
5448
  and any(has_code_spans(file.read_text(encoding="utf-8-sig")) for file in files)
5019
5449
  and not has_monofont(files[0], variables))
@@ -5044,7 +5474,9 @@ def main() -> None:
5044
5474
  print(f"SKIP REPORT: {engine} shares the {family} engine with an earlier "
5045
5475
  f"failure; skipping", file=sys.stderr)
5046
5476
  continue
5047
- with prepared_latex_inputs([*files, *metadata_files], engine in LATEX_ENGINES) as prepared, \
5477
+ with prepared_latex_inputs([*files, *metadata_files], engine in LATEX_ENGINES,
5478
+ typst_engine=(engine == "typst"),
5479
+ doc_count=len(files)) as prepared, \
5048
5480
  pdf_metadata_header_file(
5049
5481
  report_pdf_meta_snippet_text if engine in LATEX_ENGINES else None
5050
5482
  ) as report_pdf_meta_file:
@@ -5059,11 +5491,13 @@ def main() -> None:
5059
5491
  cmd += report_resource_path
5060
5492
  if geometry_needed and engine in LATEX_ENGINES:
5061
5493
  cmd += ["-V", f"geometry:margin={DEFAULT_MARGIN}"]
5494
+ elif margin_options and engine in LATEX_ENGINES:
5495
+ for margin_option in margin_options:
5496
+ cmd += ["-V", f"geometry:{margin_option}"]
5062
5497
  if monofont_needed and engine in LATEX_ENGINES:
5063
5498
  cmd += ["-V", f"monofont={default_monofont()}"]
5064
5499
  for variable in variables:
5065
5500
  cmd += ["-V", variable]
5066
- cmd += pandoc_options
5067
5501
  if preambles and engine in LATEX_ENGINES:
5068
5502
  for preamble in preambles:
5069
5503
  cmd += ["--include-in-header", str(preamble)]
@@ -5075,6 +5509,9 @@ def main() -> None:
5075
5509
  and "--citeproc" not in pandoc_options
5076
5510
  and not CITEPROC_DISABLED):
5077
5511
  cmd.append("--citeproc")
5512
+ # pandoc_options after --citeproc: see convert_one's
5513
+ # matching comment.
5514
+ cmd += pandoc_options
5078
5515
  if report_tablewidth_auto and engine in LATEX_ENGINES:
5079
5516
  cmd += ["--lua-filter", str(width_filter)]
5080
5517
  log_cmd(cmd, pandoc_cwd, args.verbose)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pdfmd-cli
3
- Version: 3.11.1
3
+ Version: 3.15.0
4
4
  Summary: One command from Markdown to a good-looking PDF: a Pandoc wrapper with smart defaults, project-file discovery and a multi-engine fallback chain
5
5
  Author: Ali Perdekhan
6
6
  License-Expression: MIT
@@ -20,6 +20,8 @@ Dynamic: license-file
20
20
 
21
21
  # pdfmd
22
22
 
23
+ [![Test](https://github.com/aliperdehan/pdfmd/actions/workflows/publish.yml/badge.svg)](https://github.com/aliperdehan/pdfmd/actions/workflows/publish.yml) [![PyPI](https://img.shields.io/pypi/v/pdfmd-cli)](https://pypi.org/project/pdfmd-cli/)
24
+
23
25
  **One command from Markdown to a good-looking PDF.** `pdfmd` wraps
24
26
  [Pandoc](https://pandoc.org) and fills in everything you would otherwise
25
27
  have to remember: sensible fonts and margins, the right Markdown dialect,
@@ -92,6 +94,16 @@ brew install --cask mactex-no-gui # optional: full LaTeX (large)
92
94
  sudo apt install pipx pandoc texlive-xetex # Debian/Ubuntu
93
95
  ```
94
96
 
97
+ ```powershell
98
+ py -m pip install --user pipx; py -m pipx ensurepath # Windows
99
+ winget install --id JohnMacFarlane.Pandoc; winget install --id Typst.Typst
100
+ ```
101
+
102
+ Every push is tested on Windows, macOS and Linux (Pandoc + Typst: single
103
+ files, CSV tables, a book, HTML output); see the
104
+ [Test and publish workflow](https://github.com/aliperdehan/pdfmd/actions/workflows/publish.yml).
105
+ LaTeX engines aren't part of that automated test on Windows yet.
106
+
95
107
  Optional extras: [Quarto](https://quarto.org) for `.qmd` files,
96
108
  `pandoc-crossref` for `@fig:`/`@tbl:` references, and LibreOffice for
97
109
  Office files.
File without changes
File without changes
File without changes