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.
- {pdfmd_cli-3.11.1/pdfmd_cli.egg-info → pdfmd_cli-3.15.0}/PKG-INFO +13 -1
- {pdfmd_cli-3.11.1 → pdfmd_cli-3.15.0}/README.md +12 -0
- {pdfmd_cli-3.11.1 → pdfmd_cli-3.15.0}/pdfmd.py +454 -17
- {pdfmd_cli-3.11.1 → pdfmd_cli-3.15.0/pdfmd_cli.egg-info}/PKG-INFO +13 -1
- {pdfmd_cli-3.11.1 → pdfmd_cli-3.15.0}/LICENSE +0 -0
- {pdfmd_cli-3.11.1 → pdfmd_cli-3.15.0}/pdfmd_cli.egg-info/SOURCES.txt +0 -0
- {pdfmd_cli-3.11.1 → pdfmd_cli-3.15.0}/pdfmd_cli.egg-info/dependency_links.txt +0 -0
- {pdfmd_cli-3.11.1 → pdfmd_cli-3.15.0}/pdfmd_cli.egg-info/entry_points.txt +0 -0
- {pdfmd_cli-3.11.1 → pdfmd_cli-3.15.0}/pdfmd_cli.egg-info/requires.txt +0 -0
- {pdfmd_cli-3.11.1 → pdfmd_cli-3.15.0}/pdfmd_cli.egg-info/top_level.txt +0 -0
- {pdfmd_cli-3.11.1 → pdfmd_cli-3.15.0}/pyproject.toml +0 -0
- {pdfmd_cli-3.11.1 → pdfmd_cli-3.15.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pdfmd-cli
|
|
3
|
-
Version: 3.
|
|
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
|
+
[](https://github.com/aliperdehan/pdfmd/actions/workflows/publish.yml) [](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
|
+
[](https://github.com/aliperdehan/pdfmd/actions/workflows/publish.yml) [](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.
|
|
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
|
|
1582
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
|
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
|
|
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'
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
[](https://github.com/aliperdehan/pdfmd/actions/workflows/publish.yml) [](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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|