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.
Files changed (32) hide show
  1. obsidian2pdf-0.1.0/LICENSE +21 -0
  2. obsidian2pdf-0.1.0/PKG-INFO +63 -0
  3. obsidian2pdf-0.1.0/README.md +37 -0
  4. obsidian2pdf-0.1.0/obsidian2pdf/__init__.py +0 -0
  5. obsidian2pdf-0.1.0/obsidian2pdf/cli.py +163 -0
  6. obsidian2pdf-0.1.0/obsidian2pdf/collector/__init__.py +0 -0
  7. obsidian2pdf-0.1.0/obsidian2pdf/collector/ordering.py +60 -0
  8. obsidian2pdf-0.1.0/obsidian2pdf/collector/tree.py +59 -0
  9. obsidian2pdf-0.1.0/obsidian2pdf/pagespec.py +49 -0
  10. obsidian2pdf-0.1.0/obsidian2pdf/preprocess/__init__.py +0 -0
  11. obsidian2pdf-0.1.0/obsidian2pdf/preprocess/host_adapters.py +66 -0
  12. obsidian2pdf-0.1.0/obsidian2pdf/preprocess/pipeline.py +57 -0
  13. obsidian2pdf-0.1.0/obsidian2pdf/preprocess/transforms.py +224 -0
  14. obsidian2pdf-0.1.0/obsidian2pdf/render/__init__.py +0 -0
  15. obsidian2pdf-0.1.0/obsidian2pdf/render/cover.py +61 -0
  16. obsidian2pdf-0.1.0/obsidian2pdf/render/document.py +101 -0
  17. obsidian2pdf-0.1.0/obsidian2pdf/render/theme.css +64 -0
  18. obsidian2pdf-0.1.0/obsidian2pdf/vault.py +35 -0
  19. obsidian2pdf-0.1.0/obsidian2pdf/writer/__init__.py +0 -0
  20. obsidian2pdf-0.1.0/obsidian2pdf/writer/epub.py +218 -0
  21. obsidian2pdf-0.1.0/obsidian2pdf/writer/pdf.py +10 -0
  22. obsidian2pdf-0.1.0/obsidian2pdf.egg-info/PKG-INFO +63 -0
  23. obsidian2pdf-0.1.0/obsidian2pdf.egg-info/SOURCES.txt +30 -0
  24. obsidian2pdf-0.1.0/obsidian2pdf.egg-info/dependency_links.txt +1 -0
  25. obsidian2pdf-0.1.0/obsidian2pdf.egg-info/entry_points.txt +2 -0
  26. obsidian2pdf-0.1.0/obsidian2pdf.egg-info/requires.txt +10 -0
  27. obsidian2pdf-0.1.0/obsidian2pdf.egg-info/top_level.txt +1 -0
  28. obsidian2pdf-0.1.0/pyproject.toml +41 -0
  29. obsidian2pdf-0.1.0/setup.cfg +4 -0
  30. obsidian2pdf-0.1.0/tests/test_cli.py +145 -0
  31. obsidian2pdf-0.1.0/tests/test_pagespec.py +36 -0
  32. 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
+ ])