metafile-render 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.
- metafile_render/__init__.py +25 -0
- metafile_render/__main__.py +10 -0
- metafile_render/api.py +37 -0
- metafile_render/binary.py +77 -0
- metafile_render/cli.py +97 -0
- metafile_render/font.py +112 -0
- metafile_render/geometry.py +409 -0
- metafile_render/limits.py +42 -0
- metafile_render/models.py +342 -0
- metafile_render/parser.py +2017 -0
- metafile_render/py.typed +0 -0
- metafile_render/render.py +1148 -0
- metafile_render-0.1.0.dist-info/METADATA +166 -0
- metafile_render-0.1.0.dist-info/RECORD +18 -0
- metafile_render-0.1.0.dist-info/WHEEL +5 -0
- metafile_render-0.1.0.dist-info/entry_points.txt +2 -0
- metafile_render-0.1.0.dist-info/licenses/LICENSE +21 -0
- metafile_render-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Copyright (c) 2026 Xiaomeng Zhao (myhloli)
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""WMF/EMF 的跨平台渲染 API。"""
|
|
4
|
+
|
|
5
|
+
from .api import render_metafile
|
|
6
|
+
from .models import (
|
|
7
|
+
MetafileDiagnostic,
|
|
8
|
+
MetafileError,
|
|
9
|
+
MetafileMalformedError,
|
|
10
|
+
MetafileOutputFormat,
|
|
11
|
+
MetafileRenderResult,
|
|
12
|
+
MetafileResourceLimitError,
|
|
13
|
+
MetafileUnsupportedError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"MetafileDiagnostic",
|
|
18
|
+
"MetafileError",
|
|
19
|
+
"MetafileMalformedError",
|
|
20
|
+
"MetafileOutputFormat",
|
|
21
|
+
"MetafileRenderResult",
|
|
22
|
+
"MetafileResourceLimitError",
|
|
23
|
+
"MetafileUnsupportedError",
|
|
24
|
+
"render_metafile",
|
|
25
|
+
]
|
metafile_render/api.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Copyright (c) 2026 Xiaomeng Zhao (myhloli)
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""WMF/EMF 公共渲染入口。"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from .models import MetafileOutputFormat, MetafileRenderResult
|
|
8
|
+
from .parser import parse_metafile
|
|
9
|
+
from .render import encode_document
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render_metafile(
|
|
13
|
+
data: bytes,
|
|
14
|
+
*,
|
|
15
|
+
output_format: MetafileOutputFormat = "png",
|
|
16
|
+
dpi: int = 144,
|
|
17
|
+
size_hint: tuple[int, int] | None = None,
|
|
18
|
+
) -> MetafileRenderResult:
|
|
19
|
+
"""把 WMF/EMF 字节渲染为 PNG、JPEG、WebP 或安全 SVG。"""
|
|
20
|
+
if output_format not in {"png", "jpeg", "svg", "webp"}:
|
|
21
|
+
raise ValueError(f"unsupported metafile output format: {output_format}")
|
|
22
|
+
document = parse_metafile(data, dpi=dpi, size_hint=size_hint)
|
|
23
|
+
output, media_type = encode_document(document, output_format)
|
|
24
|
+
return MetafileRenderResult(
|
|
25
|
+
data=output,
|
|
26
|
+
output_format=output_format,
|
|
27
|
+
media_type=media_type,
|
|
28
|
+
width=document.width,
|
|
29
|
+
height=document.height,
|
|
30
|
+
source_format=document.source_format,
|
|
31
|
+
emfplus_mode=document.emfplus_mode,
|
|
32
|
+
partial=document.partial,
|
|
33
|
+
diagnostics=document.diagnostics,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = ["render_metafile"]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Copyright (c) 2026 Xiaomeng Zhao (myhloli)
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""WMF/EMF 使用的严格有界二进制读取器。"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import struct
|
|
8
|
+
|
|
9
|
+
from .models import MetafileMalformedError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BoundedReader:
|
|
13
|
+
"""在固定 memoryview 边界内读取小端标量和切片。"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, data: bytes | memoryview, *, base_offset: int = 0) -> None:
|
|
16
|
+
"""保存输入视图与用于诊断的绝对起始偏移。"""
|
|
17
|
+
self.data = memoryview(data)
|
|
18
|
+
self.base_offset = base_offset
|
|
19
|
+
|
|
20
|
+
def _require(self, offset: int, size: int) -> None:
|
|
21
|
+
"""验证读取范围,不允许负数或越过当前视图。"""
|
|
22
|
+
if offset < 0 or size < 0 or offset > len(self.data) - size:
|
|
23
|
+
raise MetafileMalformedError(
|
|
24
|
+
f"metafile field exceeds record boundary: offset={self.base_offset + offset}, size={size}"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def u8(self, offset: int) -> int:
|
|
28
|
+
"""读取小端无符号 8 位整数。"""
|
|
29
|
+
self._require(offset, 1)
|
|
30
|
+
return int(self.data[offset])
|
|
31
|
+
|
|
32
|
+
def i16(self, offset: int) -> int:
|
|
33
|
+
"""读取小端有符号 16 位整数。"""
|
|
34
|
+
self._require(offset, 2)
|
|
35
|
+
return int(struct.unpack_from("<h", self.data, offset)[0])
|
|
36
|
+
|
|
37
|
+
def u16(self, offset: int) -> int:
|
|
38
|
+
"""读取小端无符号 16 位整数。"""
|
|
39
|
+
self._require(offset, 2)
|
|
40
|
+
return int(struct.unpack_from("<H", self.data, offset)[0])
|
|
41
|
+
|
|
42
|
+
def i32(self, offset: int) -> int:
|
|
43
|
+
"""读取小端有符号 32 位整数。"""
|
|
44
|
+
self._require(offset, 4)
|
|
45
|
+
return int(struct.unpack_from("<i", self.data, offset)[0])
|
|
46
|
+
|
|
47
|
+
def u32(self, offset: int) -> int:
|
|
48
|
+
"""读取小端无符号 32 位整数。"""
|
|
49
|
+
self._require(offset, 4)
|
|
50
|
+
return int(struct.unpack_from("<I", self.data, offset)[0])
|
|
51
|
+
|
|
52
|
+
def f32(self, offset: int) -> float:
|
|
53
|
+
"""读取小端 IEEE-754 单精度浮点数。"""
|
|
54
|
+
self._require(offset, 4)
|
|
55
|
+
return float(struct.unpack_from("<f", self.data, offset)[0])
|
|
56
|
+
|
|
57
|
+
def bytes(self, offset: int, size: int) -> bytes:
|
|
58
|
+
"""返回经过边界校验的不可变字节切片。"""
|
|
59
|
+
self._require(offset, size)
|
|
60
|
+
return self.data[offset : offset + size].tobytes()
|
|
61
|
+
|
|
62
|
+
def subreader(self, offset: int, size: int) -> BoundedReader:
|
|
63
|
+
"""返回继承绝对偏移信息的有界子读取器。"""
|
|
64
|
+
self._require(offset, size)
|
|
65
|
+
return BoundedReader(self.data[offset : offset + size], base_offset=self.base_offset + offset)
|
|
66
|
+
|
|
67
|
+
def remaining(self, offset: int) -> int:
|
|
68
|
+
"""返回从指定位置到当前视图末尾的剩余字节数。"""
|
|
69
|
+
self._require(offset, 0)
|
|
70
|
+
return len(self.data) - offset
|
|
71
|
+
|
|
72
|
+
def __len__(self) -> int:
|
|
73
|
+
"""返回当前视图的总字节数。"""
|
|
74
|
+
return len(self.data)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = ["BoundedReader"]
|
metafile_render/cli.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Copyright (c) 2026 Xiaomeng Zhao (myhloli)
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""单文件 WMF/EMF 转换命令行。"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from importlib.metadata import version
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Sequence
|
|
14
|
+
|
|
15
|
+
from .api import render_metafile
|
|
16
|
+
from .limits import MAX_METAFILE_BYTES
|
|
17
|
+
from .models import MetafileError, MetafileOutputFormat, MetafileResourceLimitError
|
|
18
|
+
|
|
19
|
+
_OUTPUT_FORMATS: dict[str, MetafileOutputFormat] = {
|
|
20
|
+
".png": "png",
|
|
21
|
+
".jpg": "jpeg",
|
|
22
|
+
".jpeg": "jpeg",
|
|
23
|
+
".svg": "svg",
|
|
24
|
+
".webp": "webp",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _positive_integer(value: str) -> int:
|
|
29
|
+
"""将命令行尺寸解析为正整数,错误交由 argparse 报告。"""
|
|
30
|
+
try:
|
|
31
|
+
number = int(value)
|
|
32
|
+
except ValueError as exc:
|
|
33
|
+
raise argparse.ArgumentTypeError("must be a positive integer") from exc
|
|
34
|
+
if number <= 0:
|
|
35
|
+
raise argparse.ArgumentTypeError("must be a positive integer")
|
|
36
|
+
return number
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_input(path: Path) -> bytes:
|
|
40
|
+
"""最多读取输入预算加一字节,避免完整载入超大文件。"""
|
|
41
|
+
with path.open("rb") as stream:
|
|
42
|
+
data = stream.read(MAX_METAFILE_BYTES + 1)
|
|
43
|
+
if len(data) > MAX_METAFILE_BYTES:
|
|
44
|
+
raise MetafileResourceLimitError(f"metafile exceeds max_metafile_bytes={MAX_METAFILE_BYTES}")
|
|
45
|
+
return data
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _write_output(path: Path, data: bytes, *, overwrite: bool) -> None:
|
|
49
|
+
"""在目标目录原子发布完整文件,并保证默认模式不会覆盖并发创建的文件。"""
|
|
50
|
+
descriptor, filename = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
|
51
|
+
temporary = Path(filename)
|
|
52
|
+
try:
|
|
53
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
54
|
+
stream.write(data)
|
|
55
|
+
if overwrite:
|
|
56
|
+
os.replace(temporary, path)
|
|
57
|
+
else:
|
|
58
|
+
os.link(temporary, path)
|
|
59
|
+
finally:
|
|
60
|
+
temporary.unlink(missing_ok=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
64
|
+
"""解析转换参数并返回稳定退出码,所有运行诊断写入 stderr。"""
|
|
65
|
+
parser = argparse.ArgumentParser(prog="metafile-render", description="Render WMF/EMF to SVG, PNG, JPEG or WebP.")
|
|
66
|
+
parser.add_argument("input", type=Path, help="input WMF or EMF file")
|
|
67
|
+
parser.add_argument("-o", "--output", type=Path, required=True, help="output .svg, .png, .jpg, .jpeg or .webp file")
|
|
68
|
+
parser.add_argument("--dpi", type=_positive_integer, default=144, help="render DPI, 1–1200 (default: 144)")
|
|
69
|
+
parser.add_argument("--size", type=_positive_integer, nargs=2, metavar=("WIDTH", "HEIGHT"), help="pixel size hint")
|
|
70
|
+
parser.add_argument("--force", action="store_true", help="replace an existing output file")
|
|
71
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {version('metafile-render')}")
|
|
72
|
+
arguments = parser.parse_args(argv)
|
|
73
|
+
output_format = _OUTPUT_FORMATS.get(arguments.output.suffix.lower())
|
|
74
|
+
if output_format is None:
|
|
75
|
+
parser.error("output extension must be .svg, .png, .jpg, .jpeg or .webp")
|
|
76
|
+
if arguments.dpi > 1200:
|
|
77
|
+
parser.error("--dpi must be between 1 and 1200")
|
|
78
|
+
try:
|
|
79
|
+
source, destination = arguments.input, arguments.output
|
|
80
|
+
if source.resolve() == destination.resolve() or destination.exists() and source.samefile(destination):
|
|
81
|
+
parser.error("input and output must refer to different files")
|
|
82
|
+
if (destination.exists() or destination.is_symlink()) and not arguments.force:
|
|
83
|
+
raise FileExistsError(f"output already exists: {destination}; use --force to replace it")
|
|
84
|
+
size_hint = (arguments.size[0], arguments.size[1]) if arguments.size else None
|
|
85
|
+
result = render_metafile(_read_input(source), output_format=output_format, dpi=arguments.dpi, size_hint=size_hint)
|
|
86
|
+
_write_output(destination, result.data, overwrite=arguments.force)
|
|
87
|
+
except (MetafileError, OSError) as exc:
|
|
88
|
+
print(f"metafile-render: {exc}", file=sys.stderr)
|
|
89
|
+
return 1
|
|
90
|
+
if result.partial:
|
|
91
|
+
print("metafile-render: partial rendering", file=sys.stderr)
|
|
92
|
+
for diagnostic in result.diagnostics:
|
|
93
|
+
print(f"metafile-render: {diagnostic.level} [{diagnostic.code}] {diagnostic.message}", file=sys.stderr)
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
__all__ = ["main"]
|
metafile_render/font.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Copyright (c) 2026 Xiaomeng Zhao (myhloli)
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""WMF/EMF parser 与 renderer 共享的跨平台字体解析和度量。"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import platform
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from PIL import ImageFont
|
|
12
|
+
|
|
13
|
+
from .models import Font
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _font_search_roots() -> tuple[Path, ...]:
|
|
17
|
+
"""返回当前平台常见字体目录,不在模块导入时访问文件系统。"""
|
|
18
|
+
system = platform.system()
|
|
19
|
+
roots: list[Path] = []
|
|
20
|
+
if system == "Darwin":
|
|
21
|
+
roots.extend((Path("/System/Library/Fonts"), Path("/Library/Fonts"), Path.home() / "Library/Fonts"))
|
|
22
|
+
elif system == "Windows":
|
|
23
|
+
import os
|
|
24
|
+
|
|
25
|
+
windows_dir = Path(os.environ.get("WINDIR", "C:/Windows"))
|
|
26
|
+
roots.append(windows_dir / "Fonts")
|
|
27
|
+
else:
|
|
28
|
+
roots.extend(
|
|
29
|
+
(
|
|
30
|
+
Path("/usr/share/fonts"),
|
|
31
|
+
Path("/usr/local/share/fonts"),
|
|
32
|
+
Path.home() / ".fonts",
|
|
33
|
+
Path.home() / ".local/share/fonts",
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
return tuple(root for root in roots if root.is_dir())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@lru_cache(maxsize=1)
|
|
40
|
+
def _font_file_index() -> dict[str, str]:
|
|
41
|
+
"""惰性建立字体文件名索引,避免 import 时扫描系统目录。"""
|
|
42
|
+
index: dict[str, str] = {}
|
|
43
|
+
for root in _font_search_roots():
|
|
44
|
+
try:
|
|
45
|
+
candidates = root.rglob("*")
|
|
46
|
+
for candidate in candidates:
|
|
47
|
+
if candidate.suffix.lower() not in {".ttf", ".ttc", ".otf"}:
|
|
48
|
+
continue
|
|
49
|
+
index.setdefault(candidate.stem.casefold().replace(" ", ""), str(candidate))
|
|
50
|
+
except OSError:
|
|
51
|
+
continue
|
|
52
|
+
return index
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _font_aliases(face_name: str, charset: int) -> tuple[str, ...]:
|
|
56
|
+
"""返回 Windows 字体名在 Linux/macOS 上的固定替代顺序。"""
|
|
57
|
+
normalized = face_name.casefold().replace(" ", "")
|
|
58
|
+
alias_map = {
|
|
59
|
+
"arial": ("Arial", "LiberationSans-Regular", "DejaVuSans"),
|
|
60
|
+
"calibri": ("Calibri", "Carlito-Regular", "Arial", "DejaVuSans"),
|
|
61
|
+
"cambria": ("Cambria", "Caladea-Regular", "DejaVuSerif"),
|
|
62
|
+
"timesnewroman": ("Times New Roman", "LiberationSerif-Regular", "DejaVuSerif"),
|
|
63
|
+
"couriernew": ("Courier New", "LiberationMono-Regular", "DejaVuSansMono"),
|
|
64
|
+
"simsun": ("SimSun", "Songti SC", "NotoSerifCJKsc-Regular", "DejaVuSans"),
|
|
65
|
+
"microsoftyahei": ("Microsoft YaHei", "PingFang SC", "NotoSansCJKsc-Regular", "DejaVuSans"),
|
|
66
|
+
}
|
|
67
|
+
aliases = alias_map.get(normalized, (face_name,))
|
|
68
|
+
if charset in {128, 129, 134, 136}:
|
|
69
|
+
aliases = (*aliases, "PingFang SC", "NotoSansCJKsc-Regular", "NotoSansCJK-Regular", "DejaVuSans")
|
|
70
|
+
return tuple(dict.fromkeys((*aliases, "DejaVuSans")))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@lru_cache(maxsize=256)
|
|
74
|
+
def load_font(
|
|
75
|
+
face_name: str,
|
|
76
|
+
size: int,
|
|
77
|
+
weight: int,
|
|
78
|
+
italic: bool,
|
|
79
|
+
charset: int,
|
|
80
|
+
) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
81
|
+
"""按字体名、别名和文件索引加载 Pillow 字体。"""
|
|
82
|
+
normalized_size = max(1, min(size, 4096))
|
|
83
|
+
index = _font_file_index()
|
|
84
|
+
for alias in _font_aliases(face_name, charset):
|
|
85
|
+
keys = [alias.casefold().replace(" ", "")]
|
|
86
|
+
if weight >= 700:
|
|
87
|
+
keys.insert(0, f"{keys[0]}bold")
|
|
88
|
+
if italic:
|
|
89
|
+
keys.insert(0, f"{keys[0]}italic")
|
|
90
|
+
candidates = [alias, f"{alias}.ttf", *(index[key] for key in keys if key in index)]
|
|
91
|
+
for candidate in candidates:
|
|
92
|
+
try:
|
|
93
|
+
return ImageFont.truetype(candidate, normalized_size)
|
|
94
|
+
except OSError:
|
|
95
|
+
continue
|
|
96
|
+
return ImageFont.load_default(size=normalized_size)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def measure_text_advance(font: Font, text: str) -> float:
|
|
100
|
+
"""用 renderer 同款字体回退规则估算无显式 spacing 的逻辑 advance。"""
|
|
101
|
+
if not text:
|
|
102
|
+
return 0.0
|
|
103
|
+
font_size = max(1, round(abs(font.height or -12.0)))
|
|
104
|
+
loaded = load_font(font.face_name, font_size, font.weight, font.italic, font.charset)
|
|
105
|
+
advance = float(loaded.getlength(text))
|
|
106
|
+
if font.width:
|
|
107
|
+
natural_width = max(float(loaded.getlength("0")), 1e-9)
|
|
108
|
+
advance *= abs(font.width) / natural_width
|
|
109
|
+
return max(advance, 0.0)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
__all__ = ["load_font", "measure_text_advance"]
|