obsidian2pdf 0.1.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.
- obsidian2pdf-0.1.0/LICENSE +21 -0
- obsidian2pdf-0.1.0/PKG-INFO +63 -0
- obsidian2pdf-0.1.0/README.md +37 -0
- obsidian2pdf-0.1.0/obsidian2pdf/__init__.py +0 -0
- obsidian2pdf-0.1.0/obsidian2pdf/cli.py +163 -0
- obsidian2pdf-0.1.0/obsidian2pdf/collector/__init__.py +0 -0
- obsidian2pdf-0.1.0/obsidian2pdf/collector/ordering.py +60 -0
- obsidian2pdf-0.1.0/obsidian2pdf/collector/tree.py +59 -0
- obsidian2pdf-0.1.0/obsidian2pdf/pagespec.py +49 -0
- obsidian2pdf-0.1.0/obsidian2pdf/preprocess/__init__.py +0 -0
- obsidian2pdf-0.1.0/obsidian2pdf/preprocess/host_adapters.py +66 -0
- obsidian2pdf-0.1.0/obsidian2pdf/preprocess/pipeline.py +57 -0
- obsidian2pdf-0.1.0/obsidian2pdf/preprocess/transforms.py +224 -0
- obsidian2pdf-0.1.0/obsidian2pdf/render/__init__.py +0 -0
- obsidian2pdf-0.1.0/obsidian2pdf/render/cover.py +61 -0
- obsidian2pdf-0.1.0/obsidian2pdf/render/document.py +101 -0
- obsidian2pdf-0.1.0/obsidian2pdf/render/theme.css +64 -0
- obsidian2pdf-0.1.0/obsidian2pdf/vault.py +35 -0
- obsidian2pdf-0.1.0/obsidian2pdf/writer/__init__.py +0 -0
- obsidian2pdf-0.1.0/obsidian2pdf/writer/epub.py +218 -0
- obsidian2pdf-0.1.0/obsidian2pdf/writer/pdf.py +10 -0
- obsidian2pdf-0.1.0/obsidian2pdf.egg-info/PKG-INFO +63 -0
- obsidian2pdf-0.1.0/obsidian2pdf.egg-info/SOURCES.txt +30 -0
- obsidian2pdf-0.1.0/obsidian2pdf.egg-info/dependency_links.txt +1 -0
- obsidian2pdf-0.1.0/obsidian2pdf.egg-info/entry_points.txt +2 -0
- obsidian2pdf-0.1.0/obsidian2pdf.egg-info/requires.txt +10 -0
- obsidian2pdf-0.1.0/obsidian2pdf.egg-info/top_level.txt +1 -0
- obsidian2pdf-0.1.0/pyproject.toml +41 -0
- obsidian2pdf-0.1.0/setup.cfg +4 -0
- obsidian2pdf-0.1.0/tests/test_cli.py +145 -0
- obsidian2pdf-0.1.0/tests/test_pagespec.py +36 -0
- obsidian2pdf-0.1.0/tests/test_vault.py +29 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 afamrk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: obsidian2pdf
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Export Obsidian notes and folders to e-reader-ready PDFs
|
|
5
|
+
Author: afamrk
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: obsidian,pdf,epub,kobo,markdown
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: markdown>=3.5
|
|
17
|
+
Requires-Dist: weasyprint>=60
|
|
18
|
+
Requires-Dist: pygments>=2.17
|
|
19
|
+
Requires-Dist: curl_cffi>=0.7
|
|
20
|
+
Requires-Dist: ebooklib>=0.19
|
|
21
|
+
Requires-Dist: Pillow>=10.1
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
24
|
+
Requires-Dist: pypdf>=4.0; extra == "test"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# obsidian2pdf
|
|
28
|
+
|
|
29
|
+
Export an Obsidian note or folder to a single PDF — including a page preset
|
|
30
|
+
matching the Kobo Libra Colour screen so notes read comfortably on the device.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
python3 -m venv .venv
|
|
35
|
+
.venv/bin/pip install -e .
|
|
36
|
+
|
|
37
|
+
WeasyPrint needs Pango; on Debian/Ubuntu:
|
|
38
|
+
`sudo apt install libpango-1.0-0 libpangoft2-1.0-0`.
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
obsidian2pdf <note.md | folder> [options]
|
|
43
|
+
|
|
44
|
+
-o, --output PATH output PDF (default: <input name>.pdf next to input)
|
|
45
|
+
-s, --size SIZE a4 (default), a5, kobo-libra-colour, kobo-clara,
|
|
46
|
+
custom "1264x1680@300" (px@dpi) or "91x120mm";
|
|
47
|
+
ignored when --format is epub (reflowable, no
|
|
48
|
+
fixed page)
|
|
49
|
+
--format NAME pdf (default) | epub
|
|
50
|
+
--order NAME alpha (default) | makemd (manual order from the
|
|
51
|
+
make.md plugin's .space/context.mdb)
|
|
52
|
+
--ignore EXT[,EXT..] drop embeds of these media types, e.g. "png,mp3"
|
|
53
|
+
--no-cover skip the cover page
|
|
54
|
+
-v, --verbose progress info
|
|
55
|
+
|
|
56
|
+
## What it does with a folder
|
|
57
|
+
|
|
58
|
+
Notes merge into one PDF: each file name becomes a heading, headings inside
|
|
59
|
+
the file shift one level down; subfolders become nested sections; a folder
|
|
60
|
+
note (`X/X.md`) renders as the section intro. Frontmatter is stripped,
|
|
61
|
+
`![[image]]` embeds are resolved vault-wide, remote images are downloaded at
|
|
62
|
+
export time, YouTube/note embeds render as styled references, and fenced code
|
|
63
|
+
blocks get Pygments syntax highlighting.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# obsidian2pdf
|
|
2
|
+
|
|
3
|
+
Export an Obsidian note or folder to a single PDF — including a page preset
|
|
4
|
+
matching the Kobo Libra Colour screen so notes read comfortably on the device.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
python3 -m venv .venv
|
|
9
|
+
.venv/bin/pip install -e .
|
|
10
|
+
|
|
11
|
+
WeasyPrint needs Pango; on Debian/Ubuntu:
|
|
12
|
+
`sudo apt install libpango-1.0-0 libpangoft2-1.0-0`.
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
obsidian2pdf <note.md | folder> [options]
|
|
17
|
+
|
|
18
|
+
-o, --output PATH output PDF (default: <input name>.pdf next to input)
|
|
19
|
+
-s, --size SIZE a4 (default), a5, kobo-libra-colour, kobo-clara,
|
|
20
|
+
custom "1264x1680@300" (px@dpi) or "91x120mm";
|
|
21
|
+
ignored when --format is epub (reflowable, no
|
|
22
|
+
fixed page)
|
|
23
|
+
--format NAME pdf (default) | epub
|
|
24
|
+
--order NAME alpha (default) | makemd (manual order from the
|
|
25
|
+
make.md plugin's .space/context.mdb)
|
|
26
|
+
--ignore EXT[,EXT..] drop embeds of these media types, e.g. "png,mp3"
|
|
27
|
+
--no-cover skip the cover page
|
|
28
|
+
-v, --verbose progress info
|
|
29
|
+
|
|
30
|
+
## What it does with a folder
|
|
31
|
+
|
|
32
|
+
Notes merge into one PDF: each file name becomes a heading, headings inside
|
|
33
|
+
the file shift one level down; subfolders become nested sections; a folder
|
|
34
|
+
note (`X/X.md`) renders as the section intro. Frontmatter is stripped,
|
|
35
|
+
`![[image]]` embeds are resolved vault-wide, remote images are downloaded at
|
|
36
|
+
export time, YouTube/note embeds render as styled references, and fenced code
|
|
37
|
+
blocks get Pygments syntax highlighting.
|
|
File without changes
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Command-line entry point: wires collector, pipeline, renderer, writer."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import vault
|
|
10
|
+
from .collector.ordering import AlphabeticalOrderStrategy, MakeMdOrderStrategy
|
|
11
|
+
from .collector.tree import build_tree
|
|
12
|
+
from .pagespec import PRESETS, resolve_size
|
|
13
|
+
from .writer.epub import write_epub
|
|
14
|
+
from .writer.pdf import write_pdf
|
|
15
|
+
from .preprocess.pipeline import default_pipeline
|
|
16
|
+
from .preprocess.transforms import NoteContext
|
|
17
|
+
from .render.cover import cover_html
|
|
18
|
+
from .render.document import DocumentRenderer, build_document, stylesheet
|
|
19
|
+
|
|
20
|
+
ORDERINGS = {
|
|
21
|
+
"alpha": AlphabeticalOrderStrategy,
|
|
22
|
+
"makemd": MakeMdOrderStrategy,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_EXAMPLES = """\
|
|
27
|
+
examples:
|
|
28
|
+
# A folder, as A4 PDF (the default)
|
|
29
|
+
obsidian2pdf ~/vault/Docker
|
|
30
|
+
|
|
31
|
+
# Kobo-ready PDF, respecting make.md's manual note order
|
|
32
|
+
obsidian2pdf ~/vault/Kubernetes -s kobo-libra-colour --order makemd -o kubernetes.pdf
|
|
33
|
+
|
|
34
|
+
# EPUB, dropping images/audio, no syntax highlighting, no cover
|
|
35
|
+
obsidian2pdf ~/vault/Docker --format epub --ignore png,jpg,mp3 --no-highlight --no-cover -o docker.epub
|
|
36
|
+
|
|
37
|
+
# EPUB with your own cover artwork instead of the generated title page
|
|
38
|
+
obsidian2pdf ~/vault/Kubernetes --format epub --cover-image ~/art/cover.jpg -o kubernetes.epub
|
|
39
|
+
|
|
40
|
+
# A single note instead of a folder
|
|
41
|
+
obsidian2pdf ~/vault/Docker/Basic.md -o basic.pdf
|
|
42
|
+
|
|
43
|
+
# Custom page size for a different e-reader (px@dpi or mm)
|
|
44
|
+
obsidian2pdf ~/vault/Kubernetes -s "1072x1448@300" -v -o kubernetes-custom.pdf
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|
49
|
+
parser = argparse.ArgumentParser(
|
|
50
|
+
prog="obsidian2pdf",
|
|
51
|
+
description="Export an Obsidian note or folder to a single PDF or EPUB.",
|
|
52
|
+
epilog=_EXAMPLES,
|
|
53
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument("input", type=Path, help="path to a .md note or folder")
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"-o", "--output", type=Path,
|
|
58
|
+
help="output file (default: <input name>.<format> next to the input)",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"-s", "--size", default="a4",
|
|
62
|
+
help=f"page size: {', '.join(sorted(PRESETS))}, WxH@DPI, or WxHmm "
|
|
63
|
+
"(default: a4)",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--order", choices=sorted(ORDERINGS), default="alpha",
|
|
67
|
+
help="child ordering per folder (default: alpha)",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--ignore", default="", metavar="EXT[,EXT...]",
|
|
71
|
+
help='media types to drop from the output, e.g. "png,mp3"',
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--no-cover", action="store_true", help="skip the cover page"
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--cover-image", type=Path, default=None, metavar="PATH",
|
|
78
|
+
help="use this image as the EPUB's cover thumbnail instead of the "
|
|
79
|
+
"generated one; ignored for --format pdf and with --no-cover",
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--no-highlight", action="store_true",
|
|
83
|
+
help="disable syntax highlighting in code blocks",
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--format", choices=("pdf", "epub"), default="pdf",
|
|
87
|
+
help="output format (default: pdf); --size has no effect on epub",
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"-v", "--verbose", action="store_true", help="print progress info"
|
|
91
|
+
)
|
|
92
|
+
return parser.parse_args(argv)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main(argv: list[str] | None = None) -> int:
|
|
96
|
+
args = parse_args(argv)
|
|
97
|
+
input_path = args.input.expanduser().resolve()
|
|
98
|
+
if not input_path.exists():
|
|
99
|
+
print(f"error: {input_path} does not exist", file=sys.stderr)
|
|
100
|
+
return 2
|
|
101
|
+
try:
|
|
102
|
+
page = resolve_size(args.size)
|
|
103
|
+
except ValueError as exc:
|
|
104
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
105
|
+
return 2
|
|
106
|
+
if args.cover_image is not None and not args.cover_image.is_file():
|
|
107
|
+
print(f"error: {args.cover_image} does not exist", file=sys.stderr)
|
|
108
|
+
return 2
|
|
109
|
+
|
|
110
|
+
ignored = frozenset(
|
|
111
|
+
ext.strip().lstrip(".").lower()
|
|
112
|
+
for ext in args.ignore.split(",")
|
|
113
|
+
if ext.strip()
|
|
114
|
+
)
|
|
115
|
+
vault_root = vault.find_vault_root(input_path)
|
|
116
|
+
attachments = vault.build_attachment_index(vault_root)
|
|
117
|
+
if args.verbose:
|
|
118
|
+
print(f"vault root: {vault_root} ({len(attachments)} attachments)")
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
tree = build_tree(input_path, ORDERINGS[args.order]())
|
|
122
|
+
except ValueError as exc:
|
|
123
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
124
|
+
return 1
|
|
125
|
+
|
|
126
|
+
output = args.output or input_path.parent / (input_path.stem + f".{args.format}")
|
|
127
|
+
with tempfile.TemporaryDirectory(prefix="obsidian2pdf-") as cache:
|
|
128
|
+
def make_context(note_path: Path) -> NoteContext:
|
|
129
|
+
return NoteContext(
|
|
130
|
+
note_path=note_path,
|
|
131
|
+
vault_root=vault_root,
|
|
132
|
+
attachments=attachments,
|
|
133
|
+
ignored_exts=ignored,
|
|
134
|
+
image_cache_dir=Path(cache),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
highlight = not args.no_highlight
|
|
138
|
+
renderer = DocumentRenderer(default_pipeline(), make_context, highlight)
|
|
139
|
+
cover = "" if args.no_cover else cover_html(tree.title)
|
|
140
|
+
|
|
141
|
+
if args.format == "epub":
|
|
142
|
+
css = stylesheet(PRESETS["a4"], highlight)
|
|
143
|
+
custom_cover_image = None
|
|
144
|
+
if cover and args.cover_image is not None:
|
|
145
|
+
custom_cover_image = (
|
|
146
|
+
args.cover_image.read_bytes(),
|
|
147
|
+
f"cover-image{args.cover_image.suffix}",
|
|
148
|
+
)
|
|
149
|
+
write_epub(tree, renderer, output, css, cover, custom_cover_image)
|
|
150
|
+
else:
|
|
151
|
+
body = renderer.render(tree)
|
|
152
|
+
document = build_document(body, stylesheet(page, highlight), cover)
|
|
153
|
+
write_pdf(document, output)
|
|
154
|
+
|
|
155
|
+
for warning in renderer.warnings:
|
|
156
|
+
print(f"warning: {warning}", file=sys.stderr)
|
|
157
|
+
if args.verbose:
|
|
158
|
+
print(f"wrote {output}")
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == "__main__":
|
|
163
|
+
sys.exit(main())
|
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Ordering strategies for a folder's children (Strategy pattern)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import sqlite3
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OrderingStrategy(Protocol):
|
|
11
|
+
def order(self, folder: Path, entries: list[Path]) -> list[Path]: ...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _natural_key(path: Path) -> list:
|
|
15
|
+
return [
|
|
16
|
+
int(tok) if tok.isdigit() else tok.casefold()
|
|
17
|
+
for tok in re.split(r"(\d+)", path.name)
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AlphabeticalOrderStrategy:
|
|
22
|
+
"""Natural sort: '2 foo' comes before '10 foo'."""
|
|
23
|
+
|
|
24
|
+
def order(self, folder: Path, entries: list[Path]) -> list[Path]:
|
|
25
|
+
return sorted(entries, key=_natural_key)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MakeMdOrderStrategy:
|
|
29
|
+
"""Manual order from make.md: row order of the `files` table in
|
|
30
|
+
<folder>/.space/context.mdb (verified: rowid order == sidebar order).
|
|
31
|
+
|
|
32
|
+
Entries missing from the table are appended alphabetically; table rows
|
|
33
|
+
with no file on disk are ignored. No database -> alphabetical.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def order(self, folder: Path, entries: list[Path]) -> list[Path]:
|
|
37
|
+
rank = {name: i for i, name in enumerate(self._read_order(folder))}
|
|
38
|
+
ranked = sorted(
|
|
39
|
+
(e for e in entries if e.name in rank), key=lambda e: rank[e.name]
|
|
40
|
+
)
|
|
41
|
+
unranked = [e for e in entries if e.name not in rank]
|
|
42
|
+
return ranked + AlphabeticalOrderStrategy().order(folder, unranked)
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def _read_order(folder: Path) -> list[str]:
|
|
46
|
+
db = folder / ".space" / "context.mdb"
|
|
47
|
+
if not db.is_file():
|
|
48
|
+
return []
|
|
49
|
+
try:
|
|
50
|
+
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
51
|
+
try:
|
|
52
|
+
rows = con.execute(
|
|
53
|
+
'SELECT "File" FROM files ORDER BY rowid'
|
|
54
|
+
).fetchall()
|
|
55
|
+
finally:
|
|
56
|
+
con.close()
|
|
57
|
+
except sqlite3.Error:
|
|
58
|
+
return []
|
|
59
|
+
# Values are vault-relative paths ("Kubernetes/Basic.md"); keep names.
|
|
60
|
+
return [Path(str(r[0])).name for r in rows]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Document tree: Composite of folder sections and notes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .ordering import OrderingStrategy
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class NoteNode:
|
|
12
|
+
path: Path
|
|
13
|
+
title: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class SectionNode:
|
|
18
|
+
path: Path
|
|
19
|
+
title: str
|
|
20
|
+
intro: NoteNode | None = None # the folder note (X/X.md), if any
|
|
21
|
+
children: list["SectionNode | NoteNode"] = field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_tree(input_path: Path, strategy: OrderingStrategy) -> SectionNode | NoteNode:
|
|
25
|
+
input_path = input_path.resolve()
|
|
26
|
+
if input_path.is_file():
|
|
27
|
+
return NoteNode(input_path, input_path.stem)
|
|
28
|
+
section = _build_section(input_path, strategy)
|
|
29
|
+
if section.intro is None and not section.children:
|
|
30
|
+
raise ValueError(f"no markdown notes found under {input_path}")
|
|
31
|
+
return section
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _has_notes(folder: Path) -> bool:
|
|
35
|
+
return any(
|
|
36
|
+
not any(part.startswith(".") for part in p.relative_to(folder).parts)
|
|
37
|
+
for p in folder.rglob("*.md")
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _build_section(folder: Path, strategy: OrderingStrategy) -> SectionNode:
|
|
42
|
+
section = SectionNode(folder, folder.name)
|
|
43
|
+
entries: list[Path] = []
|
|
44
|
+
for child in folder.iterdir():
|
|
45
|
+
if child.name.startswith("."):
|
|
46
|
+
continue
|
|
47
|
+
if child.is_file() and child.suffix == ".md":
|
|
48
|
+
if child.stem == folder.name:
|
|
49
|
+
section.intro = NoteNode(child, child.stem)
|
|
50
|
+
else:
|
|
51
|
+
entries.append(child)
|
|
52
|
+
elif child.is_dir() and _has_notes(child):
|
|
53
|
+
entries.append(child)
|
|
54
|
+
for path in strategy.order(folder, entries):
|
|
55
|
+
if path.is_dir():
|
|
56
|
+
section.children.append(_build_section(path, strategy))
|
|
57
|
+
else:
|
|
58
|
+
section.children.append(NoteNode(path, path.stem))
|
|
59
|
+
return section
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Page geometry: named presets and custom size specs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class PageSpec:
|
|
10
|
+
name: str
|
|
11
|
+
width_mm: float
|
|
12
|
+
height_mm: float
|
|
13
|
+
base_font_pt: float = 10.0
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def css_size(self) -> str:
|
|
17
|
+
return f"{self.width_mm:.2f}mm {self.height_mm:.2f}mm"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Kobo presets are the device screens at 300 ppi, converted to millimetres.
|
|
21
|
+
PRESETS = {
|
|
22
|
+
"a4": PageSpec("a4", 210.0, 297.0, 11.0),
|
|
23
|
+
"a5": PageSpec("a5", 148.0, 210.0, 10.0),
|
|
24
|
+
"kobo-libra-colour": PageSpec("kobo-libra-colour", 107.0, 142.2, 8.5),
|
|
25
|
+
"kobo-clara": PageSpec("kobo-clara", 90.8, 122.6, 8.0),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_PX_SPEC = re.compile(r"^(\d+)x(\d+)@(\d+)$")
|
|
29
|
+
_MM_SPEC = re.compile(r"^(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)mm$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_size(spec: str) -> PageSpec:
|
|
33
|
+
"""Resolve a preset name, 'WxH@DPI' (pixels), or 'WxHmm' into a PageSpec."""
|
|
34
|
+
key = spec.strip().lower()
|
|
35
|
+
if key in PRESETS:
|
|
36
|
+
return PRESETS[key]
|
|
37
|
+
m = _PX_SPEC.match(key)
|
|
38
|
+
if m:
|
|
39
|
+
w_px, h_px, dpi = (int(g) for g in m.groups())
|
|
40
|
+
if dpi == 0:
|
|
41
|
+
raise ValueError(f"dpi must be positive in size spec {spec!r}")
|
|
42
|
+
return PageSpec(key, w_px / dpi * 25.4, h_px / dpi * 25.4)
|
|
43
|
+
m = _MM_SPEC.match(key)
|
|
44
|
+
if m:
|
|
45
|
+
return PageSpec(key, float(m.group(1)), float(m.group(2)))
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"unknown page size {spec!r} — presets: {', '.join(sorted(PRESETS))}; "
|
|
48
|
+
"custom: WxH@DPI (pixels) or WxHmm"
|
|
49
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Per-host adapters for fetching hotlink-protected remote images.
|
|
2
|
+
|
|
3
|
+
Mirrors the ordering Strategy pattern (collector/ordering.py): each host
|
|
4
|
+
that needs special handling gets its own adapter; hosts with no adapter
|
|
5
|
+
fall through to a generic one that sets a parent-domain Referer.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import urllib.parse
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HostAdapter(Protocol):
|
|
15
|
+
def matches(self, url: str) -> bool: ...
|
|
16
|
+
|
|
17
|
+
def fetch_target(self, url: str) -> tuple[str, str]:
|
|
18
|
+
"""Returns (url_to_fetch, referer)."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ImgurAdapter:
|
|
23
|
+
"""i.imgur.com direct links resolve to an HTML "removed" page (status
|
|
24
|
+
200, not 404) unless routed through Imgur's download endpoint with a
|
|
25
|
+
Referer from the image's own page."""
|
|
26
|
+
|
|
27
|
+
_DIRECT = re.compile(r"^https?://i\.imgur\.com/([A-Za-z0-9]+)(?:\.\w+)?$")
|
|
28
|
+
|
|
29
|
+
def matches(self, url: str) -> bool:
|
|
30
|
+
return bool(self._DIRECT.match(url))
|
|
31
|
+
|
|
32
|
+
def fetch_target(self, url: str) -> tuple[str, str]:
|
|
33
|
+
image_id = self._DIRECT.match(url).group(1)
|
|
34
|
+
return (
|
|
35
|
+
f"https://imgur.com/download/{image_id}/",
|
|
36
|
+
f"https://imgur.com/{image_id}",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GenericAdapter:
|
|
41
|
+
"""Fallback for hosts with no dedicated adapter: leave the URL as-is
|
|
42
|
+
and set a Referer from the parent domain, since hotlink protection
|
|
43
|
+
commonly trusts same-site Referers (e.g. a CDN subdomain trusting
|
|
44
|
+
requests referred from its own root domain)."""
|
|
45
|
+
|
|
46
|
+
def matches(self, url: str) -> bool:
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
def fetch_target(self, url: str) -> tuple[str, str]:
|
|
50
|
+
netloc = urllib.parse.urlparse(url).netloc
|
|
51
|
+
parts = netloc.split(".")
|
|
52
|
+
parent = ".".join(parts[1:]) if len(parts) > 2 else netloc
|
|
53
|
+
return url, f"https://{parent}/"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_ADAPTERS: list[HostAdapter] = [ImgurAdapter()]
|
|
57
|
+
_DEFAULT_ADAPTER: HostAdapter = GenericAdapter()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_fetch_target(url: str) -> tuple[str, str]:
|
|
61
|
+
"""Returns (url_to_fetch, referer) for the first matching adapter, or
|
|
62
|
+
the generic adapter's result when no host-specific adapter applies."""
|
|
63
|
+
for adapter in _ADAPTERS:
|
|
64
|
+
if adapter.matches(url):
|
|
65
|
+
return adapter.fetch_target(url)
|
|
66
|
+
return _DEFAULT_ADAPTER.fetch_target(url)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Runs transforms in order with fenced and inline code masked out, so
|
|
2
|
+
Obsidian syntax inside code samples is never rewritten."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .transforms import (
|
|
8
|
+
FrontmatterStripper,
|
|
9
|
+
IgnoredMediaFilter,
|
|
10
|
+
ImageEmbedResolver,
|
|
11
|
+
LocalMdImageResolver,
|
|
12
|
+
NoteContext,
|
|
13
|
+
NoteEmbedToLink,
|
|
14
|
+
RemoteImageFetcher,
|
|
15
|
+
Transform,
|
|
16
|
+
VideoEmbedToLink,
|
|
17
|
+
WikilinkToText,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Pipeline:
|
|
22
|
+
_FENCED = re.compile(
|
|
23
|
+
r"^(```|~~~)[^\n]*\n.*?^\1[ \t]*$", re.DOTALL | re.MULTILINE
|
|
24
|
+
)
|
|
25
|
+
_INLINE = re.compile(r"`[^`\n]+`")
|
|
26
|
+
_SLOT = re.compile(r"\x00(\d+)\x00")
|
|
27
|
+
|
|
28
|
+
def __init__(self, transforms: list[Transform]):
|
|
29
|
+
self.transforms = list(transforms)
|
|
30
|
+
|
|
31
|
+
def run(self, text: str, ctx: NoteContext) -> str:
|
|
32
|
+
stash: list[str] = []
|
|
33
|
+
|
|
34
|
+
def mask(m: re.Match) -> str:
|
|
35
|
+
stash.append(m.group(0))
|
|
36
|
+
return f"\x00{len(stash) - 1}\x00"
|
|
37
|
+
|
|
38
|
+
masked = self._FENCED.sub(mask, text)
|
|
39
|
+
masked = self._INLINE.sub(mask, masked)
|
|
40
|
+
for transform in self.transforms:
|
|
41
|
+
masked = transform.apply(masked, ctx)
|
|
42
|
+
return self._SLOT.sub(lambda m: stash[int(m.group(1))], masked)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def default_pipeline() -> Pipeline:
|
|
46
|
+
"""Order matters: filter ignored media before resolvers; classify video
|
|
47
|
+
URLs before the remote fetcher; wiki image embeds before generic embeds."""
|
|
48
|
+
return Pipeline([
|
|
49
|
+
FrontmatterStripper(),
|
|
50
|
+
IgnoredMediaFilter(),
|
|
51
|
+
VideoEmbedToLink(),
|
|
52
|
+
ImageEmbedResolver(),
|
|
53
|
+
NoteEmbedToLink(),
|
|
54
|
+
WikilinkToText(),
|
|
55
|
+
LocalMdImageResolver(),
|
|
56
|
+
RemoteImageFetcher(),
|
|
57
|
+
])
|