pdfmd-cli 3.11.2__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.2
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,7 +20,7 @@ Dynamic: license-file
20
20
 
21
21
  # pdfmd
22
22
 
23
- [![Test](https://github.com/aliperdehan/pdfmd/actions/workflows/test.yml/badge.svg)](https://github.com/aliperdehan/pdfmd/actions/workflows/test.yml) [![PyPI](https://img.shields.io/pypi/v/pdfmd-cli)](https://pypi.org/project/pdfmd-cli/)
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
24
 
25
25
  **One command from Markdown to a good-looking PDF.** `pdfmd` wraps
26
26
  [Pandoc](https://pandoc.org) and fills in everything you would otherwise
@@ -101,7 +101,7 @@ winget install --id JohnMacFarlane.Pandoc; winget install --id Typst.Typst
101
101
 
102
102
  Every push is tested on Windows, macOS and Linux (Pandoc + Typst: single
103
103
  files, CSV tables, a book, HTML output); see the
104
- [Test workflow](https://github.com/aliperdehan/pdfmd/actions/workflows/test.yml).
104
+ [Test and publish workflow](https://github.com/aliperdehan/pdfmd/actions/workflows/publish.yml).
105
105
  LaTeX engines aren't part of that automated test on Windows yet.
106
106
 
107
107
  Optional extras: [Quarto](https://quarto.org) for `.qmd` files,
@@ -1,6 +1,6 @@
1
1
  # pdfmd
2
2
 
3
- [![Test](https://github.com/aliperdehan/pdfmd/actions/workflows/test.yml/badge.svg)](https://github.com/aliperdehan/pdfmd/actions/workflows/test.yml) [![PyPI](https://img.shields.io/pypi/v/pdfmd-cli)](https://pypi.org/project/pdfmd-cli/)
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
4
 
5
5
  **One command from Markdown to a good-looking PDF.** `pdfmd` wraps
6
6
  [Pandoc](https://pandoc.org) and fills in everything you would otherwise
@@ -81,7 +81,7 @@ winget install --id JohnMacFarlane.Pandoc; winget install --id Typst.Typst
81
81
 
82
82
  Every push is tested on Windows, macOS and Linux (Pandoc + Typst: single
83
83
  files, CSV tables, a book, HTML output); see the
84
- [Test workflow](https://github.com/aliperdehan/pdfmd/actions/workflows/test.yml).
84
+ [Test and publish workflow](https://github.com/aliperdehan/pdfmd/actions/workflows/publish.yml).
85
85
  LaTeX engines aren't part of that automated test on Windows yet.
86
86
 
87
87
  Optional extras: [Quarto](https://quarto.org) for `.qmd` 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.2"
452
+ PDFMD_VERSION = "3.15.0"
453
453
  import argparse
454
454
  import filecmp
455
455
  from fnmatch import fnmatchcase
@@ -1585,14 +1585,39 @@ def wrap_latex_header_includes(text: str) -> str:
1585
1585
 
1586
1586
 
1587
1587
  @contextmanager
1588
- def prepared_latex_inputs(paths: list[Path], latex_engine: bool) -> Iterator[list[Path]]:
1589
- """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
+ """
1590
1610
  temporary_paths: list[Path] = []
1591
1611
  prepared: list[Path] = []
1592
1612
  try:
1593
- for path in paths:
1613
+ for index, path in enumerate(paths):
1594
1614
  text = path.read_text(encoding="utf-8-sig")
1595
- 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
1596
1621
  if corrected == text:
1597
1622
  prepared.append(path)
1598
1623
  continue
@@ -1949,6 +1974,189 @@ def has_geometry(md_path: Path, variables: list[str], metadata_files: list[Path]
1949
1974
  for preamble in preamble_files)
1950
1975
 
1951
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
+
1952
2160
  def pagesize_typo_value(md_path: Path, variables: list[str]) -> str | None:
1953
2161
  """Return a document's own ``pagesize:`` value, IF ``papersize:`` isn't
1954
2162
  already set anywhere Pandoc would see it -- a -V variable or the
@@ -2031,6 +2239,112 @@ def typst_papersize_translation(value: str | None) -> str | None:
2031
2239
  return TYPST_PAPERSIZE_ALIASES.get(value.strip().casefold())
2032
2240
 
2033
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
+
2034
2348
  def has_monofont(md_path: Path, variables: list[str]) -> bool:
2035
2349
  if any(variable.startswith("monofont=") for variable in variables):
2036
2350
  return True
@@ -2482,6 +2796,51 @@ def frontmatter_extra_preambles(md_path: Path) -> list[Path]:
2482
2796
  return resolved
2483
2797
 
2484
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
+
2485
2844
  # --stamp / pdfmd-options.stamp: appends (or updates) a "BUILD NOTES" HTML
2486
2845
  # comment at/near the end of a document recording what compiled it and when
2487
2846
  # -- a lab-notebook-style provenance note, invisible in the rendered output
@@ -3885,11 +4244,15 @@ def convert_via_soffice_bridge(md_path: Path, output: Path, effective_from: str
3885
4244
  cmd += resource_path_option(md_path.parent, pandoc_cwd, metadata_files)
3886
4245
  for variable in variables:
3887
4246
  cmd += ["-V", variable]
3888
- cmd += pandoc_options
3889
4247
  cmd += crossref_filter_args(md_path, pandoc_options, no_auto, str(md_path))
3890
4248
  cmd += csv_table_filter_args(md_path, no_auto, csv_filter)
3891
4249
  if contains_citations(md_path) and "--citeproc" not in pandoc_options and not CITEPROC_DISABLED:
3892
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
3893
4256
  for lua_filter in lua_filters:
3894
4257
  cmd += ["--lua-filter", str(lua_filter)]
3895
4258
  log_cmd(cmd, pandoc_cwd, verbose)
@@ -4115,6 +4478,10 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4115
4478
  if reader_reason:
4116
4479
  note("READER", reader_reason)
4117
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)
4118
4485
  for lua_filter in lua_filters:
4119
4486
  note("LUA", str(lua_filter))
4120
4487
 
@@ -4166,6 +4533,11 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4166
4533
  if is_tex_target and geometry_needed:
4167
4534
  note("MARGIN", f"{md_path}: no geometry/margin set; "
4168
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)}")
4169
4541
  if is_tex_target and monofont_needed:
4170
4542
  note("MONOFONT", f"{md_path}: has code but no monofont set; "
4171
4543
  f"using {default_monofont()} on LaTeX-family targets")
@@ -4176,7 +4548,8 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4176
4548
  f"(papersize: is) -- using papersize={pagesize_typo} on LaTeX-family "
4177
4549
  "targets. Set papersize: yourself, or --no-auto papersize, to silence "
4178
4550
  "this and keep the Letter default")
4179
- 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, \
4180
4553
  document_header_file(md_path, (bool(preamble_files) or bool(pdf_meta_snippet_text))
4181
4554
  and is_tex_target) as header_file, \
4182
4555
  pdf_metadata_header_file(pdf_meta_snippet_text if is_tex_target else None) as pdf_meta_file:
@@ -4212,6 +4585,9 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4212
4585
  cmd += ["-V", f"mainfont={first_font}"]
4213
4586
  if geometry_needed:
4214
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}"]
4215
4591
  if monofont_needed:
4216
4592
  cmd += ["-V", f"monofont={default_monofont()}"]
4217
4593
  if pagesize_typo:
@@ -4230,7 +4606,6 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4230
4606
  cmd += ["-V", f"papersize={typst_size}"]
4231
4607
  if shift_heading:
4232
4608
  cmd += ["--shift-heading-level-by=-1"]
4233
- cmd += pandoc_options
4234
4609
  if preamble_files and is_tex_target:
4235
4610
  for preamble_file in preamble_files:
4236
4611
  cmd += ["--include-in-header", str(preamble_file)]
@@ -4256,6 +4631,11 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4256
4631
  cmd.append(f"--{citation_engine}")
4257
4632
  else:
4258
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
4259
4639
  if is_tex_target and tablewidth_auto:
4260
4640
  cmd += ["--lua-filter", str(width_filter)]
4261
4641
  for lua_filter in lua_filters:
@@ -4276,6 +4656,11 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4276
4656
  if geometry_needed:
4277
4657
  note("MARGIN", f"{md_path}: no geometry/margin set; "
4278
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)}")
4279
4664
  monofont_needed = (not auto_disabled(no_auto, "monofont")
4280
4665
  and has_code_spans(md_path.read_text(encoding="utf-8-sig"))
4281
4666
  and not has_monofont(md_path, variables))
@@ -4343,7 +4728,8 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4343
4728
  remaining = [e for e in engines[engine_index + 1:] if ENGINE_FAMILY.get(e, e) not in failed_families]
4344
4729
  report_engine_failure(str(md_path), engine, result, remaining, debug)
4345
4730
  continue
4346
- 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, \
4347
4733
  document_header_file(md_path, (bool(preamble_files) or bool(pdf_meta_snippet_text))
4348
4734
  and engine in LATEX_ENGINES) as header_file, \
4349
4735
  pdf_metadata_header_file(pdf_meta_snippet_text if engine in LATEX_ENGINES else None) as pdf_meta_file:
@@ -4367,6 +4753,9 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4367
4753
  cmd += ["-V", f"mainfontfallback={fallback_font()}"]
4368
4754
  if geometry_needed and engine in LATEX_ENGINES:
4369
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}"]
4370
4759
  if monofont_needed and engine in LATEX_ENGINES:
4371
4760
  cmd += ["-V", f"monofont={default_monofont()}"]
4372
4761
  if pagesize_typo and engine in LATEX_ENGINES:
@@ -4385,7 +4774,6 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4385
4774
  cmd += ["-V", f"papersize={typst_size}"]
4386
4775
  if shift_heading:
4387
4776
  cmd += ["--shift-heading-level-by=-1"]
4388
- cmd += pandoc_options
4389
4777
  if preamble_files and engine in LATEX_ENGINES:
4390
4778
  for preamble_file in preamble_files:
4391
4779
  cmd += ["--include-in-header", str(preamble_file)]
@@ -4402,6 +4790,15 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4402
4790
  cmd += crossref_filter_args(md_path, pandoc_options, no_auto, str(md_path))
4403
4791
  if contains_citations(md_path) and "--citeproc" not in pandoc_options and not CITEPROC_DISABLED:
4404
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
4405
4802
  # Lua filters go last: pandoc applies --citeproc and filters in
4406
4803
  # command-line order, and a filter that renders cell contents to
4407
4804
  # LaTeX needs the citations already resolved. csv-table has to
@@ -4948,13 +5345,22 @@ def main() -> None:
4948
5345
  if report_geometry_needed:
4949
5346
  report_note("MARGIN", "REPORT: no geometry/margin set; "
4950
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)}")
4951
5355
  report_monofont_needed = (is_tex_target and not auto_disabled(report_no_auto, "monofont")
4952
5356
  and any(has_code_spans(file.read_text(encoding="utf-8-sig")) for file in files)
4953
5357
  and not has_monofont(files[0], variables))
4954
5358
  if report_monofont_needed:
4955
5359
  report_note("MONOFONT", "REPORT: has code but no monofont set; "
4956
5360
  f"using {default_monofont()} on LaTeX-family targets")
4957
- 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, \
4958
5364
  document_header_file(files[0], (bool(report_preambles) or bool(report_pdf_meta_snippet_text))
4959
5365
  and is_tex_target) as report_header_file, \
4960
5366
  pdf_metadata_header_file(report_pdf_meta_snippet_text) as report_pdf_meta_file:
@@ -4982,11 +5388,13 @@ def main() -> None:
4982
5388
  cmd += ["-V", f"mainfont={report_first_font}"]
4983
5389
  if report_geometry_needed:
4984
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}"]
4985
5394
  if report_monofont_needed:
4986
5395
  cmd += ["-V", f"monofont={default_monofont()}"]
4987
5396
  for variable in variables:
4988
5397
  cmd += ["-V", variable]
4989
- cmd += pandoc_options
4990
5398
  if report_preambles and is_tex_target:
4991
5399
  for preamble in report_preambles:
4992
5400
  cmd += ["--include-in-header", str(preamble)]
@@ -5004,6 +5412,9 @@ def main() -> None:
5004
5412
  and "--citeproc" not in pandoc_options
5005
5413
  and not CITEPROC_DISABLED):
5006
5414
  cmd.append("--citeproc")
5415
+ # pandoc_options after --citeproc: see convert_one's
5416
+ # matching comment.
5417
+ cmd += pandoc_options
5007
5418
  if is_tex_target and not auto_disabled(report_no_auto, "tablewidth"):
5008
5419
  cmd += ["--lua-filter", str(width_filter)]
5009
5420
  log_cmd(cmd, pandoc_cwd, args.verbose)
@@ -5027,6 +5438,12 @@ def main() -> None:
5027
5438
  if geometry_needed:
5028
5439
  report_note("MARGIN", "REPORT: no geometry/margin set; "
5029
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)}")
5030
5447
  monofont_needed = (not auto_disabled(report_no_auto, "monofont")
5031
5448
  and any(has_code_spans(file.read_text(encoding="utf-8-sig")) for file in files)
5032
5449
  and not has_monofont(files[0], variables))
@@ -5057,7 +5474,9 @@ def main() -> None:
5057
5474
  print(f"SKIP REPORT: {engine} shares the {family} engine with an earlier "
5058
5475
  f"failure; skipping", file=sys.stderr)
5059
5476
  continue
5060
- 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, \
5061
5480
  pdf_metadata_header_file(
5062
5481
  report_pdf_meta_snippet_text if engine in LATEX_ENGINES else None
5063
5482
  ) as report_pdf_meta_file:
@@ -5072,11 +5491,13 @@ def main() -> None:
5072
5491
  cmd += report_resource_path
5073
5492
  if geometry_needed and engine in LATEX_ENGINES:
5074
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}"]
5075
5497
  if monofont_needed and engine in LATEX_ENGINES:
5076
5498
  cmd += ["-V", f"monofont={default_monofont()}"]
5077
5499
  for variable in variables:
5078
5500
  cmd += ["-V", variable]
5079
- cmd += pandoc_options
5080
5501
  if preambles and engine in LATEX_ENGINES:
5081
5502
  for preamble in preambles:
5082
5503
  cmd += ["--include-in-header", str(preamble)]
@@ -5088,6 +5509,9 @@ def main() -> None:
5088
5509
  and "--citeproc" not in pandoc_options
5089
5510
  and not CITEPROC_DISABLED):
5090
5511
  cmd.append("--citeproc")
5512
+ # pandoc_options after --citeproc: see convert_one's
5513
+ # matching comment.
5514
+ cmd += pandoc_options
5091
5515
  if report_tablewidth_auto and engine in LATEX_ENGINES:
5092
5516
  cmd += ["--lua-filter", str(width_filter)]
5093
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.2
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,7 +20,7 @@ Dynamic: license-file
20
20
 
21
21
  # pdfmd
22
22
 
23
- [![Test](https://github.com/aliperdehan/pdfmd/actions/workflows/test.yml/badge.svg)](https://github.com/aliperdehan/pdfmd/actions/workflows/test.yml) [![PyPI](https://img.shields.io/pypi/v/pdfmd-cli)](https://pypi.org/project/pdfmd-cli/)
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
24
 
25
25
  **One command from Markdown to a good-looking PDF.** `pdfmd` wraps
26
26
  [Pandoc](https://pandoc.org) and fills in everything you would otherwise
@@ -101,7 +101,7 @@ winget install --id JohnMacFarlane.Pandoc; winget install --id Typst.Typst
101
101
 
102
102
  Every push is tested on Windows, macOS and Linux (Pandoc + Typst: single
103
103
  files, CSV tables, a book, HTML output); see the
104
- [Test workflow](https://github.com/aliperdehan/pdfmd/actions/workflows/test.yml).
104
+ [Test and publish workflow](https://github.com/aliperdehan/pdfmd/actions/workflows/publish.yml).
105
105
  LaTeX engines aren't part of that automated test on Windows yet.
106
106
 
107
107
  Optional extras: [Quarto](https://quarto.org) for `.qmd` files,
File without changes
File without changes
File without changes