markdown-docx 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
Binary file
@@ -0,0 +1,47 @@
1
+ {
2
+ "format": "markdown-docx",
3
+ "version": "0.1.0",
4
+ "text": "markdown-docx 0.1.0 syntax\n\nSupported Markdown:\n ATX headings, paragraphs, emphasis, strong text, inline code, hard line breaks, fenced code blocks, blockquotes, ordered and unordered lists, pipe tables, and images.\n\nMetadata comments:\n document metadata must be first\n section metadata starts a next-page section\n <!-- markdown-docx: page-break --> inserts a page break\n table metadata must immediately precede a pipe table\n image metadata must immediately precede a standalone image\n\nLengths use in, cm, mm, or pt. Image widths may also use percentages.\n\nUnsupported in 0.1.0:\n links, raw HTML, task lists, footnotes, horizontal rules, indented code blocks, multi-paragraph list items, DOTX, floating images, headers and footers from Markdown, page-number fields, and direct OOXML features.",
5
+ "page_sizes": ["letter", "legal", "a4", "custom"],
6
+ "orientations": ["portrait", "landscape"],
7
+ "length_units": ["in", "cm", "mm", "pt"],
8
+ "directives": {
9
+ "document": {
10
+ "keys": ["page_size", "orientation", "margins", "styles", "fonts"],
11
+ "placement": "first non-whitespace content"
12
+ },
13
+ "section": {
14
+ "keys": ["page_size", "orientation", "margins"],
15
+ "shorthand": "default",
16
+ "break": "next-page"
17
+ },
18
+ "page_break": {
19
+ "syntax": "<!-- markdown-docx: page-break -->"
20
+ },
21
+ "table": {
22
+ "keys": ["style", "alignment", "width", "column_widths"],
23
+ "alignment": ["left", "center", "right"],
24
+ "width": ["auto", "page"]
25
+ },
26
+ "image": {
27
+ "keys": ["width", "alignment"],
28
+ "alignment": ["left", "center", "right"]
29
+ }
30
+ },
31
+ "default_styles": {
32
+ "paragraph": "Normal",
33
+ "headings": {
34
+ "1": "Heading 1",
35
+ "2": "Heading 2",
36
+ "3": "Heading 3",
37
+ "4": "Heading 4",
38
+ "5": "Heading 5",
39
+ "6": "Heading 6"
40
+ },
41
+ "blockquote": "Quote",
42
+ "code_block": "Code Block",
43
+ "ordered_list": ["List Number", "List Number 2", "List Number 3"],
44
+ "unordered_list": ["List Bullet", "List Bullet 2", "List Bullet 3"],
45
+ "table": "Table Grid"
46
+ }
47
+ }
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from importlib import resources
5
+ from typing import Any
6
+
7
+
8
+ def default_template_bytes() -> bytes:
9
+ return resources.files("markdown_docx").joinpath("assets", "default.docx").read_bytes()
10
+
11
+
12
+ def load_syntax_payload() -> dict[str, Any]:
13
+ text = resources.files("markdown_docx").joinpath("assets", "syntax.json").read_text(encoding="utf-8")
14
+ payload: dict[str, Any] = json.loads(text)
15
+ return payload
markdown_docx/cli.py ADDED
@@ -0,0 +1,318 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+ from typing import Any, NoReturn, TextIO
9
+
10
+ from markdown_docx import __version__
11
+ from markdown_docx.assets import load_syntax_payload
12
+ from markdown_docx.errors import EXIT_INTERNAL, InputError, MarkdownDocxError, UsageError
13
+ from markdown_docx.parser import parse_document
14
+ from markdown_docx.renderer import render_docx
15
+ from markdown_docx.skill import install_skill, remove_skill
16
+ from markdown_docx.template import inspect_template
17
+
18
+ PROGRAM_NAME = "markdown-docx"
19
+ PROJECT_URL = "https://github.com/pseudosavant/markdown-docx"
20
+ PROJECT_SUMMARY = "Convert constrained Markdown documents into editable Word files."
21
+ PROJECT_LICENSE = "MIT"
22
+ EXIT_CODES = (
23
+ (0, "success"),
24
+ (2, "usage or input error"),
25
+ (3, "Markdown or metadata parse error"),
26
+ (4, "template or style error"),
27
+ (5, "image or asset error"),
28
+ (6, "unsupported Markdown or feature"),
29
+ (7, "DOCX rendering error"),
30
+ (8, "unexpected internal error"),
31
+ )
32
+
33
+
34
+ class CliArgumentParser(argparse.ArgumentParser):
35
+ def error(self, message: str) -> NoReturn:
36
+ raise UsageError(message)
37
+
38
+
39
+ def build_parser() -> argparse.ArgumentParser:
40
+ parser = CliArgumentParser(prog=PROGRAM_NAME, description=PROJECT_SUMMARY, add_help=False)
41
+ parser.add_argument("-h", "--help", action="store_true")
42
+ parser.add_argument("input", nargs="?", help="Input Markdown path, or '-' for stdin.")
43
+ parser.add_argument("output", nargs="?", help="Optional output .docx path.")
44
+ parser.add_argument("--input", dest="input_flag", help="Input Markdown path, or '-' for stdin.")
45
+ parser.add_argument("--output", dest="output_flag", help="Output .docx path.")
46
+ parser.add_argument("--template", help="Blank DOCX formatting template.")
47
+ parser.add_argument("--base-dir", help="Resolve stdin image paths from this directory.")
48
+ parser.add_argument("--force", action="store_true", help="Overwrite an existing generated DOCX.")
49
+ parser.add_argument("--no-remote-images", action="store_true", help="Reject HTTP and HTTPS images.")
50
+ parser.add_argument("--json", action="store_true", help="Emit one structured JSON object.")
51
+ parser.add_argument("--syntax", action="store_true", help="Show the complete input syntax.")
52
+ parser.add_argument("--list-styles", action="store_true", help="List paragraph and character styles.")
53
+ parser.add_argument("--list-table-styles", action="store_true", help="List table styles.")
54
+ parser.add_argument("--inspect-template", action="store_true", help="Inspect blank-template compatibility.")
55
+ parser.add_argument("--about", action="store_true", help="Show project metadata.")
56
+ parser.add_argument("--version", action="store_true", help="Show the installed version.")
57
+ return parser
58
+
59
+
60
+ def build_root_help() -> str:
61
+ exit_lines = "\n".join(f" {code} {meaning}" for code, meaning in EXIT_CODES)
62
+ return f"""{PROGRAM_NAME} {__version__}
63
+ {PROJECT_SUMMARY}
64
+
65
+ Usage:
66
+ {PROGRAM_NAME} INPUT.md [OUTPUT.docx] [OPTIONS]
67
+ {PROGRAM_NAME} --input - --output OUTPUT.docx --base-dir DIR [OPTIONS]
68
+
69
+ Happy path:
70
+ {PROGRAM_NAME} document.md
71
+ {PROGRAM_NAME} document.md output.docx --template formatting.docx
72
+
73
+ Inspection:
74
+ {PROGRAM_NAME} --syntax [--json]
75
+ {PROGRAM_NAME} --inspect-template [--template formatting.docx] [--json]
76
+ {PROGRAM_NAME} --list-styles [--template formatting.docx] [--json]
77
+ {PROGRAM_NAME} --list-table-styles [--template formatting.docx] [--json]
78
+
79
+ Agent skill:
80
+ {PROGRAM_NAME} skill install [--skills-dir DIR] [--json]
81
+ {PROGRAM_NAME} skill remove [--skills-dir DIR] [--force] [--json]
82
+
83
+ Common options:
84
+ -h, --help Show this quick reference.
85
+ --template PATH Use a blank DOCX formatting template.
86
+ --base-dir PATH Resolve relative stdin assets from PATH.
87
+ --force Overwrite an existing generated DOCX.
88
+ --no-remote-images Reject HTTP and HTTPS images.
89
+ --json Emit structured output.
90
+
91
+ Metadata:
92
+ {PROGRAM_NAME} --about
93
+ {PROGRAM_NAME} --version
94
+
95
+ Exit codes:
96
+ {exit_lines}
97
+
98
+ Project: {PROJECT_URL}
99
+ License: {PROJECT_LICENSE}
100
+ """
101
+
102
+
103
+ def main(
104
+ argv: Sequence[str] | None = None,
105
+ *,
106
+ stdin: TextIO | None = None,
107
+ stdout: TextIO | None = None,
108
+ stderr: TextIO | None = None,
109
+ ) -> int:
110
+ stdin = sys.stdin if stdin is None else stdin
111
+ stdout = sys.stdout if stdout is None else stdout
112
+ stderr = sys.stderr if stderr is None else stderr
113
+ args_list = list(sys.argv[1:] if argv is None else argv)
114
+ json_mode = "--json" in args_list
115
+ try:
116
+ if not args_list:
117
+ stdout.write(build_root_help())
118
+ return 0
119
+ if "-h" in args_list or "--help" in args_list:
120
+ stdout.write(build_skill_help() if args_list[0] == "skill" else build_root_help())
121
+ return 0
122
+ if "--version" in args_list:
123
+ if args_list != ["--version"]:
124
+ raise UsageError("--version cannot be combined with other arguments.")
125
+ stdout.write(f"{PROGRAM_NAME} {__version__}\n")
126
+ return 0
127
+ if "--about" in args_list:
128
+ if args_list != ["--about"]:
129
+ raise UsageError("--about cannot be combined with other arguments.")
130
+ stdout.write(
131
+ f"{PROGRAM_NAME} {__version__}\n{PROJECT_SUMMARY}\nProject: {PROJECT_URL}\nLicense: {PROJECT_LICENSE}\n"
132
+ )
133
+ return 0
134
+ if args_list[0] == "skill":
135
+ return _run_skill_command(args_list[1:], stdout=stdout)
136
+ args = build_parser().parse_args(args_list)
137
+ return _run(args, stdin=stdin, stdout=stdout)
138
+ except MarkdownDocxError as exc:
139
+ _write_error(exc, json_mode=json_mode, stdout=stdout, stderr=stderr)
140
+ return exc.context.exit_code
141
+ except KeyboardInterrupt:
142
+ stderr.write("interrupted: operation cancelled\n")
143
+ return 130
144
+ except Exception as exc:
145
+ message = f"unexpected {type(exc).__name__}: {exc}"
146
+ if json_mode:
147
+ stdout.write(
148
+ json.dumps({"ok": False, "error": {"code": "internal_error", "message": message}}, indent=2) + "\n"
149
+ )
150
+ else:
151
+ stderr.write(f"internal_error: {message}\n")
152
+ return EXIT_INTERNAL
153
+
154
+
155
+ def _run(args: argparse.Namespace, *, stdin: TextIO, stdout: TextIO) -> int:
156
+ inspection = [
157
+ name for name in ("syntax", "list_styles", "list_table_styles", "inspect_template") if getattr(args, name)
158
+ ]
159
+ if len(inspection) > 1:
160
+ raise UsageError("Inspection modes are mutually exclusive.")
161
+ if inspection:
162
+ return _run_inspection(args, mode=inspection[0], stdout=stdout)
163
+
164
+ input_arg = args.input_flag or args.input
165
+ output_arg = args.output_flag or args.output
166
+ if args.input_flag and args.input:
167
+ raise UsageError("Use either positional input or --input, not both.")
168
+ if args.output_flag and args.output:
169
+ raise UsageError("Use either positional output or --output, not both.")
170
+ if not input_arg:
171
+ raise UsageError("An input Markdown file is required.")
172
+ if input_arg == "-":
173
+ if not output_arg:
174
+ raise UsageError("stdin input requires an output path.")
175
+ if not args.base_dir:
176
+ raise UsageError("stdin input requires --base-dir for relative assets.")
177
+ base_dir = Path(args.base_dir).resolve()
178
+ if not base_dir.is_dir():
179
+ raise InputError("invalid_base_dir", f"Base directory does not exist: {base_dir}", input_path=str(base_dir))
180
+ source = stdin.read()
181
+ input_path = None
182
+ source_name = "<stdin>"
183
+ else:
184
+ if args.base_dir:
185
+ raise UsageError("--base-dir is valid only with stdin input.")
186
+ input_path = Path(input_arg).resolve()
187
+ source = _read_input(input_path)
188
+ base_dir = input_path.parent
189
+ source_name = str(input_path)
190
+ output_path = Path(output_arg).resolve() if output_arg else input_path.with_suffix(".docx") # type: ignore[union-attr]
191
+ if output_path.suffix.lower() != ".docx":
192
+ raise UsageError("Output path must use the .docx extension.")
193
+ if output_path.exists() and not args.force:
194
+ raise InputError("output_exists", f"Output already exists: {output_path}", input_path=str(output_path))
195
+ template_path = Path(args.template).resolve() if args.template else None
196
+ model = parse_document(source, input_path=input_path, source_name=source_name)
197
+ result = render_docx(
198
+ model,
199
+ output_path,
200
+ template_path=template_path,
201
+ base_dir=base_dir,
202
+ allow_remote_images=not args.no_remote_images,
203
+ )
204
+ payload = {
205
+ "ok": True,
206
+ "mode": "render",
207
+ "input": source_name,
208
+ "template": str(template_path) if template_path else "packaged-default",
209
+ **result,
210
+ }
211
+ if args.json:
212
+ stdout.write(json.dumps(payload, indent=2) + "\n")
213
+ else:
214
+ stdout.write(str(output_path) + "\n")
215
+ return 0
216
+
217
+
218
+ def _run_inspection(args: argparse.Namespace, *, mode: str, stdout: TextIO) -> int:
219
+ allowed = {"json", mode}
220
+ if mode != "syntax":
221
+ allowed.add("template")
222
+ _validate_args(args, allowed=allowed)
223
+ if mode == "syntax":
224
+ syntax = load_syntax_payload()
225
+ payload = {"ok": True, "mode": "syntax", **syntax}
226
+ plain = syntax["text"].rstrip() + "\n"
227
+ else:
228
+ template_path = Path(args.template).resolve() if args.template else None
229
+ details = inspect_template(template_path)
230
+ if mode == "list_styles":
231
+ styles = [*details["styles"]["paragraph"], *details["styles"]["character"]]
232
+ payload = {"ok": True, "mode": mode, "template": details["template"], "styles": styles}
233
+ plain = "\n".join(styles) + "\n"
234
+ elif mode == "list_table_styles":
235
+ styles = details["styles"]["table"]
236
+ payload = {"ok": True, "mode": mode, "template": details["template"], "styles": styles}
237
+ plain = "\n".join(styles) + "\n"
238
+ else:
239
+ payload = {"ok": True, "mode": mode, **details}
240
+ plain = _format_template_inspection(details)
241
+ stdout.write(json.dumps(payload, indent=2) + "\n" if args.json else plain)
242
+ return 0
243
+
244
+
245
+ def _read_input(path: Path) -> str:
246
+ if not path.is_file():
247
+ raise InputError("input_not_found", f"Input does not exist: {path}", input_path=str(path))
248
+ try:
249
+ return path.read_text(encoding="utf-8")
250
+ except UnicodeDecodeError as exc:
251
+ raise InputError("input_not_utf8", f"Input must be UTF-8: {path}", input_path=str(path)) from exc
252
+
253
+
254
+ def _validate_args(args: argparse.Namespace, *, allowed: set[str]) -> None:
255
+ ignored = {"help", "about", "version"}
256
+ for name, value in vars(args).items():
257
+ if name in allowed or name in ignored or value in (None, False):
258
+ continue
259
+ raise UsageError(f"--{name.replace('_', '-')} cannot be used with this inspection mode.")
260
+
261
+
262
+ def _format_template_inspection(details: dict[str, Any]) -> str:
263
+ status = "valid" if details["valid"] else "invalid"
264
+ lines = [f"Template: {details['template']}", f"Blank-template contract: {status}"]
265
+ lines.extend(f"Error: {error}" for error in details["errors"])
266
+ for key in ("paragraph", "character", "table"):
267
+ lines.append(f"{key.title()} styles: {len(details['styles'][key])}")
268
+ for section in details["sections"]:
269
+ lines.append(
270
+ f"Section {section['index']}: {section['orientation']}, "
271
+ f"{section['width_inches']} x {section['height_inches']} in"
272
+ )
273
+ return "\n".join(lines) + "\n"
274
+
275
+
276
+ def _write_error(exc: MarkdownDocxError, *, json_mode: bool, stdout: TextIO, stderr: TextIO) -> None:
277
+ if json_mode:
278
+ stdout.write(json.dumps({"ok": False, "error": exc.context.as_dict()}, indent=2) + "\n")
279
+ return
280
+ prefix = exc.context.input_path or ""
281
+ if prefix and exc.context.line is not None:
282
+ prefix += f":{exc.context.line}"
283
+ if prefix:
284
+ prefix += ": "
285
+ stderr.write(f"{prefix}{exc.context.code}: {exc.context.message}\n")
286
+
287
+
288
+ def build_skill_help() -> str:
289
+ return f"""Usage:
290
+ {PROGRAM_NAME} skill install [--skills-dir DIR] [--json]
291
+ {PROGRAM_NAME} skill remove [--skills-dir DIR] [--force] [--json]
292
+
293
+ Install or remove the managed `{PROGRAM_NAME}` agent skill. The default root is ~/.agents/skills.
294
+ Removal refuses unmanaged content unless --force is supplied.
295
+ """
296
+
297
+
298
+ def _run_skill_command(args_list: list[str], *, stdout: TextIO) -> int:
299
+ parser = CliArgumentParser(prog=f"{PROGRAM_NAME} skill", add_help=False)
300
+ parser.add_argument("action", choices=("install", "remove"))
301
+ parser.add_argument("--skills-dir", type=Path)
302
+ parser.add_argument("--force", action="store_true")
303
+ parser.add_argument("--json", action="store_true")
304
+ args = parser.parse_args(args_list)
305
+ if args.action == "install" and args.force:
306
+ raise UsageError("--force is valid only with 'skill remove'.")
307
+ root = args.skills_dir.resolve() if args.skills_dir else None
308
+ result = install_skill(root) if args.action == "install" else remove_skill(root, force=args.force)
309
+ if args.json:
310
+ stdout.write(json.dumps({"ok": True, "mode": f"skill_{args.action}", **result}, indent=2) + "\n")
311
+ elif args.action == "install":
312
+ verb = "Installed" if result["created"] else "Updated" if result["updated"] else "Already installed"
313
+ stdout.write(f"{verb} {result['path']}\n")
314
+ elif result["removed"]:
315
+ stdout.write(f"Removed {result['path']}\n")
316
+ else:
317
+ stdout.write(f"Skill is not installed at {result['path']}\n")
318
+ return 0
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from typing import Any
5
+
6
+ EXIT_OK = 0
7
+ EXIT_USAGE = 2
8
+ EXIT_PARSE = 3
9
+ EXIT_TEMPLATE = 4
10
+ EXIT_ASSET = 5
11
+ EXIT_UNSUPPORTED = 6
12
+ EXIT_RENDER = 7
13
+ EXIT_INTERNAL = 8
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class ErrorContext:
18
+ code: str
19
+ message: str
20
+ exit_code: int
21
+ line: int | None = None
22
+ input_path: str | None = None
23
+ metadata_kind: str | None = None
24
+ details: dict[str, Any] | None = None
25
+
26
+ def as_dict(self) -> dict[str, Any]:
27
+ payload = asdict(self)
28
+ return {key: value for key, value in payload.items() if value is not None and key != "exit_code"}
29
+
30
+
31
+ class MarkdownDocxError(Exception):
32
+ def __init__(
33
+ self,
34
+ code: str,
35
+ message: str,
36
+ *,
37
+ exit_code: int,
38
+ line: int | None = None,
39
+ input_path: str | None = None,
40
+ metadata_kind: str | None = None,
41
+ details: dict[str, Any] | None = None,
42
+ ) -> None:
43
+ super().__init__(message)
44
+ self.context = ErrorContext(
45
+ code=code,
46
+ message=message,
47
+ exit_code=exit_code,
48
+ line=line,
49
+ input_path=input_path,
50
+ metadata_kind=metadata_kind,
51
+ details=details,
52
+ )
53
+
54
+
55
+ class UsageError(MarkdownDocxError):
56
+ def __init__(self, message: str) -> None:
57
+ super().__init__("usage_error", message, exit_code=EXIT_USAGE)
58
+
59
+
60
+ class InputError(MarkdownDocxError):
61
+ def __init__(self, code: str, message: str, *, input_path: str | None = None) -> None:
62
+ super().__init__(code, message, exit_code=EXIT_USAGE, input_path=input_path)
63
+
64
+
65
+ class ParseError(MarkdownDocxError):
66
+ def __init__(
67
+ self,
68
+ code: str,
69
+ message: str,
70
+ *,
71
+ line: int | None = None,
72
+ input_path: str | None = None,
73
+ metadata_kind: str | None = None,
74
+ details: dict[str, Any] | None = None,
75
+ ) -> None:
76
+ super().__init__(
77
+ code,
78
+ message,
79
+ exit_code=EXIT_PARSE,
80
+ line=line,
81
+ input_path=input_path,
82
+ metadata_kind=metadata_kind,
83
+ details=details,
84
+ )
85
+
86
+
87
+ class TemplateError(MarkdownDocxError):
88
+ def __init__(self, code: str, message: str, *, details: dict[str, Any] | None = None) -> None:
89
+ super().__init__(code, message, exit_code=EXIT_TEMPLATE, details=details)
90
+
91
+
92
+ class AssetError(MarkdownDocxError):
93
+ def __init__(
94
+ self,
95
+ code: str,
96
+ message: str,
97
+ *,
98
+ line: int | None = None,
99
+ input_path: str | None = None,
100
+ ) -> None:
101
+ super().__init__(code, message, exit_code=EXIT_ASSET, line=line, input_path=input_path)
102
+
103
+
104
+ class UnsupportedFeatureError(MarkdownDocxError):
105
+ def __init__(
106
+ self,
107
+ message: str,
108
+ *,
109
+ line: int | None = None,
110
+ input_path: str | None = None,
111
+ code: str = "unsupported_feature",
112
+ ) -> None:
113
+ super().__init__(code, message, exit_code=EXIT_UNSUPPORTED, line=line, input_path=input_path)
114
+
115
+
116
+ class RenderError(MarkdownDocxError):
117
+ def __init__(self, code: str, message: str, *, details: dict[str, Any] | None = None) -> None:
118
+ super().__init__(code, message, exit_code=EXIT_RENDER, details=details)
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from io import BytesIO
5
+ from pathlib import Path
6
+ from urllib.parse import urlsplit, urlunsplit
7
+
8
+ import httpx
9
+ from PIL import Image, UnidentifiedImageError
10
+
11
+ from markdown_docx.errors import AssetError
12
+ from markdown_docx.metadata import EMU_PER_INCH
13
+ from markdown_docx.models import ImageOptions
14
+
15
+ MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024
16
+ MAX_IMAGE_PIXELS = 50_000_000
17
+ DOWNLOAD_TIMEOUT_SECONDS = 15.0
18
+
19
+
20
+ @dataclass(slots=True, frozen=True)
21
+ class ImageAsset:
22
+ data: bytes
23
+ natural_width: int
24
+ natural_height: int
25
+
26
+
27
+ class ImageLoader:
28
+ def __init__(self, base_dir: Path, *, allow_remote: bool) -> None:
29
+ self.base_dir = base_dir
30
+ self.allow_remote = allow_remote
31
+ self.cache: dict[str, ImageAsset] = {}
32
+
33
+ def load(self, source: str, *, line: int, input_path: str | None) -> ImageAsset:
34
+ if source in self.cache:
35
+ return self.cache[source]
36
+ if source.startswith(("http://", "https://")):
37
+ if not self.allow_remote:
38
+ raise AssetError(
39
+ "image_download_failed",
40
+ f"Remote images are disabled: {_safe_url(source)}",
41
+ line=line,
42
+ input_path=input_path,
43
+ )
44
+ data = self._download(source, line=line, input_path=input_path)
45
+ else:
46
+ path = Path(source)
47
+ if not path.is_absolute():
48
+ path = self.base_dir / path
49
+ path = path.resolve()
50
+ if not path.is_file():
51
+ raise AssetError("image_not_found", f"Image does not exist: {path}", line=line, input_path=input_path)
52
+ if path.stat().st_size > MAX_DOWNLOAD_BYTES:
53
+ raise AssetError(
54
+ "image_too_large", f"Image exceeds the 25 MiB limit: {path}", line=line, input_path=input_path
55
+ )
56
+ data = path.read_bytes()
57
+ asset = _decode_image(data, source=source, line=line, input_path=input_path)
58
+ self.cache[source] = asset
59
+ return asset
60
+
61
+ def _download(self, source: str, *, line: int, input_path: str | None) -> bytes:
62
+ safe_source = _safe_url(source)
63
+ try:
64
+ with httpx.Client(follow_redirects=True, timeout=DOWNLOAD_TIMEOUT_SECONDS) as client:
65
+ with client.stream("GET", source) as response:
66
+ response.raise_for_status()
67
+ content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
68
+ if not content_type.startswith("image/"):
69
+ raise AssetError(
70
+ "image_download_failed",
71
+ f"Remote image response is not an image: {safe_source}",
72
+ line=line,
73
+ input_path=input_path,
74
+ )
75
+ content_length = response.headers.get("content-length")
76
+ if content_length and int(content_length) > MAX_DOWNLOAD_BYTES:
77
+ raise AssetError(
78
+ "image_too_large",
79
+ f"Remote image exceeds the 25 MiB limit: {safe_source}",
80
+ line=line,
81
+ input_path=input_path,
82
+ )
83
+ chunks: list[bytes] = []
84
+ total = 0
85
+ for chunk in response.iter_bytes():
86
+ total += len(chunk)
87
+ if total > MAX_DOWNLOAD_BYTES:
88
+ raise AssetError(
89
+ "image_too_large",
90
+ f"Remote image exceeds the 25 MiB limit: {safe_source}",
91
+ line=line,
92
+ input_path=input_path,
93
+ )
94
+ chunks.append(chunk)
95
+ return b"".join(chunks)
96
+ except AssetError:
97
+ raise
98
+ except (httpx.HTTPError, ValueError) as exc:
99
+ raise AssetError(
100
+ "image_download_failed",
101
+ f"Could not download image: {safe_source}",
102
+ line=line,
103
+ input_path=input_path,
104
+ ) from exc
105
+
106
+
107
+ def rendered_width(asset: ImageAsset, options: ImageOptions, *, usable_width: int, line: int, input_path: str) -> int:
108
+ if options.width is None:
109
+ return min(asset.natural_width, usable_width)
110
+ if options.width_is_percent:
111
+ return round(usable_width * float(options.width) / 100)
112
+ width = int(options.width)
113
+ if width > usable_width:
114
+ raise AssetError(
115
+ "image_too_wide",
116
+ "Explicit image width exceeds the active section's usable width.",
117
+ line=line,
118
+ input_path=input_path,
119
+ )
120
+ return width
121
+
122
+
123
+ def _decode_image(data: bytes, *, source: str, line: int, input_path: str | None) -> ImageAsset:
124
+ try:
125
+ with Image.open(BytesIO(data)) as image:
126
+ width_px, height_px = image.size
127
+ if width_px * height_px > MAX_IMAGE_PIXELS:
128
+ raise AssetError(
129
+ "image_too_large",
130
+ f"Image exceeds the 50 megapixel limit: {_safe_source(source)}",
131
+ line=line,
132
+ input_path=input_path,
133
+ )
134
+ dpi = image.info.get("dpi", (72, 72))
135
+ dpi_x = float(dpi[0]) if isinstance(dpi, tuple) and dpi and dpi[0] else 72.0
136
+ dpi_y = float(dpi[1]) if isinstance(dpi, tuple) and len(dpi) > 1 and dpi[1] else dpi_x
137
+ image.verify()
138
+ except AssetError:
139
+ raise
140
+ except (UnidentifiedImageError, OSError, ValueError, TypeError) as exc:
141
+ raise AssetError(
142
+ "image_invalid",
143
+ f"Image is corrupt or uses an unsupported format: {_safe_source(source)}",
144
+ line=line,
145
+ input_path=input_path,
146
+ ) from exc
147
+ natural_width = max(1, round(width_px / dpi_x * EMU_PER_INCH))
148
+ natural_height = max(1, round(height_px / dpi_y * EMU_PER_INCH))
149
+ return ImageAsset(data=data, natural_width=natural_width, natural_height=natural_height)
150
+
151
+
152
+ def _safe_url(url: str) -> str:
153
+ parts = urlsplit(url)
154
+ host = parts.hostname or ""
155
+ if parts.port:
156
+ host = f"{host}:{parts.port}"
157
+ return urlunsplit((parts.scheme, host, parts.path, parts.query, parts.fragment))
158
+
159
+
160
+ def _safe_source(source: str) -> str:
161
+ return _safe_url(source) if source.startswith(("http://", "https://")) else source