@yujp_vc/ppt-assistant 0.0.0-stage → 1.5.2
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.
- package/LICENSE +21 -0
- package/README.md +131 -2
- package/asset_catalog.py +159 -0
- package/assets/base_template.pptx +0 -0
- package/assets/cover_deco.png +0 -0
- package/assets/logo.png +0 -0
- package/assets/slide2_right.png +0 -0
- package/assets/theme_apricot.jpg +0 -0
- package/assets/theme_blush.jpg +0 -0
- package/assets/theme_coral.jpg +0 -0
- package/assets/theme_glow.jpg +0 -0
- package/assets/theme_mauve.jpg +0 -0
- package/assets/theme_peach.jpg +0 -0
- package/assets/theme_rose.jpg +0 -0
- package/assets/theme_sunset.jpg +0 -0
- package/assets/topright_deco.png +0 -0
- package/build_deck.py +2288 -0
- package/charts.py +329 -0
- package/check.py +236 -0
- package/config.json +5 -0
- package/contact_sheet.py +88 -0
- package/cordis.patch.yml +457 -0
- package/deck_baseline.py +335 -0
- package/delivery.py +143 -0
- package/density-recipe.md +110 -0
- package/example.json +26 -0
- package/formula.py +125 -0
- package/gen_image.py +395 -0
- package/generate_consensus.py +892 -0
- package/generate_ppt.py +1356 -0
- package/grasp_evidence.py +150 -0
- package/image_check.py +148 -0
- package/image_fetch.py +253 -0
- package/ingest.py +93 -0
- package/media.py +470 -0
- package/package.json +68 -4
- package/preset.yml +2 -0
- package/quality_check.py +196 -0
- package/render_ppt.py +91 -0
- package/requirements.txt +7 -0
- package/restyle.py +80 -0
- package/run_pptfast.sh +27 -0
- package/run_py.sh +43 -0
- package/slide_ops.py +160 -0
- package/slide_patch.py +153 -0
- package/speaker_notes.py +245 -0
- package/style-recipes.md +166 -0
- package/video_gif.py +164 -0
- package/vision_check.py +79 -0
package/media.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deck-ready image compositing primitives.
|
|
3
|
+
|
|
4
|
+
Everything is baked with Pillow/NumPy so a slide renders identically in
|
|
5
|
+
PowerPoint, LibreOffice and an exported PDF, with no font- or renderer-specific
|
|
6
|
+
gradient behaviour. The functions here are deliberately small and pure: they
|
|
7
|
+
take an image and return an image, so ``build_deck.py`` stays about layout.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Iterable, Sequence
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from PIL import Image, ImageDraw, ImageFilter, ImageOps
|
|
17
|
+
|
|
18
|
+
RGB = tuple[int, int, int]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_color(value) -> RGB:
|
|
22
|
+
"""Accept ``#RRGGBB``, ``RRGGBB`` or an ``(r, g, b)`` triple."""
|
|
23
|
+
if isinstance(value, (tuple, list)) and len(value) == 3:
|
|
24
|
+
return tuple(int(max(0, min(255, c))) for c in value) # type: ignore[return-value]
|
|
25
|
+
text = str(value).strip().lstrip("#")
|
|
26
|
+
if len(text) != 6:
|
|
27
|
+
raise ValueError(f"unsupported color: {value!r}")
|
|
28
|
+
return int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def to_hex(color: Iterable[int]) -> str:
|
|
32
|
+
return "".join(f"{max(0, min(255, int(c))):02X}" for c in color)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cover(image: Image.Image, size: tuple[int, int],
|
|
36
|
+
centering: tuple[float, float] = (0.5, 0.5)) -> Image.Image:
|
|
37
|
+
"""Crop-to-fill ``size`` without distortion (the only legal image fit)."""
|
|
38
|
+
width, height = size
|
|
39
|
+
return ImageOps.fit(image.convert("RGB"), (width, height), method=Image.LANCZOS,
|
|
40
|
+
centering=centering)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sample_ink(image: Image.Image, percentile: float = 12.0,
|
|
44
|
+
mix: float = 0.0, mix_color: str = "0B1324") -> tuple[int, int, int]:
|
|
45
|
+
"""Return the dominant dark tone of ``image``.
|
|
46
|
+
|
|
47
|
+
Gradient scrims should continue the picture's own palette instead of a dead
|
|
48
|
+
black; sampling the darkest band is stable and cheap. ``mix`` blends that
|
|
49
|
+
tone toward a fixed ink color (used to keep a deck's theme consistent).
|
|
50
|
+
"""
|
|
51
|
+
small = image.convert("RGB").resize((160, max(1, round(160 * image.height / max(1, image.width))))
|
|
52
|
+
, Image.BOX)
|
|
53
|
+
data = np.asarray(small, dtype=np.float32)
|
|
54
|
+
luminance = data @ np.array([0.299, 0.587, 0.114], dtype=np.float32)
|
|
55
|
+
threshold = np.percentile(luminance, percentile)
|
|
56
|
+
darkest = data[luminance <= threshold]
|
|
57
|
+
tone = darkest.mean(axis=0) if len(darkest) else data.reshape(-1, 3).mean(axis=0)
|
|
58
|
+
if mix:
|
|
59
|
+
blend = np.array(parse_color(mix_color), dtype=np.float32)
|
|
60
|
+
tone = tone * (1 - mix) + blend * mix
|
|
61
|
+
return tuple(int(round(c)) for c in tone) # type: ignore[return-value]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _axis_alpha(size: tuple[int, int], direction: str, start: float, end: float,
|
|
65
|
+
gamma: float) -> np.ndarray:
|
|
66
|
+
width, height = size
|
|
67
|
+
if direction in {"left", "right"}:
|
|
68
|
+
axis = np.linspace(0.0, 1.0, width, dtype=np.float32)
|
|
69
|
+
if direction == "right":
|
|
70
|
+
axis = 1.0 - axis
|
|
71
|
+
grid = np.tile(axis, (height, 1))
|
|
72
|
+
elif direction in {"top", "bottom"}:
|
|
73
|
+
axis = np.linspace(0.0, 1.0, height, dtype=np.float32)
|
|
74
|
+
# alpha is 1 where the axis is 0, so "bottom" must measure from the
|
|
75
|
+
# bottom edge: that is what makes a photo fade *up* into the page.
|
|
76
|
+
if direction == "bottom":
|
|
77
|
+
axis = 1.0 - axis
|
|
78
|
+
grid = np.tile(axis[:, None], (1, width))
|
|
79
|
+
elif direction == "diagonal":
|
|
80
|
+
# Dark toward the lower-left corner: a safe zone for left-aligned text.
|
|
81
|
+
x = np.linspace(0.0, 1.0, width, dtype=np.float32)
|
|
82
|
+
y = np.linspace(0.0, 1.0, height, dtype=np.float32)
|
|
83
|
+
grid = np.maximum(np.tile(x, (height, 1)), np.tile(1.0 - y[:, None], (1, width)))
|
|
84
|
+
else:
|
|
85
|
+
raise ValueError(f"unsupported gradient direction: {direction}")
|
|
86
|
+
span = max(1e-6, end - start)
|
|
87
|
+
alpha = np.clip((end - grid) / span, 0.0, 1.0) ** gamma
|
|
88
|
+
return alpha.astype(np.float32)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def apply_scrim(image: Image.Image, color="0B1324", direction: str = "left",
|
|
92
|
+
start: float = 0.0, end: float = 0.78, gamma: float = 1.15,
|
|
93
|
+
opacity: float = 0.92) -> Image.Image:
|
|
94
|
+
"""Composite a linear-color scrim used to keep overlaid text legible."""
|
|
95
|
+
base = image.convert("RGBA")
|
|
96
|
+
alpha = _axis_alpha(base.size, direction, start, end, gamma) * float(np.clip(opacity, 0, 1))
|
|
97
|
+
layer = np.asarray(base, dtype=np.float32).copy()
|
|
98
|
+
tone = np.array(parse_color(color), dtype=np.float32)
|
|
99
|
+
layer[..., :3] = layer[..., :3] * (1 - alpha[..., None]) + tone * alpha[..., None]
|
|
100
|
+
return Image.fromarray(np.clip(layer, 0, 255).astype(np.uint8), "RGBA")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def veil(image: Image.Image, color="FFFFFF", opacity: float = 0.4) -> Image.Image:
|
|
104
|
+
"""A uniform translucent wash; keeps dark text legible on any picture."""
|
|
105
|
+
base = image.convert("RGBA")
|
|
106
|
+
layer = np.asarray(base, dtype=np.float32).copy()
|
|
107
|
+
tone = np.array(parse_color(color), dtype=np.float32)
|
|
108
|
+
alpha = float(np.clip(opacity, 0, 1))
|
|
109
|
+
layer[..., :3] = layer[..., :3] * (1 - alpha) + tone * alpha
|
|
110
|
+
return Image.fromarray(np.clip(layer, 0, 255).astype(np.uint8), "RGBA")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def blend_edge(image: Image.Image, color, edge: str = "right",
|
|
114
|
+
depth: float = 0.38, gamma: float = 1.6,
|
|
115
|
+
opacity: float = 1.0) -> Image.Image:
|
|
116
|
+
"""Melt one edge of a picture into a flat background color.
|
|
117
|
+
|
|
118
|
+
This is what makes a photo look designed into the page instead of pasted on:
|
|
119
|
+
the picture bleeds off one side and its far edge dissolves into the theme.
|
|
120
|
+
"""
|
|
121
|
+
return apply_scrim(image, color=color, direction=edge,
|
|
122
|
+
start=0.0, end=max(0.02, min(1.0, depth)),
|
|
123
|
+
gamma=gamma, opacity=opacity)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def vignette(image: Image.Image, strength: float = 0.32,
|
|
127
|
+
color="0B1220") -> Image.Image:
|
|
128
|
+
"""Subtle radial darkening; keeps a full-bleed photo from looking flat."""
|
|
129
|
+
base = image.convert("RGBA")
|
|
130
|
+
width, height = base.size
|
|
131
|
+
y, x = np.ogrid[:height, :width]
|
|
132
|
+
cx, cy = width / 2.0, height / 2.0
|
|
133
|
+
distance = np.sqrt(((x - cx) / cx) ** 2 + ((y - cy) / cy) ** 2) / math.sqrt(2)
|
|
134
|
+
alpha = np.clip((distance - 0.35) / 0.65, 0, 1) ** 1.5 * float(np.clip(strength, 0, 1))
|
|
135
|
+
layer = np.asarray(base, dtype=np.float32).copy()
|
|
136
|
+
tone = np.array(parse_color(color), dtype=np.float32)
|
|
137
|
+
layer[..., :3] = layer[..., :3] * (1 - alpha[..., None]) + tone * alpha[..., None]
|
|
138
|
+
return Image.fromarray(np.clip(layer, 0, 255).astype(np.uint8), "RGBA")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def rounded(image: Image.Image, radius_ratio: float = 0.06,
|
|
142
|
+
supersample: int = 3) -> Image.Image:
|
|
143
|
+
"""Round the corners with a supersampled mask (clean antialiasing)."""
|
|
144
|
+
base = image.convert("RGBA")
|
|
145
|
+
width, height = base.size
|
|
146
|
+
radius = max(0, int(round(min(width, height) * radius_ratio)))
|
|
147
|
+
if radius == 0:
|
|
148
|
+
return base
|
|
149
|
+
mask = Image.new("L", (width * supersample, height * supersample), 0)
|
|
150
|
+
ImageDraw.Draw(mask).rounded_rectangle(
|
|
151
|
+
[0, 0, width * supersample - 1, height * supersample - 1],
|
|
152
|
+
radius=radius * supersample, fill=255)
|
|
153
|
+
mask = mask.resize((width, height), Image.LANCZOS)
|
|
154
|
+
base.putalpha(mask)
|
|
155
|
+
return base
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def duotone(image: Image.Image, dark="0B1324", light="F7FBFF",
|
|
159
|
+
strength: float = 1.0) -> Image.Image:
|
|
160
|
+
"""Map luminance onto two brand colors; use for section art, not evidence."""
|
|
161
|
+
gray = ImageOps.grayscale(image).convert("L")
|
|
162
|
+
data = np.asarray(gray, dtype=np.float32) / 255.0
|
|
163
|
+
lo = np.array(parse_color(dark), dtype=np.float32)
|
|
164
|
+
hi = np.array(parse_color(light), dtype=np.float32)
|
|
165
|
+
mapped = lo[None, None, :] * (1 - data[..., None]) + hi[None, None, :] * data[..., None]
|
|
166
|
+
original = np.asarray(image.convert("RGB"), dtype=np.float32)
|
|
167
|
+
mixed = original * (1 - float(np.clip(strength, 0, 1))) + mapped * float(np.clip(strength, 0, 1))
|
|
168
|
+
return Image.fromarray(np.clip(mixed, 0, 255).astype(np.uint8), "RGB")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def blur(image: Image.Image, radius: float = 12.0) -> Image.Image:
|
|
172
|
+
return image.convert("RGB").filter(ImageFilter.GaussianBlur(radius))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def strip_color_cast(image: Image.Image, keep: float = 0.35) -> Image.Image:
|
|
176
|
+
"""Reduce saturation while keeping a hint of the original color."""
|
|
177
|
+
base = image.convert("RGB")
|
|
178
|
+
gray = ImageOps.grayscale(base).convert("RGB")
|
|
179
|
+
return Image.blend(gray, base, float(np.clip(keep, 0, 1)))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ── editorial backgrounds and type art ────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
def display_font(size: int = 400, bold: bool = True):
|
|
185
|
+
"""Load a heavy CJK display face for raster type; None when unavailable."""
|
|
186
|
+
candidates = [
|
|
187
|
+
"/usr/share/fonts/opentype/noto/NotoSansCJK-Black.ttc",
|
|
188
|
+
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
|
|
189
|
+
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
|
190
|
+
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
|
|
191
|
+
]
|
|
192
|
+
from PIL import ImageFont
|
|
193
|
+
for path in candidates:
|
|
194
|
+
if Path(path).is_file():
|
|
195
|
+
try:
|
|
196
|
+
return ImageFont.truetype(path, size)
|
|
197
|
+
except OSError:
|
|
198
|
+
continue
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def mesh_gradient(size: tuple[int, int], colors: Sequence[str],
|
|
203
|
+
seed: int = 7, blur: float = 150.0) -> Image.Image:
|
|
204
|
+
"""A soft multi-color field: the premium alternative to a flat background."""
|
|
205
|
+
import random
|
|
206
|
+
width, height = size
|
|
207
|
+
rng = random.Random(seed)
|
|
208
|
+
base = Image.new("RGB", size, parse_color(colors[0]))
|
|
209
|
+
layer = Image.new("RGB", size, parse_color(colors[0]))
|
|
210
|
+
draw = ImageDraw.Draw(layer)
|
|
211
|
+
palette = list(colors)
|
|
212
|
+
for index in range(7):
|
|
213
|
+
color = parse_color(palette[index % len(palette)])
|
|
214
|
+
blob_w = rng.randint(int(width * 0.45), int(width * 1.1))
|
|
215
|
+
blob_h = rng.randint(int(height * 0.45), int(height * 1.1))
|
|
216
|
+
left = rng.randint(-int(width * 0.25), width - int(blob_w * 0.3))
|
|
217
|
+
top = rng.randint(-int(height * 0.25), height - int(blob_h * 0.3))
|
|
218
|
+
draw.ellipse([left, top, left + blob_w, top + blob_h], fill=color)
|
|
219
|
+
layer = layer.filter(ImageFilter.GaussianBlur(blur))
|
|
220
|
+
return Image.blend(base, layer, 0.88)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def grain(image: Image.Image, amount: float = 0.045, seed: int = 3) -> Image.Image:
|
|
224
|
+
"""Fine film grain; keeps large color fields from looking flat."""
|
|
225
|
+
rng = np.random.default_rng(seed)
|
|
226
|
+
base = np.asarray(image.convert("RGB"), dtype=np.float32)
|
|
227
|
+
noise = rng.normal(0.0, 255.0 * float(np.clip(amount, 0, 0.2)), base.shape[:2])
|
|
228
|
+
base = np.clip(base + noise[..., None], 0, 255)
|
|
229
|
+
return Image.fromarray(base.astype(np.uint8), "RGB")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def dot_grid(image: Image.Image, spacing: int = 28, color="FFFFFF",
|
|
233
|
+
opacity: float = 0.10) -> Image.Image:
|
|
234
|
+
"""A quiet dot matrix behind content pages."""
|
|
235
|
+
base = image.convert("RGBA")
|
|
236
|
+
layer = Image.new("RGBA", base.size, (0, 0, 0, 0))
|
|
237
|
+
draw = ImageDraw.Draw(layer)
|
|
238
|
+
tone = parse_color(color) + (int(255 * np.clip(opacity, 0, 1)),)
|
|
239
|
+
for y in range(spacing // 2, base.height, spacing):
|
|
240
|
+
for x in range(spacing // 2, base.width, spacing):
|
|
241
|
+
draw.ellipse([x, y, x + 2, y + 2], fill=tone)
|
|
242
|
+
return Image.alpha_composite(base, layer)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def rule_grid(image: Image.Image, spacing: int = 96, color="FFFFFF",
|
|
246
|
+
opacity: float = 0.06) -> Image.Image:
|
|
247
|
+
base = image.convert("RGBA")
|
|
248
|
+
layer = Image.new("RGBA", base.size, (0, 0, 0, 0))
|
|
249
|
+
draw = ImageDraw.Draw(layer)
|
|
250
|
+
tone = parse_color(color) + (int(255 * np.clip(opacity, 0, 1)),)
|
|
251
|
+
for x in range(0, base.width, spacing):
|
|
252
|
+
draw.line([(x, 0), (x, base.height)], fill=tone, width=1)
|
|
253
|
+
for y in range(0, base.height, spacing):
|
|
254
|
+
draw.line([(0, y), (base.width, y)], fill=tone, width=1)
|
|
255
|
+
return Image.alpha_composite(base, layer)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def frosted(background: Image.Image, box: tuple[int, int, int, int],
|
|
259
|
+
radius: float = 0.08, tint="FFFFFF", opacity: float = 0.20,
|
|
260
|
+
blur: float = 22.0, border: float = 0.35) -> Image.Image:
|
|
261
|
+
"""A frosted-glass panel cut from the page's own background."""
|
|
262
|
+
left, top, right, bottom = [int(value) for value in box]
|
|
263
|
+
region = background.convert("RGB").crop((left, top, right, bottom))
|
|
264
|
+
region = region.filter(ImageFilter.GaussianBlur(blur))
|
|
265
|
+
# Desaturating before the wash is what keeps frosted glass from turning
|
|
266
|
+
# muddy when it sits over a warm highlight.
|
|
267
|
+
gray = ImageOps.grayscale(region).convert("RGB")
|
|
268
|
+
region = Image.blend(region, gray, 0.32)
|
|
269
|
+
wash = Image.new("RGB", region.size, parse_color(tint))
|
|
270
|
+
region = Image.blend(region, wash, float(np.clip(opacity, 0, 1)))
|
|
271
|
+
region = grain(region, 0.02)
|
|
272
|
+
region = rounded(region, radius)
|
|
273
|
+
if border > 0:
|
|
274
|
+
draw = ImageDraw.Draw(region)
|
|
275
|
+
width = max(1, int(border * 4))
|
|
276
|
+
draw.rounded_rectangle([width, width, region.width - width, region.height - width],
|
|
277
|
+
radius=int(min(region.width, region.height) * radius),
|
|
278
|
+
outline=parse_color(tint) + (90,), width=width)
|
|
279
|
+
return region
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def diagonal_split(left: Image.Image, right: Image.Image, size: tuple[int, int],
|
|
283
|
+
split: float = 0.60, lean: float = 0.16,
|
|
284
|
+
seam: str | None = None, seam_width: int = 5) -> Image.Image:
|
|
285
|
+
"""Two photographs joined by a diagonal cut instead of a straight gutter."""
|
|
286
|
+
width, height = size
|
|
287
|
+
left_image = cover(left, size)
|
|
288
|
+
canvas = cover(right, size).convert("RGB")
|
|
289
|
+
mask = Image.new("L", size, 0)
|
|
290
|
+
top_x = int(width * split)
|
|
291
|
+
bottom_x = int(width * (split - lean))
|
|
292
|
+
ImageDraw.Draw(mask).polygon(
|
|
293
|
+
[(0, 0), (top_x, 0), (bottom_x, height), (0, height)], fill=255)
|
|
294
|
+
canvas.paste(left_image, (0, 0), mask)
|
|
295
|
+
if seam:
|
|
296
|
+
draw = ImageDraw.Draw(canvas)
|
|
297
|
+
draw.line([(top_x, 0), (bottom_x, height)], fill=parse_color(seam),
|
|
298
|
+
width=max(1, seam_width))
|
|
299
|
+
return canvas
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def text_art(text: str, font_size: int, *, fill: str | Sequence[str] | None = None,
|
|
303
|
+
image: Image.Image | None = None, stroke: int = 0,
|
|
304
|
+
stroke_color="FFFFFF", shadow: float = 0.0,
|
|
305
|
+
padding: float = 0.12, outline: int = 0,
|
|
306
|
+
outline_color="FFFFFF") -> Image.Image:
|
|
307
|
+
"""Rasterise display type filled with a color, gradient or photograph.
|
|
308
|
+
|
|
309
|
+
Used for decorative numerals and word-art only; semantic copy stays as real
|
|
310
|
+
text boxes. With ``stroke`` > 0 the glyph becomes a hollow outline in
|
|
311
|
+
``stroke_color`` (``fill``/``image`` are ignored).
|
|
312
|
+
"""
|
|
313
|
+
font = display_font(font_size)
|
|
314
|
+
if font is None:
|
|
315
|
+
raise ValueError("no display font available for text art")
|
|
316
|
+
bbox = font.getbbox(text)
|
|
317
|
+
text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
318
|
+
pad = max(8, int(font_size * padding))
|
|
319
|
+
size = (text_w + pad * 2, text_h + pad * 2)
|
|
320
|
+
pos = (pad - bbox[0], pad - bbox[1])
|
|
321
|
+
|
|
322
|
+
mask = Image.new("L", size, 0)
|
|
323
|
+
draw = ImageDraw.Draw(mask)
|
|
324
|
+
if stroke > 0:
|
|
325
|
+
draw.text(pos, text, font=font, fill=255,
|
|
326
|
+
stroke_width=stroke, stroke_fill=255)
|
|
327
|
+
draw.text(pos, text, font=font, fill=0)
|
|
328
|
+
art = Image.new("RGBA", size, parse_color(stroke_color) + (255,))
|
|
329
|
+
else:
|
|
330
|
+
draw.text(pos, text, font=font, fill=255)
|
|
331
|
+
if image is not None:
|
|
332
|
+
canvas = Image.new("RGB", size, parse_color("0B1324"))
|
|
333
|
+
canvas.paste(cover(image, size), (0, 0))
|
|
334
|
+
art = canvas.convert("RGBA")
|
|
335
|
+
elif isinstance(fill, (list, tuple)) and len(fill) > 1:
|
|
336
|
+
gradient = np.zeros((size[1], size[0], 3), dtype=np.float32)
|
|
337
|
+
stops = [parse_color(color) for color in fill]
|
|
338
|
+
for x in range(size[0]):
|
|
339
|
+
at = x / max(1, size[0] - 1)
|
|
340
|
+
segment = at * (len(stops) - 1)
|
|
341
|
+
index = min(int(segment), len(stops) - 2)
|
|
342
|
+
local = segment - index
|
|
343
|
+
gradient[:, x] = (np.array(stops[index]) * (1 - local)
|
|
344
|
+
+ np.array(stops[index + 1]) * local)
|
|
345
|
+
art = Image.fromarray(gradient.astype(np.uint8), "RGB").convert("RGBA")
|
|
346
|
+
else:
|
|
347
|
+
color = parse_color(fill if isinstance(fill, str) else "FFFFFF")
|
|
348
|
+
art = Image.new("RGBA", size, color + (255,))
|
|
349
|
+
art.putalpha(mask)
|
|
350
|
+
|
|
351
|
+
if outline > 0 and image is not None:
|
|
352
|
+
# A thin rim keeps a photo-filled glyph readable against a busy page.
|
|
353
|
+
grown = mask.filter(ImageFilter.MaxFilter(2 * outline + 1))
|
|
354
|
+
ring = Image.fromarray(np.clip(np.asarray(grown, dtype=np.int16)
|
|
355
|
+
- np.asarray(mask, dtype=np.int16), 0, 255).astype(np.uint8))
|
|
356
|
+
rim = Image.new("RGBA", art.size, parse_color(outline_color) + (255,))
|
|
357
|
+
rim.putalpha(ring)
|
|
358
|
+
rim.alpha_composite(art, (0, 0))
|
|
359
|
+
art = rim
|
|
360
|
+
|
|
361
|
+
if shadow > 0:
|
|
362
|
+
offset = int(font_size * 0.035)
|
|
363
|
+
canvas = Image.new("RGBA", (size[0] + offset * 2, size[1] + offset * 2), (0, 0, 0, 0))
|
|
364
|
+
shade_mask = Image.new("L", canvas.size, 0)
|
|
365
|
+
shade_mask.paste(mask, (offset, offset))
|
|
366
|
+
shade_mask = shade_mask.filter(ImageFilter.GaussianBlur(max(1, int(font_size * 0.02))))
|
|
367
|
+
shade = Image.new("RGBA", canvas.size,
|
|
368
|
+
(0, 0, 0, int(255 * float(np.clip(shadow, 0, 1)))))
|
|
369
|
+
shade.putalpha(shade_mask)
|
|
370
|
+
shade.alpha_composite(art, (offset, offset))
|
|
371
|
+
return shade
|
|
372
|
+
return art
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def halftone(image: Image.Image, spacing: int = 7, angle: float = 0.0) -> Image.Image:
|
|
376
|
+
"""Print-style dot screen; use only for section art, never for evidence."""
|
|
377
|
+
import math
|
|
378
|
+
gray = ImageOps.grayscale(image).convert("RGB")
|
|
379
|
+
gray = gray.rotate(angle, expand=False)
|
|
380
|
+
width, height = gray.size
|
|
381
|
+
out = Image.new("RGB", (width, height), (255, 255, 255))
|
|
382
|
+
draw = ImageDraw.Draw(out)
|
|
383
|
+
pixels = gray.load()
|
|
384
|
+
for y in range(0, height, spacing):
|
|
385
|
+
for x in range(0, width, spacing):
|
|
386
|
+
value = pixels[min(x, width - 1), min(y, height - 1)][0]
|
|
387
|
+
radius = max(0.4, (1 - value / 255) * spacing * 0.55)
|
|
388
|
+
draw.ellipse([x - radius, y - radius, x + radius, y + radius], fill=(0, 0, 0))
|
|
389
|
+
return out.rotate(-angle, expand=False)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def defringe(image: Image.Image, pixels: int = 1) -> Image.Image:
|
|
393
|
+
"""Erode the alpha edge by a pixel or two to remove the white matte halo."""
|
|
394
|
+
base = image.convert("RGBA")
|
|
395
|
+
if pixels <= 0:
|
|
396
|
+
return base
|
|
397
|
+
alpha = base.getchannel("A").filter(ImageFilter.MinFilter(2 * pixels + 1))
|
|
398
|
+
base.putalpha(alpha)
|
|
399
|
+
return base
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def drop_shadow(image: Image.Image, blur: float = 16.0, offset: tuple[int, int] = (0, 10),
|
|
403
|
+
opacity: float = 0.45) -> Image.Image:
|
|
404
|
+
"""Bake a soft alpha shadow behind a cutout (renderer-independent)."""
|
|
405
|
+
base = image.convert("RGBA")
|
|
406
|
+
pad = int(blur * 1.8) + max(abs(offset[0]), abs(offset[1]))
|
|
407
|
+
canvas = Image.new("RGBA", (base.width + pad * 2, base.height + pad * 2), (0, 0, 0, 0))
|
|
408
|
+
alpha = Image.new("L", canvas.size, 0)
|
|
409
|
+
alpha.paste(base.getchannel("A"), (pad + offset[0], pad + offset[1]))
|
|
410
|
+
alpha = alpha.filter(ImageFilter.GaussianBlur(blur))
|
|
411
|
+
shade = Image.new("RGBA", canvas.size, (0, 0, 0, int(255 * float(np.clip(opacity, 0, 1)))))
|
|
412
|
+
shade.putalpha(alpha)
|
|
413
|
+
shade.alpha_composite(base, (pad, pad))
|
|
414
|
+
return shade
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def arch_mask(image: Image.Image, radius_ratio: float = 0.5,
|
|
418
|
+
supersample: int = 3) -> Image.Image:
|
|
419
|
+
"""Crop into an arch/window: semicircular top, straight sides and base."""
|
|
420
|
+
base = image.convert("RGBA")
|
|
421
|
+
width, height = base.size
|
|
422
|
+
radius = int(min(width * radius_ratio, height * 0.9))
|
|
423
|
+
mask = Image.new("L", (width * supersample, height * supersample), 0)
|
|
424
|
+
draw = ImageDraw.Draw(mask)
|
|
425
|
+
w, h, r = width * supersample, height * supersample, radius * supersample
|
|
426
|
+
draw.pieslice([0, 0, w, 2 * r], 180, 360, fill=255)
|
|
427
|
+
draw.rectangle([0, r, w, h], fill=255)
|
|
428
|
+
mask = mask.resize((width, height), Image.LANCZOS)
|
|
429
|
+
base.putalpha(mask)
|
|
430
|
+
return base
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def circle_mask(image: Image.Image, supersample: int = 3) -> Image.Image:
|
|
434
|
+
base = image.convert("RGBA")
|
|
435
|
+
side = min(base.size)
|
|
436
|
+
square = ImageOps.fit(base, (side, side), method=Image.LANCZOS)
|
|
437
|
+
mask = Image.new("L", (side * supersample, side * supersample), 0)
|
|
438
|
+
ImageDraw.Draw(mask).ellipse([0, 0, side * supersample - 1, side * supersample - 1], fill=255)
|
|
439
|
+
mask = mask.resize((side, side), Image.LANCZOS)
|
|
440
|
+
square.putalpha(mask)
|
|
441
|
+
return square
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def glitch(image: Image.Image, shift: int = 4, scanlines: float = 0.10) -> Image.Image:
|
|
445
|
+
"""Chromatic split + scanlines; use as a deliberate accent, not for photos of record."""
|
|
446
|
+
base = image.convert("RGB")
|
|
447
|
+
width, height = base.size
|
|
448
|
+
red, green, blue = base.split()
|
|
449
|
+
|
|
450
|
+
def offset(channel, dx: int, edge: int):
|
|
451
|
+
pad = abs(dx)
|
|
452
|
+
if not pad:
|
|
453
|
+
return channel
|
|
454
|
+
padded = ImageOps.expand(channel, border=pad, fill=edge)
|
|
455
|
+
start = 0 if dx > 0 else pad
|
|
456
|
+
return padded.crop((start, pad, start + width, pad + height))
|
|
457
|
+
|
|
458
|
+
base = Image.merge("RGB", (
|
|
459
|
+
offset(red, shift, red.getpixel((0, height // 2))),
|
|
460
|
+
green,
|
|
461
|
+
offset(blue, -shift, blue.getpixel((width - 1, height // 2))),
|
|
462
|
+
))
|
|
463
|
+
if scanlines > 0:
|
|
464
|
+
overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
|
|
465
|
+
draw = ImageDraw.Draw(overlay)
|
|
466
|
+
tone = (0, 0, 0, int(255 * float(np.clip(scanlines, 0, 1))))
|
|
467
|
+
for y in range(0, base.height, 4):
|
|
468
|
+
draw.line([(0, y), (base.width, y)], fill=tone)
|
|
469
|
+
base = Image.alpha_composite(base.convert("RGBA"), overlay).convert("RGB")
|
|
470
|
+
return base
|
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yujp_vc/ppt-assistant",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.5.2",
|
|
4
|
+
"description": "PPT 助手 agent preset for DeepSeek Harness: a visual-director workflow with six style recipes, plan-first editable decks, GPT imagery/vision QC, showcase layouts, and a verified scoped-edit ledger.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"files": [
|
|
9
|
+
"contact_sheet.py",
|
|
10
|
+
"style-recipes.md",
|
|
11
|
+
"image_check.py",
|
|
12
|
+
"run_py.sh",
|
|
13
|
+
"deck_baseline.py",
|
|
14
|
+
"run_py.sh",
|
|
15
|
+
"gen_image.py",
|
|
16
|
+
"media.py",
|
|
17
|
+
"cordis.patch.yml",
|
|
18
|
+
"README.md",
|
|
19
|
+
"preset.yml",
|
|
20
|
+
"build_deck.py",
|
|
21
|
+
"charts.py",
|
|
22
|
+
"grasp_evidence.py",
|
|
23
|
+
"generate_ppt.py",
|
|
24
|
+
"generate_consensus.py",
|
|
25
|
+
"quality_check.py",
|
|
26
|
+
"check.py",
|
|
27
|
+
"ingest.py",
|
|
28
|
+
"render_ppt.py",
|
|
29
|
+
"image_fetch.py",
|
|
30
|
+
"vision_check.py",
|
|
31
|
+
"restyle.py",
|
|
32
|
+
"run_pptfast.sh",
|
|
33
|
+
"density-recipe.md",
|
|
34
|
+
"speaker_notes.py",
|
|
35
|
+
"slide_ops.py",
|
|
36
|
+
"slide_patch.py",
|
|
37
|
+
"asset_catalog.py",
|
|
38
|
+
"delivery.py",
|
|
39
|
+
"video_gif.py",
|
|
40
|
+
"formula.py",
|
|
41
|
+
"requirements.txt",
|
|
42
|
+
"LICENSE",
|
|
43
|
+
"config.json",
|
|
44
|
+
"assets",
|
|
45
|
+
"example.json"
|
|
46
|
+
],
|
|
47
|
+
"dsh": {
|
|
48
|
+
"bundle": {
|
|
49
|
+
"patch": "./cordis.patch.yml"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@deepseek-ai/dsh-agent-preset": ">=0.2.1-rc.3 <0.3.0"
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public",
|
|
57
|
+
"registry": "https://registry.npmjs.org/"
|
|
58
|
+
},
|
|
59
|
+
"keywords": [
|
|
60
|
+
"dsh-plugin",
|
|
61
|
+
"deepseek-harness",
|
|
62
|
+
"powerpoint",
|
|
63
|
+
"pptx"
|
|
64
|
+
],
|
|
65
|
+
"repository": {
|
|
66
|
+
"type": "git",
|
|
67
|
+
"url": "git+https://github.com/YujpVc/ppt-assistant.git"
|
|
68
|
+
},
|
|
69
|
+
"homepage": "https://github.com/YujpVc/ppt-assistant#readme"
|
|
70
|
+
}
|
package/preset.yml
ADDED