pdfmd-cli 3.11.2__tar.gz → 3.15.1__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.1
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.1"
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
@@ -3193,6 +3552,15 @@ def stamp_pdf_metadata_posthoc(pdf_path: Path, options: dict, texts: list[str],
3193
3552
  warnings.simplefilter("ignore")
3194
3553
  reader = pypdf.PdfReader(pdf_path)
3195
3554
  existing = dict(reader.metadata) if reader.metadata else {}
3555
+ # Credit pdfmd in /Creator, appended in pandoc's own
3556
+ # "<tool> via <wrapper>" order ("LaTeX via pandoc" -> "LaTeX
3557
+ # via pandoc via pdfmd-cli"; "Typst 0.15.1" -> "Typst 0.15.1
3558
+ # via pdfmd-cli"). Named by its PyPI distribution name so it
3559
+ # is searchable; the version is already in PdfmdVersions.
3560
+ # Idempotent (a re-stamp doesn't append twice).
3561
+ creator = str(existing.get("/Creator") or "").strip()
3562
+ if not creator.endswith("pdfmd-cli"):
3563
+ existing["/Creator"] = f"{creator} via pdfmd-cli" if creator else "pdfmd-cli"
3196
3564
  writer = pypdf.PdfWriter()
3197
3565
  writer.append(reader)
3198
3566
  writer.add_metadata({
@@ -3885,11 +4253,15 @@ def convert_via_soffice_bridge(md_path: Path, output: Path, effective_from: str
3885
4253
  cmd += resource_path_option(md_path.parent, pandoc_cwd, metadata_files)
3886
4254
  for variable in variables:
3887
4255
  cmd += ["-V", variable]
3888
- cmd += pandoc_options
3889
4256
  cmd += crossref_filter_args(md_path, pandoc_options, no_auto, str(md_path))
3890
4257
  cmd += csv_table_filter_args(md_path, no_auto, csv_filter)
3891
4258
  if contains_citations(md_path) and "--citeproc" not in pandoc_options and not CITEPROC_DISABLED:
3892
4259
  cmd.append("--citeproc")
4260
+ # pandoc_options after --citeproc: any --lua-filter/--filter a caller
4261
+ # passes through needs resolved citations already in the AST, same
4262
+ # invariant as the auto-discovered lua_filters below (see run()'s
4263
+ # matching comment in convert_one).
4264
+ cmd += pandoc_options
3893
4265
  for lua_filter in lua_filters:
3894
4266
  cmd += ["--lua-filter", str(lua_filter)]
3895
4267
  log_cmd(cmd, pandoc_cwd, verbose)
@@ -4115,6 +4487,10 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4115
4487
  if reader_reason:
4116
4488
  note("READER", reader_reason)
4117
4489
  lua_filters = [] if auto_disabled(no_auto, "lua") else find_lua_filters(md_path, metadata_files)
4490
+ if not auto_disabled(no_auto, "lua"):
4491
+ for extra_filter in frontmatter_extra_lua_filters(md_path):
4492
+ if extra_filter not in lua_filters:
4493
+ lua_filters.append(extra_filter)
4118
4494
  for lua_filter in lua_filters:
4119
4495
  note("LUA", str(lua_filter))
4120
4496
 
@@ -4166,6 +4542,11 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4166
4542
  if is_tex_target and geometry_needed:
4167
4543
  note("MARGIN", f"{md_path}: no geometry/margin set; "
4168
4544
  f"using geometry:margin={DEFAULT_MARGIN} on LaTeX-family targets")
4545
+ margin_options = (None if auto_disabled(no_auto, "margin")
4546
+ else frontmatter_margin_geometry_options(md_path, variables, metadata_files))
4547
+ if is_tex_target and margin_options:
4548
+ note("MARGIN", f"{md_path}: margin: isn't a Pandoc variable LaTeX-family targets "
4549
+ f"read (geometry: is) -- using geometry:{','.join(margin_options)}")
4169
4550
  if is_tex_target and monofont_needed:
4170
4551
  note("MONOFONT", f"{md_path}: has code but no monofont set; "
4171
4552
  f"using {default_monofont()} on LaTeX-family targets")
@@ -4176,7 +4557,8 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4176
4557
  f"(papersize: is) -- using papersize={pagesize_typo} on LaTeX-family "
4177
4558
  "targets. Set papersize: yourself, or --no-auto papersize, to silence "
4178
4559
  "this and keep the Letter default")
4179
- with prepared_latex_inputs([title_source, *metadata_files], is_tex_target) as prepared, \
4560
+ with prepared_latex_inputs([title_source, *metadata_files], is_tex_target,
4561
+ typst_engine=(target_format == "typst")) as prepared, \
4180
4562
  document_header_file(md_path, (bool(preamble_files) or bool(pdf_meta_snippet_text))
4181
4563
  and is_tex_target) as header_file, \
4182
4564
  pdf_metadata_header_file(pdf_meta_snippet_text if is_tex_target else None) as pdf_meta_file:
@@ -4212,6 +4594,9 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4212
4594
  cmd += ["-V", f"mainfont={first_font}"]
4213
4595
  if geometry_needed:
4214
4596
  cmd += ["-V", f"geometry:margin={DEFAULT_MARGIN}"]
4597
+ elif margin_options:
4598
+ for margin_option in margin_options:
4599
+ cmd += ["-V", f"geometry:{margin_option}"]
4215
4600
  if monofont_needed:
4216
4601
  cmd += ["-V", f"monofont={default_monofont()}"]
4217
4602
  if pagesize_typo:
@@ -4230,7 +4615,6 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4230
4615
  cmd += ["-V", f"papersize={typst_size}"]
4231
4616
  if shift_heading:
4232
4617
  cmd += ["--shift-heading-level-by=-1"]
4233
- cmd += pandoc_options
4234
4618
  if preamble_files and is_tex_target:
4235
4619
  for preamble_file in preamble_files:
4236
4620
  cmd += ["--include-in-header", str(preamble_file)]
@@ -4256,6 +4640,11 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4256
4640
  cmd.append(f"--{citation_engine}")
4257
4641
  else:
4258
4642
  cmd.append("--citeproc")
4643
+ # pandoc_options after the citation-engine flag above: a
4644
+ # caller-supplied --lua-filter/--filter needs resolved
4645
+ # citations already in the AST (same invariant as the
4646
+ # auto-discovered lua_filters below).
4647
+ cmd += pandoc_options
4259
4648
  if is_tex_target and tablewidth_auto:
4260
4649
  cmd += ["--lua-filter", str(width_filter)]
4261
4650
  for lua_filter in lua_filters:
@@ -4276,6 +4665,11 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4276
4665
  if geometry_needed:
4277
4666
  note("MARGIN", f"{md_path}: no geometry/margin set; "
4278
4667
  f"using geometry:margin={DEFAULT_MARGIN} on LaTeX-family engines")
4668
+ margin_options = (None if auto_disabled(no_auto, "margin")
4669
+ else frontmatter_margin_geometry_options(md_path, variables, metadata_files))
4670
+ if margin_options:
4671
+ note("MARGIN", f"{md_path}: margin: isn't a Pandoc variable LaTeX-family engines "
4672
+ f"read (geometry: is) -- using geometry:{','.join(margin_options)}")
4279
4673
  monofont_needed = (not auto_disabled(no_auto, "monofont")
4280
4674
  and has_code_spans(md_path.read_text(encoding="utf-8-sig"))
4281
4675
  and not has_monofont(md_path, variables))
@@ -4343,7 +4737,8 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4343
4737
  remaining = [e for e in engines[engine_index + 1:] if ENGINE_FAMILY.get(e, e) not in failed_families]
4344
4738
  report_engine_failure(str(md_path), engine, result, remaining, debug)
4345
4739
  continue
4346
- with prepared_latex_inputs([title_source, *metadata_files], engine in LATEX_ENGINES) as prepared, \
4740
+ with prepared_latex_inputs([title_source, *metadata_files], engine in LATEX_ENGINES,
4741
+ typst_engine=(engine == "typst")) as prepared, \
4347
4742
  document_header_file(md_path, (bool(preamble_files) or bool(pdf_meta_snippet_text))
4348
4743
  and engine in LATEX_ENGINES) as header_file, \
4349
4744
  pdf_metadata_header_file(pdf_meta_snippet_text if engine in LATEX_ENGINES else None) as pdf_meta_file:
@@ -4367,6 +4762,9 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4367
4762
  cmd += ["-V", f"mainfontfallback={fallback_font()}"]
4368
4763
  if geometry_needed and engine in LATEX_ENGINES:
4369
4764
  cmd += ["-V", f"geometry:margin={DEFAULT_MARGIN}"]
4765
+ elif margin_options and engine in LATEX_ENGINES:
4766
+ for margin_option in margin_options:
4767
+ cmd += ["-V", f"geometry:{margin_option}"]
4370
4768
  if monofont_needed and engine in LATEX_ENGINES:
4371
4769
  cmd += ["-V", f"monofont={default_monofont()}"]
4372
4770
  if pagesize_typo and engine in LATEX_ENGINES:
@@ -4385,7 +4783,6 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4385
4783
  cmd += ["-V", f"papersize={typst_size}"]
4386
4784
  if shift_heading:
4387
4785
  cmd += ["--shift-heading-level-by=-1"]
4388
- cmd += pandoc_options
4389
4786
  if preamble_files and engine in LATEX_ENGINES:
4390
4787
  for preamble_file in preamble_files:
4391
4788
  cmd += ["--include-in-header", str(preamble_file)]
@@ -4402,6 +4799,15 @@ def _convert_one(md_path: Path, out_dir: Path | None, presentation: bool, font:
4402
4799
  cmd += crossref_filter_args(md_path, pandoc_options, no_auto, str(md_path))
4403
4800
  if contains_citations(md_path) and "--citeproc" not in pandoc_options and not CITEPROC_DISABLED:
4404
4801
  cmd.append("--citeproc")
4802
+ # pandoc_options after --citeproc, same reason as below: a
4803
+ # caller-supplied --lua-filter/--filter needs resolved
4804
+ # citations already in the AST. (This used to sit before
4805
+ # the --citeproc append above, which silently broke any
4806
+ # citeproc-dependent filter passed via extra CLI args --
4807
+ # e.g. `--lua-filter=some.lua` landed ahead of --citeproc
4808
+ # on the actual pandoc command line. Confirmed via
4809
+ # --verbose CMD output before this fix.)
4810
+ cmd += pandoc_options
4405
4811
  # Lua filters go last: pandoc applies --citeproc and filters in
4406
4812
  # command-line order, and a filter that renders cell contents to
4407
4813
  # LaTeX needs the citations already resolved. csv-table has to
@@ -4948,13 +5354,22 @@ def main() -> None:
4948
5354
  if report_geometry_needed:
4949
5355
  report_note("MARGIN", "REPORT: no geometry/margin set; "
4950
5356
  f"using geometry:margin={DEFAULT_MARGIN} on LaTeX-family targets")
5357
+ report_margin_options = (None if auto_disabled(report_no_auto, "margin")
5358
+ else frontmatter_margin_geometry_options(files[0], variables,
5359
+ metadata_files))
5360
+ if is_tex_target and report_margin_options:
5361
+ report_note("MARGIN", "REPORT: margin: isn't a Pandoc variable LaTeX-family "
5362
+ "targets read (geometry: is) -- using "
5363
+ f"geometry:{','.join(report_margin_options)}")
4951
5364
  report_monofont_needed = (is_tex_target and not auto_disabled(report_no_auto, "monofont")
4952
5365
  and any(has_code_spans(file.read_text(encoding="utf-8-sig")) for file in files)
4953
5366
  and not has_monofont(files[0], variables))
4954
5367
  if report_monofont_needed:
4955
5368
  report_note("MONOFONT", "REPORT: has code but no monofont set; "
4956
5369
  f"using {default_monofont()} on LaTeX-family targets")
4957
- with prepared_latex_inputs([*files, *metadata_files], is_tex_target) as prepared, \
5370
+ with prepared_latex_inputs([*files, *metadata_files], is_tex_target,
5371
+ typst_engine=(target_format == "typst"),
5372
+ doc_count=len(files)) as prepared, \
4958
5373
  document_header_file(files[0], (bool(report_preambles) or bool(report_pdf_meta_snippet_text))
4959
5374
  and is_tex_target) as report_header_file, \
4960
5375
  pdf_metadata_header_file(report_pdf_meta_snippet_text) as report_pdf_meta_file:
@@ -4982,11 +5397,13 @@ def main() -> None:
4982
5397
  cmd += ["-V", f"mainfont={report_first_font}"]
4983
5398
  if report_geometry_needed:
4984
5399
  cmd += ["-V", f"geometry:margin={DEFAULT_MARGIN}"]
5400
+ elif report_margin_options:
5401
+ for margin_option in report_margin_options:
5402
+ cmd += ["-V", f"geometry:{margin_option}"]
4985
5403
  if report_monofont_needed:
4986
5404
  cmd += ["-V", f"monofont={default_monofont()}"]
4987
5405
  for variable in variables:
4988
5406
  cmd += ["-V", variable]
4989
- cmd += pandoc_options
4990
5407
  if report_preambles and is_tex_target:
4991
5408
  for preamble in report_preambles:
4992
5409
  cmd += ["--include-in-header", str(preamble)]
@@ -5004,6 +5421,9 @@ def main() -> None:
5004
5421
  and "--citeproc" not in pandoc_options
5005
5422
  and not CITEPROC_DISABLED):
5006
5423
  cmd.append("--citeproc")
5424
+ # pandoc_options after --citeproc: see convert_one's
5425
+ # matching comment.
5426
+ cmd += pandoc_options
5007
5427
  if is_tex_target and not auto_disabled(report_no_auto, "tablewidth"):
5008
5428
  cmd += ["--lua-filter", str(width_filter)]
5009
5429
  log_cmd(cmd, pandoc_cwd, args.verbose)
@@ -5027,6 +5447,12 @@ def main() -> None:
5027
5447
  if geometry_needed:
5028
5448
  report_note("MARGIN", "REPORT: no geometry/margin set; "
5029
5449
  f"using geometry:margin={DEFAULT_MARGIN} on LaTeX-family engines")
5450
+ margin_options = (None if auto_disabled(report_no_auto, "margin")
5451
+ else frontmatter_margin_geometry_options(files[0], variables, metadata_files))
5452
+ if margin_options:
5453
+ report_note("MARGIN", "REPORT: margin: isn't a Pandoc variable LaTeX-family "
5454
+ "engines read (geometry: is) -- using "
5455
+ f"geometry:{','.join(margin_options)}")
5030
5456
  monofont_needed = (not auto_disabled(report_no_auto, "monofont")
5031
5457
  and any(has_code_spans(file.read_text(encoding="utf-8-sig")) for file in files)
5032
5458
  and not has_monofont(files[0], variables))
@@ -5057,7 +5483,9 @@ def main() -> None:
5057
5483
  print(f"SKIP REPORT: {engine} shares the {family} engine with an earlier "
5058
5484
  f"failure; skipping", file=sys.stderr)
5059
5485
  continue
5060
- with prepared_latex_inputs([*files, *metadata_files], engine in LATEX_ENGINES) as prepared, \
5486
+ with prepared_latex_inputs([*files, *metadata_files], engine in LATEX_ENGINES,
5487
+ typst_engine=(engine == "typst"),
5488
+ doc_count=len(files)) as prepared, \
5061
5489
  pdf_metadata_header_file(
5062
5490
  report_pdf_meta_snippet_text if engine in LATEX_ENGINES else None
5063
5491
  ) as report_pdf_meta_file:
@@ -5072,11 +5500,13 @@ def main() -> None:
5072
5500
  cmd += report_resource_path
5073
5501
  if geometry_needed and engine in LATEX_ENGINES:
5074
5502
  cmd += ["-V", f"geometry:margin={DEFAULT_MARGIN}"]
5503
+ elif margin_options and engine in LATEX_ENGINES:
5504
+ for margin_option in margin_options:
5505
+ cmd += ["-V", f"geometry:{margin_option}"]
5075
5506
  if monofont_needed and engine in LATEX_ENGINES:
5076
5507
  cmd += ["-V", f"monofont={default_monofont()}"]
5077
5508
  for variable in variables:
5078
5509
  cmd += ["-V", variable]
5079
- cmd += pandoc_options
5080
5510
  if preambles and engine in LATEX_ENGINES:
5081
5511
  for preamble in preambles:
5082
5512
  cmd += ["--include-in-header", str(preamble)]
@@ -5088,6 +5518,9 @@ def main() -> None:
5088
5518
  and "--citeproc" not in pandoc_options
5089
5519
  and not CITEPROC_DISABLED):
5090
5520
  cmd.append("--citeproc")
5521
+ # pandoc_options after --citeproc: see convert_one's
5522
+ # matching comment.
5523
+ cmd += pandoc_options
5091
5524
  if report_tablewidth_auto and engine in LATEX_ENGINES:
5092
5525
  cmd += ["--lua-filter", str(width_filter)]
5093
5526
  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.1
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