Skip to content

Commit e0243f3

Browse files
committed
Release v1.1.0: Added immage support
1 parent 0f592da commit e0243f3

7 files changed

Lines changed: 135 additions & 3 deletions

File tree

document_placeholder/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""DocumentPlaceholder — fill Word templates using YAML configs."""
22

3-
__version__ = "1.0.0.post1"
3+
__version__ = "1.1.0"

document_placeholder/cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from pathlib import Path
88

99
import document_placeholder.functions.date # noqa: F401 — register functions
10+
import document_placeholder.functions.image # noqa: F401
1011
import document_placeholder.functions.logic # noqa: F401
1112
import document_placeholder.functions.math # noqa: F401
1213
import document_placeholder.functions.string # noqa: F401
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Функции для вставки изображений в документ."""
2+
3+
from __future__ import annotations
4+
5+
from document_placeholder.functions import FunctionRegistry
6+
from document_placeholder.image_value import ImageValue
7+
8+
_reg = FunctionRegistry.register
9+
10+
11+
@_reg("IMAGE")
12+
def image(
13+
source: str,
14+
width_cm: float | None = None,
15+
height_cm: float | None = None,
16+
) -> ImageValue:
17+
"""Вставить изображение по URL или пути к файлу.
18+
19+
source: URL (https://...) или путь к файлу (.png, .jpg, ...)
20+
width_cm, height_cm: размер в см (опционально). Если не заданы — 5 см по ширине.
21+
"""
22+
w = float(width_cm) if width_cm is not None else 5.0
23+
h = float(height_cm) if height_cm is not None else None
24+
return ImageValue(source=source, width_cm=w, height_cm=h)

document_placeholder/gui.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from tkinter import filedialog
1212

1313
import document_placeholder.functions.date # noqa: F401 — register functions
14+
import document_placeholder.functions.image # noqa: F401
1415
import document_placeholder.functions.logic # noqa: F401
1516
import document_placeholder.functions.math # noqa: F401
1617
import document_placeholder.functions.string # noqa: F401
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Значение-изображение для вставки в документ."""
2+
3+
from __future__ import annotations
4+
5+
6+
class ImageValue:
7+
"""Дескриптор изображения: URL или путь к файлу. Processor вставит картинку."""
8+
9+
__slots__ = ("source", "width_cm", "height_cm")
10+
11+
def __init__(
12+
self,
13+
source: str,
14+
width_cm: float | None = None,
15+
height_cm: float | None = None,
16+
) -> None:
17+
self.source = source.strip()
18+
self.width_cm = width_cm
19+
self.height_cm = height_cm

document_placeholder/processor.py

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22

33
from __future__ import annotations
44

5+
import urllib.request
6+
from io import BytesIO
57
from pathlib import Path
68
from typing import Any
79

810
from docx import Document
11+
from docx.shared import Cm
12+
13+
from document_placeholder.image_value import ImageValue
914

1015

1116
class DocumentProcessor:
@@ -43,16 +48,97 @@ def _replace_in_paragraph(paragraph, values: dict[str, Any]) -> None:
4348
if not runs:
4449
return
4550

46-
full_text = "".join(run.text for run in runs)
51+
for key, value in values.items():
52+
if not isinstance(value, ImageValue):
53+
continue
54+
placeholder = "{" + key + "}"
55+
for run in runs:
56+
if placeholder in run.text:
57+
run.text = run.text.replace(placeholder, "")
58+
try:
59+
stream = DocumentProcessor._load_image(value.source)
60+
w = value.width_cm if value.width_cm is not None else 5.0
61+
kwargs: dict[str, Any] = {"width": Cm(w)}
62+
if value.height_cm is not None:
63+
kwargs["height"] = Cm(value.height_cm)
64+
run.add_picture(stream, **kwargs)
65+
except (OSError, ValueError, KeyError):
66+
pass
67+
break
4768

69+
full_text = "".join(run.text for run in runs)
4870
new_text = full_text
4971
for key, value in values.items():
72+
if isinstance(value, ImageValue):
73+
continue
5074
placeholder = "{" + key + "}"
5175
if placeholder in new_text:
5276
display = str(value) if value is not None else ""
77+
display = DocumentProcessor._sanitize_xml_text(display)
5378
new_text = new_text.replace(placeholder, display)
5479

5580
if new_text != full_text:
56-
runs[0].text = new_text
81+
runs[0].text = DocumentProcessor._sanitize_xml_text(new_text)
5782
for run in runs[1:]:
5883
run.text = ""
84+
85+
@staticmethod
86+
def _sanitize_xml_text(text: str) -> str:
87+
"""Удалить символы, недопустимые в XML (NULL, control chars)."""
88+
if not text:
89+
return ""
90+
result = []
91+
for c in str(text):
92+
code = ord(c)
93+
if code == 0x9 or code == 0xA or code == 0xD:
94+
result.append(c)
95+
elif 0x20 <= code <= 0xD7FF or 0xE000 <= code <= 0xFFFD:
96+
result.append(c)
97+
elif 0x10000 <= code <= 0x10FFFF:
98+
result.append(c)
99+
else:
100+
result.append(" ")
101+
return "".join(result)
102+
103+
@staticmethod
104+
def _load_image(source: str) -> BytesIO:
105+
"""Загрузить изображение из URL или файла. SVG конвертируется в PNG."""
106+
source = source.strip()
107+
if source.startswith(("http://", "https://")):
108+
req = urllib.request.Request(
109+
source,
110+
headers={"User-Agent": "DocumentPlaceholder/1.0"},
111+
)
112+
with urllib.request.urlopen(req, timeout=30) as resp:
113+
data = resp.read()
114+
else:
115+
path = Path(source)
116+
if path.exists():
117+
data = path.read_bytes()
118+
else:
119+
raise FileNotFoundError(source)
120+
121+
return DocumentProcessor._ensure_raster(data, source)
122+
123+
@staticmethod
124+
def _ensure_raster(data: bytes, source: str = "") -> BytesIO:
125+
"""Конвертировать SVG в PNG, остальные форматы вернуть как есть."""
126+
if DocumentProcessor._is_svg(data, source):
127+
try:
128+
from cairosvg import svg2png
129+
130+
png_out = BytesIO()
131+
svg2png(bytestring=data, write_to=png_out)
132+
png_out.seek(0)
133+
return png_out
134+
except Exception:
135+
raise ValueError("SVG conversion failed")
136+
return BytesIO(data)
137+
138+
@staticmethod
139+
def _is_svg(data: bytes, source: str) -> bool:
140+
"""Проверить, является ли контент SVG."""
141+
if source.lower().endswith(".svg") or source.lower().endswith(".svgz"):
142+
return True
143+
start = data.lstrip()[:200].decode("utf-8", errors="ignore")
144+
return start.lstrip().startswith("<svg") or start.lstrip().startswith("<?xml")

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ classifiers = [
3333
dependencies = [
3434
"python-docx>=1.1.0",
3535
"PyYAML>=6.0",
36+
"cairosvg>=2.7.0",
3637
]
3738

3839
[project.optional-dependencies]

0 commit comments

Comments
 (0)