@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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -2
  3. package/asset_catalog.py +159 -0
  4. package/assets/base_template.pptx +0 -0
  5. package/assets/cover_deco.png +0 -0
  6. package/assets/logo.png +0 -0
  7. package/assets/slide2_right.png +0 -0
  8. package/assets/theme_apricot.jpg +0 -0
  9. package/assets/theme_blush.jpg +0 -0
  10. package/assets/theme_coral.jpg +0 -0
  11. package/assets/theme_glow.jpg +0 -0
  12. package/assets/theme_mauve.jpg +0 -0
  13. package/assets/theme_peach.jpg +0 -0
  14. package/assets/theme_rose.jpg +0 -0
  15. package/assets/theme_sunset.jpg +0 -0
  16. package/assets/topright_deco.png +0 -0
  17. package/build_deck.py +2288 -0
  18. package/charts.py +329 -0
  19. package/check.py +236 -0
  20. package/config.json +5 -0
  21. package/contact_sheet.py +88 -0
  22. package/cordis.patch.yml +457 -0
  23. package/deck_baseline.py +335 -0
  24. package/delivery.py +143 -0
  25. package/density-recipe.md +110 -0
  26. package/example.json +26 -0
  27. package/formula.py +125 -0
  28. package/gen_image.py +395 -0
  29. package/generate_consensus.py +892 -0
  30. package/generate_ppt.py +1356 -0
  31. package/grasp_evidence.py +150 -0
  32. package/image_check.py +148 -0
  33. package/image_fetch.py +253 -0
  34. package/ingest.py +93 -0
  35. package/media.py +470 -0
  36. package/package.json +68 -4
  37. package/preset.yml +2 -0
  38. package/quality_check.py +196 -0
  39. package/render_ppt.py +91 -0
  40. package/requirements.txt +7 -0
  41. package/restyle.py +80 -0
  42. package/run_pptfast.sh +27 -0
  43. package/run_py.sh +43 -0
  44. package/slide_ops.py +160 -0
  45. package/slide_patch.py +153 -0
  46. package/speaker_notes.py +245 -0
  47. package/style-recipes.md +166 -0
  48. package/video_gif.py +164 -0
  49. 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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
@@ -0,0 +1,2 @@
1
+ name: PPT 助手
2
+ description: 一条龙 PPT 生成 Agent:视觉总监式风格简报与六套配方、金字塔页计划、自主 GPT 生图(含透明抠图与视觉验收)、满幅/渐变/毛玻璃/斜切/抠图压版等高级排版、品牌色与双字体系统、可核验的改稿台账、读取 PPT/PDF(含 OCR)、更改已有 PPT,并渲染自查(缩略图总览 + 视觉模型逐页审查)迭代到满意后输出可编辑 .pptx。