slidev-theme-practicum 0.2.0 → 0.3.0
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/README.md +147 -18
- package/components/Slide.vue +11 -57
- package/components/Slot.vue +2 -1
- package/components/StepsGrid.vue +112 -0
- package/composables/deck-decors.ts +74 -0
- package/composables/decor-sources.ts +43 -0
- package/composables/layout-authoring.ts +34 -9
- package/composables/layout-recipes.ts +16 -2
- package/composables/layout-shorthands.ts +6 -17
- package/composables/local-layout-variant-files.ts +106 -0
- package/composables/local-layout-variants.ts +73 -0
- package/composables/slide-layout.ts +3 -0
- package/composables/use-theme-config.ts +17 -10
- package/composables/validate-deck-layouts.cjs +77 -6
- package/env.d.ts +18 -0
- package/package.json +6 -3
- package/scripts/browser-smoke.mjs +65 -0
- package/scripts/check-local-layout-variant-build.mjs +189 -0
- package/scripts/check-package.mjs +18 -2
- package/scripts/requirements-illustrations.txt +1 -0
- package/scripts/trace-line-art.py +422 -0
- package/scripts/validate-deck.cjs +1 -1
- package/setup/vite-plugins.ts +109 -2
- package/skills/slidev-practicum/SKILL.md +18 -4
- package/skills/slidev-practicum/references/contour-illustrations.md +114 -0
- package/skills/slidev-practicum/references/deck-project-structure.md +136 -0
- package/skills/slidev-practicum/references/illustration-examples/balance-scales.png +0 -0
- package/skills/slidev-practicum/references/illustration-examples/balance-scales.svg +88 -0
- package/skills/slidev-practicum/references/illustration-examples/chainsaw.png +0 -0
- package/skills/slidev-practicum/references/illustration-examples/chainsaw.svg +4 -0
- package/skills/slidev-practicum/references/illustration-examples/graduation-cap.png +0 -0
- package/skills/slidev-practicum/references/illustration-examples/graduation-cap.svg +23 -0
- package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.png +0 -0
- package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.svg +4 -0
- package/skills/slidev-practicum/references/photographic-illustrations.md +100 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Очищает контурный PNG и трассирует его в SVG с currentColor."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import math
|
|
8
|
+
import sys
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from html import escape
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from PIL import Image
|
|
15
|
+
except ImportError as error:
|
|
16
|
+
raise SystemExit(
|
|
17
|
+
"Для трассировки нужен Pillow: установите пакет в используемое Python-окружение"
|
|
18
|
+
) from error
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
Point = tuple[int, int]
|
|
22
|
+
Edge = tuple[Point, Point]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_arguments() -> argparse.Namespace:
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
add_help=False,
|
|
28
|
+
description=(
|
|
29
|
+
"Выделяет чёрный контур по альфа-каналу или яркости, сохраняет "
|
|
30
|
+
"очищенный PNG и строит SVG из замкнутых путей."
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
parser._positionals.title = "позиционные аргументы"
|
|
34
|
+
parser._optionals.title = "параметры"
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"-h",
|
|
37
|
+
"--help",
|
|
38
|
+
action="help",
|
|
39
|
+
help="показать эту справку и выйти",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument("source", type=Path, help="Исходный PNG")
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"output_prefix",
|
|
44
|
+
type=Path,
|
|
45
|
+
help="Путь результата без расширения, например public/illustrations/axe",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument("--title", required=True, help="Русское название для <title>")
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--threshold",
|
|
50
|
+
type=int,
|
|
51
|
+
default=128,
|
|
52
|
+
help="Порог яркости для непрозрачного исходника, 0–255; по умолчанию 128",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--alpha-threshold",
|
|
56
|
+
type=int,
|
|
57
|
+
default=128,
|
|
58
|
+
help="Порог альфа-канала для прозрачного исходника, 0–255; по умолчанию 128",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--small-span",
|
|
62
|
+
type=int,
|
|
63
|
+
default=260,
|
|
64
|
+
help="Максимальный размах малого контура; по умолчанию 260",
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--small-tolerance",
|
|
68
|
+
type=float,
|
|
69
|
+
default=1.05,
|
|
70
|
+
help="Допуск упрощения малого контура; по умолчанию 1.05",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--large-tolerance",
|
|
74
|
+
type=float,
|
|
75
|
+
default=2.4,
|
|
76
|
+
help="Допуск упрощения крупного контура; по умолчанию 2.4",
|
|
77
|
+
)
|
|
78
|
+
return parser.parse_args()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def create_mask(
|
|
82
|
+
image: Image.Image,
|
|
83
|
+
luminance_threshold: int,
|
|
84
|
+
alpha_threshold: int,
|
|
85
|
+
) -> tuple[list[list[bool]], bool]:
|
|
86
|
+
rgba = image.convert("RGBA")
|
|
87
|
+
width, height = rgba.size
|
|
88
|
+
alpha_minimum, _ = rgba.getchannel("A").getextrema()
|
|
89
|
+
use_alpha = alpha_minimum < 255
|
|
90
|
+
pixels = rgba.load()
|
|
91
|
+
mask_flat: list[bool] = []
|
|
92
|
+
|
|
93
|
+
for y in range(height):
|
|
94
|
+
for x in range(width):
|
|
95
|
+
red, green, blue, alpha = pixels[x, y]
|
|
96
|
+
if use_alpha:
|
|
97
|
+
mask_flat.append(alpha >= alpha_threshold)
|
|
98
|
+
else:
|
|
99
|
+
luminance = round(0.2126 * red + 0.7152 * green + 0.0722 * blue)
|
|
100
|
+
mask_flat.append(luminance < luminance_threshold)
|
|
101
|
+
|
|
102
|
+
mask = [
|
|
103
|
+
mask_flat[row_start : row_start + width]
|
|
104
|
+
for row_start in range(0, width * height, width)
|
|
105
|
+
]
|
|
106
|
+
return mask, use_alpha
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def save_clean_png(mask: list[list[bool]], output_path: Path) -> None:
|
|
110
|
+
height = len(mask)
|
|
111
|
+
width = len(mask[0])
|
|
112
|
+
result = Image.new("RGBA", (width, height))
|
|
113
|
+
result.putdata(
|
|
114
|
+
[
|
|
115
|
+
(0, 0, 0, 255 if is_ink else 0)
|
|
116
|
+
for row in mask
|
|
117
|
+
for is_ink in row
|
|
118
|
+
]
|
|
119
|
+
)
|
|
120
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
result.save(output_path)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def perpendicular_distance(point: Point, start: Point, end: Point) -> float:
|
|
125
|
+
if start == end:
|
|
126
|
+
return math.dist(point, start)
|
|
127
|
+
x, y = point
|
|
128
|
+
x1, y1 = start
|
|
129
|
+
x2, y2 = end
|
|
130
|
+
numerator = abs((y2 - y1) * x - (x2 - x1) * y + x2 * y1 - y2 * x1)
|
|
131
|
+
return numerator / math.hypot(y2 - y1, x2 - x1)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def simplify_open(points: list[Point], tolerance: float) -> list[Point]:
|
|
135
|
+
if len(points) <= 2:
|
|
136
|
+
return points
|
|
137
|
+
|
|
138
|
+
start = points[0]
|
|
139
|
+
end = points[-1]
|
|
140
|
+
maximum_distance = 0.0
|
|
141
|
+
split_index = 0
|
|
142
|
+
|
|
143
|
+
for index in range(1, len(points) - 1):
|
|
144
|
+
distance = perpendicular_distance(points[index], start, end)
|
|
145
|
+
if distance > maximum_distance:
|
|
146
|
+
maximum_distance = distance
|
|
147
|
+
split_index = index
|
|
148
|
+
|
|
149
|
+
if maximum_distance <= tolerance:
|
|
150
|
+
return [start, end]
|
|
151
|
+
|
|
152
|
+
left = simplify_open(points[: split_index + 1], tolerance)
|
|
153
|
+
right = simplify_open(points[split_index:], tolerance)
|
|
154
|
+
return left[:-1] + right
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def simplify_closed(points: list[Point], tolerance: float) -> list[Point]:
|
|
158
|
+
if len(points) < 5:
|
|
159
|
+
return points
|
|
160
|
+
|
|
161
|
+
first = min(range(len(points)), key=lambda index: (points[index][1], points[index][0]))
|
|
162
|
+
anchor = points[first]
|
|
163
|
+
second = max(range(len(points)), key=lambda index: math.dist(points[index], anchor))
|
|
164
|
+
|
|
165
|
+
if first > second:
|
|
166
|
+
first, second = second, first
|
|
167
|
+
|
|
168
|
+
first_arc = points[first : second + 1]
|
|
169
|
+
second_arc = points[second:] + points[: first + 1]
|
|
170
|
+
simplified = (
|
|
171
|
+
simplify_open(first_arc, tolerance)[:-1]
|
|
172
|
+
+ simplify_open(second_arc, tolerance)[:-1]
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
deduplicated: list[Point] = []
|
|
176
|
+
for point in simplified:
|
|
177
|
+
if not deduplicated or point != deduplicated[-1]:
|
|
178
|
+
deduplicated.append(point)
|
|
179
|
+
return deduplicated
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def build_edges(mask: list[list[bool]]) -> set[Edge]:
|
|
183
|
+
height = len(mask)
|
|
184
|
+
width = len(mask[0])
|
|
185
|
+
edges: set[Edge] = set()
|
|
186
|
+
|
|
187
|
+
for y, row in enumerate(mask):
|
|
188
|
+
for x, is_ink in enumerate(row):
|
|
189
|
+
if not is_ink:
|
|
190
|
+
continue
|
|
191
|
+
if y == 0 or not mask[y - 1][x]:
|
|
192
|
+
edges.add(((x, y), (x + 1, y)))
|
|
193
|
+
if x == width - 1 or not mask[y][x + 1]:
|
|
194
|
+
edges.add(((x + 1, y), (x + 1, y + 1)))
|
|
195
|
+
if y == height - 1 or not mask[y + 1][x]:
|
|
196
|
+
edges.add(((x + 1, y + 1), (x, y + 1)))
|
|
197
|
+
if x == 0 or not mask[y][x - 1]:
|
|
198
|
+
edges.add(((x, y + 1), (x, y)))
|
|
199
|
+
|
|
200
|
+
return edges
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def edge_direction(edge: Edge) -> int:
|
|
204
|
+
(x1, y1), (x2, y2) = edge
|
|
205
|
+
direction = (x2 - x1, y2 - y1)
|
|
206
|
+
return {(1, 0): 0, (0, 1): 1, (-1, 0): 2, (0, -1): 3}[direction]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def trace_contours(edges: set[Edge]) -> list[list[Point]]:
|
|
210
|
+
outgoing: dict[Point, set[Edge]] = defaultdict(set)
|
|
211
|
+
for edge in edges:
|
|
212
|
+
outgoing[edge[0]].add(edge)
|
|
213
|
+
|
|
214
|
+
contours: list[list[Point]] = []
|
|
215
|
+
turn_priority = {1: 0, 0: 1, 3: 2, 2: 3}
|
|
216
|
+
|
|
217
|
+
while edges:
|
|
218
|
+
first_edge = min(edges)
|
|
219
|
+
start = first_edge[0]
|
|
220
|
+
current = first_edge
|
|
221
|
+
contour = [start]
|
|
222
|
+
|
|
223
|
+
while current in edges:
|
|
224
|
+
edges.remove(current)
|
|
225
|
+
outgoing[current[0]].discard(current)
|
|
226
|
+
endpoint = current[1]
|
|
227
|
+
contour.append(endpoint)
|
|
228
|
+
|
|
229
|
+
if endpoint == start:
|
|
230
|
+
break
|
|
231
|
+
|
|
232
|
+
candidates = [candidate for candidate in outgoing[endpoint] if candidate in edges]
|
|
233
|
+
if not candidates:
|
|
234
|
+
break
|
|
235
|
+
|
|
236
|
+
incoming_direction = edge_direction(current)
|
|
237
|
+
current = min(
|
|
238
|
+
candidates,
|
|
239
|
+
key=lambda candidate: turn_priority[
|
|
240
|
+
(edge_direction(candidate) - incoming_direction) % 4
|
|
241
|
+
],
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if len(contour) >= 8 and contour[-1] == start:
|
|
245
|
+
contours.append(contour[:-1])
|
|
246
|
+
|
|
247
|
+
return contours
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def contour_area(points: list[Point]) -> float:
|
|
251
|
+
return 0.5 * abs(
|
|
252
|
+
sum(
|
|
253
|
+
x1 * y2 - x2 * y1
|
|
254
|
+
for (x1, y1), (x2, y2) in zip(points, points[1:] + points[:1])
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def contour_span(points: list[Point]) -> int:
|
|
260
|
+
xs = [point[0] for point in points]
|
|
261
|
+
ys = [point[1] for point in points]
|
|
262
|
+
return max(max(xs) - min(xs), max(ys) - min(ys))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def format_number(value: float) -> str:
|
|
266
|
+
rounded = round(value, 1)
|
|
267
|
+
return str(int(rounded)) if rounded.is_integer() else str(rounded)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def is_corner(
|
|
271
|
+
previous: Point,
|
|
272
|
+
current: Point,
|
|
273
|
+
following: Point,
|
|
274
|
+
threshold_degrees: float,
|
|
275
|
+
) -> bool:
|
|
276
|
+
incoming = (current[0] - previous[0], current[1] - previous[1])
|
|
277
|
+
outgoing = (following[0] - current[0], following[1] - current[1])
|
|
278
|
+
incoming_length = math.hypot(*incoming)
|
|
279
|
+
outgoing_length = math.hypot(*outgoing)
|
|
280
|
+
if incoming_length == 0 or outgoing_length == 0:
|
|
281
|
+
return True
|
|
282
|
+
cosine = max(
|
|
283
|
+
-1.0,
|
|
284
|
+
min(
|
|
285
|
+
1.0,
|
|
286
|
+
(incoming[0] * outgoing[0] + incoming[1] * outgoing[1])
|
|
287
|
+
/ (incoming_length * outgoing_length),
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
return math.acos(cosine) > math.radians(threshold_degrees)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def path_data(contour: list[Point]) -> str:
|
|
294
|
+
count = len(contour)
|
|
295
|
+
span = contour_span(contour)
|
|
296
|
+
corner_threshold = 100 if span <= 120 else 48
|
|
297
|
+
corners = [
|
|
298
|
+
is_corner(
|
|
299
|
+
contour[(index - 1) % count],
|
|
300
|
+
contour[index],
|
|
301
|
+
contour[(index + 1) % count],
|
|
302
|
+
corner_threshold,
|
|
303
|
+
)
|
|
304
|
+
for index in range(count)
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
if any(corners):
|
|
308
|
+
start_index = corners.index(True)
|
|
309
|
+
contour = contour[start_index:] + contour[:start_index]
|
|
310
|
+
corners = corners[start_index:] + corners[:start_index]
|
|
311
|
+
first = contour[0]
|
|
312
|
+
commands = [f"M{format_number(first[0])} {format_number(first[1])}"]
|
|
313
|
+
|
|
314
|
+
for index in range(1, count):
|
|
315
|
+
current = contour[index]
|
|
316
|
+
following = contour[(index + 1) % count]
|
|
317
|
+
if corners[index]:
|
|
318
|
+
commands.append(
|
|
319
|
+
f"L{format_number(current[0])} {format_number(current[1])}"
|
|
320
|
+
)
|
|
321
|
+
else:
|
|
322
|
+
midpoint = (
|
|
323
|
+
(current[0] + following[0]) / 2,
|
|
324
|
+
(current[1] + following[1]) / 2,
|
|
325
|
+
)
|
|
326
|
+
commands.append(
|
|
327
|
+
"Q"
|
|
328
|
+
f"{format_number(current[0])} {format_number(current[1])} "
|
|
329
|
+
f"{format_number(midpoint[0])} {format_number(midpoint[1])}"
|
|
330
|
+
)
|
|
331
|
+
commands.append(f"L{format_number(first[0])} {format_number(first[1])}")
|
|
332
|
+
else:
|
|
333
|
+
first_midpoint = (
|
|
334
|
+
(contour[0][0] + contour[1][0]) / 2,
|
|
335
|
+
(contour[0][1] + contour[1][1]) / 2,
|
|
336
|
+
)
|
|
337
|
+
commands = [
|
|
338
|
+
f"M{format_number(first_midpoint[0])} {format_number(first_midpoint[1])}"
|
|
339
|
+
]
|
|
340
|
+
for index in range(1, count + 1):
|
|
341
|
+
current = contour[index % count]
|
|
342
|
+
following = contour[(index + 1) % count]
|
|
343
|
+
midpoint = (
|
|
344
|
+
(current[0] + following[0]) / 2,
|
|
345
|
+
(current[1] + following[1]) / 2,
|
|
346
|
+
)
|
|
347
|
+
commands.append(
|
|
348
|
+
"Q"
|
|
349
|
+
f"{format_number(current[0])} {format_number(current[1])} "
|
|
350
|
+
f"{format_number(midpoint[0])} {format_number(midpoint[1])}"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
commands.append("Z")
|
|
354
|
+
return "".join(commands)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def save_svg(
|
|
358
|
+
contours: list[list[Point]],
|
|
359
|
+
width: int,
|
|
360
|
+
height: int,
|
|
361
|
+
title: str,
|
|
362
|
+
output_path: Path,
|
|
363
|
+
) -> None:
|
|
364
|
+
paths = "\n ".join(path_data(contour) for contour in contours)
|
|
365
|
+
svg = f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" fill="none" color="#111111">
|
|
366
|
+
<title>{escape(title)}</title>
|
|
367
|
+
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="
|
|
368
|
+
{paths}
|
|
369
|
+
"/>
|
|
370
|
+
</svg>
|
|
371
|
+
"""
|
|
372
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
373
|
+
output_path.write_text(svg, encoding="utf-8")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def main() -> None:
|
|
377
|
+
arguments = parse_arguments()
|
|
378
|
+
if not 0 <= arguments.threshold <= 255:
|
|
379
|
+
raise SystemExit("--threshold должен быть в диапазоне 0–255")
|
|
380
|
+
if not 0 <= arguments.alpha_threshold <= 255:
|
|
381
|
+
raise SystemExit("--alpha-threshold должен быть в диапазоне 0–255")
|
|
382
|
+
|
|
383
|
+
image = Image.open(arguments.source)
|
|
384
|
+
width, height = image.size
|
|
385
|
+
mask, used_alpha = create_mask(
|
|
386
|
+
image,
|
|
387
|
+
arguments.threshold,
|
|
388
|
+
arguments.alpha_threshold,
|
|
389
|
+
)
|
|
390
|
+
png_output = arguments.output_prefix.with_suffix(".png")
|
|
391
|
+
svg_output = arguments.output_prefix.with_suffix(".svg")
|
|
392
|
+
save_clean_png(mask, png_output)
|
|
393
|
+
|
|
394
|
+
contours = [
|
|
395
|
+
contour
|
|
396
|
+
for contour in trace_contours(build_edges(mask))
|
|
397
|
+
if contour_area(contour) >= 3
|
|
398
|
+
]
|
|
399
|
+
simplified = [
|
|
400
|
+
simplify_closed(
|
|
401
|
+
contour,
|
|
402
|
+
arguments.small_tolerance
|
|
403
|
+
if contour_span(contour) <= arguments.small_span
|
|
404
|
+
else arguments.large_tolerance,
|
|
405
|
+
)
|
|
406
|
+
for contour in contours
|
|
407
|
+
]
|
|
408
|
+
save_svg(simplified, width, height, arguments.title, svg_output)
|
|
409
|
+
|
|
410
|
+
source_mode = "альфа-канал" if used_alpha else f"яркость < {arguments.threshold}"
|
|
411
|
+
vertex_count = sum(len(contour) for contour in simplified)
|
|
412
|
+
print(
|
|
413
|
+
f"Готово: {png_output}, {svg_output}; "
|
|
414
|
+
f"маска: {source_mode}; контуров: {len(contours)}; вершин: {vertex_count}"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
if __name__ == "__main__":
|
|
419
|
+
try:
|
|
420
|
+
main()
|
|
421
|
+
except OSError as error:
|
|
422
|
+
raise SystemExit(f"Ошибка обработки изображения: {error}") from error
|
|
@@ -6,7 +6,7 @@ const deckPath = resolve(process.argv[2] ?? 'example.md')
|
|
|
6
6
|
|
|
7
7
|
validateDeckLayouts(deckPath).then((issues) => {
|
|
8
8
|
if (!issues.length) {
|
|
9
|
-
console.log(`OK: ${deckPath} — контракты
|
|
9
|
+
console.log(`OK: ${deckPath} — контракты макетов, Markdown и локальных вариантов соблюдены.`)
|
|
10
10
|
process.exit(0)
|
|
11
11
|
}
|
|
12
12
|
|
package/setup/vite-plugins.ts
CHANGED
|
@@ -1,19 +1,124 @@
|
|
|
1
|
-
import { resolve } from 'node:path'
|
|
1
|
+
import { relative, resolve, sep } from 'node:path'
|
|
2
2
|
import type { ResolvedSlidevOptions } from '@slidev/types'
|
|
3
3
|
import type { Plugin } from 'vite'
|
|
4
|
+
import {
|
|
5
|
+
collectDeckDecorFiles,
|
|
6
|
+
DECK_DECORS_VIRTUAL_ID,
|
|
7
|
+
resolveDeckFileDecors,
|
|
8
|
+
RESOLVED_DECK_DECORS_VIRTUAL_ID,
|
|
9
|
+
} from '../composables/deck-decors'
|
|
4
10
|
import { createFileDecorStore } from '../composables/decor-file-store'
|
|
11
|
+
import { isRecord } from '../composables/decor-sources'
|
|
5
12
|
import { createDecorSaveMiddleware } from './decor-save-middleware'
|
|
13
|
+
import {
|
|
14
|
+
createDeckLayoutVariantModuleSource,
|
|
15
|
+
deckLayoutVariantsDirectory,
|
|
16
|
+
DECK_LAYOUT_VARIANTS_VIRTUAL_ID,
|
|
17
|
+
discoverDeckLayoutVariantFiles,
|
|
18
|
+
RESOLVED_DECK_LAYOUT_VARIANTS_VIRTUAL_ID,
|
|
19
|
+
} from '../composables/local-layout-variant-files'
|
|
6
20
|
|
|
7
21
|
const OUTPUT_PATH = resolve(process.cwd(), 'composables/decor-tuning-overrides.mjs')
|
|
8
22
|
|
|
9
23
|
type DecorLibraryVitePluginContext = Pick<ResolvedSlidevOptions, 'data'>
|
|
24
|
+
& Partial<Pick<ResolvedSlidevOptions, 'userRoot'>>
|
|
25
|
+
|
|
26
|
+
function readThemeConfig(options?: DecorLibraryVitePluginContext) {
|
|
27
|
+
const value = options?.data?.config?.themeConfig
|
|
28
|
+
return isRecord(value) ? value : {}
|
|
29
|
+
}
|
|
10
30
|
|
|
11
31
|
function readExpectedOrigin(options?: DecorLibraryVitePluginContext) {
|
|
12
|
-
const value = options
|
|
32
|
+
const value = readThemeConfig(options).decorSaveOrigin
|
|
13
33
|
|
|
14
34
|
return typeof value === 'string' && value.trim() ? value : undefined
|
|
15
35
|
}
|
|
16
36
|
|
|
37
|
+
function createDeckDecorsPlugin(options?: DecorLibraryVitePluginContext): Plugin {
|
|
38
|
+
const root = process.cwd()
|
|
39
|
+
|
|
40
|
+
function spec() {
|
|
41
|
+
return {
|
|
42
|
+
decors: readThemeConfig(options).decors,
|
|
43
|
+
root,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
name: 'practicum:deck-decors',
|
|
49
|
+
async config() {
|
|
50
|
+
return {
|
|
51
|
+
define: {
|
|
52
|
+
__PRATICUM_DECK_DECORS__: JSON.stringify(await resolveDeckFileDecors(spec())),
|
|
53
|
+
},
|
|
54
|
+
optimizeDeps: {
|
|
55
|
+
exclude: ['slidev-theme-practicum'],
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
resolveId(id) {
|
|
60
|
+
if (id === DECK_DECORS_VIRTUAL_ID)
|
|
61
|
+
return RESOLVED_DECK_DECORS_VIRTUAL_ID
|
|
62
|
+
},
|
|
63
|
+
async load(id) {
|
|
64
|
+
if (id !== RESOLVED_DECK_DECORS_VIRTUAL_ID)
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
const current = spec()
|
|
68
|
+
for (const path of collectDeckDecorFiles(current))
|
|
69
|
+
this.addWatchFile(path)
|
|
70
|
+
|
|
71
|
+
return `export const DECK_FILE_DECORS = ${JSON.stringify(await resolveDeckFileDecors(current))}\n`
|
|
72
|
+
},
|
|
73
|
+
configureServer(server) {
|
|
74
|
+
const watcher = server.watcher
|
|
75
|
+
if (!watcher?.add)
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
for (const path of collectDeckDecorFiles(spec()))
|
|
79
|
+
watcher.add(path)
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function createDeckLayoutVariantsPlugin(options?: DecorLibraryVitePluginContext): Plugin {
|
|
85
|
+
const root = options?.userRoot ?? process.cwd()
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
name: 'practicum:deck-layout-variants',
|
|
89
|
+
resolveId(id) {
|
|
90
|
+
if (id === DECK_LAYOUT_VARIANTS_VIRTUAL_ID)
|
|
91
|
+
return RESOLVED_DECK_LAYOUT_VARIANTS_VIRTUAL_ID
|
|
92
|
+
},
|
|
93
|
+
load(id) {
|
|
94
|
+
if (id !== RESOLVED_DECK_LAYOUT_VARIANTS_VIRTUAL_ID)
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
const files = discoverDeckLayoutVariantFiles(root)
|
|
98
|
+
for (const file of files)
|
|
99
|
+
this.addWatchFile(file.path)
|
|
100
|
+
|
|
101
|
+
return createDeckLayoutVariantModuleSource(files)
|
|
102
|
+
},
|
|
103
|
+
configureServer(server) {
|
|
104
|
+
server.watcher.add(deckLayoutVariantsDirectory(root))
|
|
105
|
+
},
|
|
106
|
+
handleHotUpdate(context) {
|
|
107
|
+
const relativeFile = relative(deckLayoutVariantsDirectory(root), context.file)
|
|
108
|
+
const segments = relativeFile.split(sep)
|
|
109
|
+
if (segments.length !== 2 || relativeFile.startsWith(`..${sep}`) || !context.file.endsWith('.vue'))
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
const virtualModule = context.server.moduleGraph.getModuleById(RESOLVED_DECK_LAYOUT_VARIANTS_VIRTUAL_ID)
|
|
113
|
+
if (virtualModule)
|
|
114
|
+
context.server.moduleGraph.invalidateModule(virtualModule)
|
|
115
|
+
|
|
116
|
+
context.server.ws.send({ type: 'full-reload' })
|
|
117
|
+
return []
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
17
122
|
export default function decorLibraryVitePlugins(
|
|
18
123
|
options?: DecorLibraryVitePluginContext,
|
|
19
124
|
): Plugin[] {
|
|
@@ -32,5 +137,7 @@ export default function decorLibraryVitePlugins(
|
|
|
32
137
|
}))
|
|
33
138
|
},
|
|
34
139
|
},
|
|
140
|
+
createDeckDecorsPlugin(options),
|
|
141
|
+
createDeckLayoutVariantsPlugin(options),
|
|
35
142
|
]
|
|
36
143
|
}
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: slidev-practicum
|
|
3
|
-
description:
|
|
3
|
+
description: Используй для Slidev-презентаций, фотографических и предметных контурных иллюстраций в стиле slidev-theme-practicum или Яндекс Практикума.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Slidev Практикума
|
|
7
7
|
|
|
8
|
-
Используй этот скилл как тонкий маршрутизатор для
|
|
8
|
+
Используй этот скилл как тонкий маршрутизатор для колод, фотографических и контурных иллюстраций темы.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
## Колода
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
**ОБЯЗАТЕЛЬНЫЙ ПОДСКИЛЛ:** используй `slidev` для Markdown-синтаксиса, frontmatter, кода, заметок, анимаций, dev-сервера, сборки и экспорта.
|
|
13
|
+
|
|
14
|
+
Не дублируй правила темы. Читай локальный источник истины:
|
|
13
15
|
|
|
14
16
|
- `README.md` — публичный авторский контракт, лейауты, компоненты, `themeConfig`, декор и правила контента.
|
|
15
17
|
- `example.md` — канонические паттерны слайдов, которые нужно копировать и адаптировать.
|
|
@@ -21,5 +23,17 @@ description: Используй, когда нужно создать, отре
|
|
|
21
23
|
- Во внешней колоде с установленным пакетом используй `theme: practicum`.
|
|
22
24
|
- Пиши по-русски, если пользователь не попросил другой язык.
|
|
23
25
|
- Выбирай тип слайда по задаче кадра, следуя `README.md`.
|
|
26
|
+
- Используй верхнеуровневый `title` только в первом headmatter как служебное название всей колоды. Не считай его видимым содержимым: пиши основной заголовок каждого кадра в теле слайда как Markdown `# …`, а для явной композиции — как видимый `<Text as="h1">…</Text>`. Вложенные `title` моделей компонентов сохраняй по контракту варианта.
|
|
27
|
+
- Для повторяющейся композиции конкретной колоды без Vue-тегов в `slides.md` используй `components/layout-variants/<layout>/<variant>.vue` и существующие поля front matter `layout` + `variant`; точный контракт бери из раздела README «Локальные варианты презентации».
|
|
28
|
+
|
|
29
|
+
Перед созданием структуры новой колоды, добавлением локального медиа или реорганизацией существующей колоды полностью прочитай `references/deck-project-structure.md`. Он владеет каноническим деревом проекта, классификацией `public/decor`, `public/photos`, `public/illustrations`, `public/figures`, правилами путей и именования. Не создавай альтернативные общие каталоги `assets` или `images`.
|
|
24
30
|
|
|
25
31
|
Не используй `slidev-theme-architect-skill.md` для обычных колод; он нужен для создания или редизайна theme package.
|
|
32
|
+
|
|
33
|
+
## Контурная иллюстрация
|
|
34
|
+
|
|
35
|
+
Перед созданием, перерисовкой или векторизацией предметной контурной иллюстрации полностью прочитай `references/contour-illustrations.md`. Он владеет референсами, растровой генерацией, трассировкой и приёмкой. Документы колоды загружай только если задача одновременно меняет слайд.
|
|
36
|
+
|
|
37
|
+
## Фотографическая иллюстрация
|
|
38
|
+
|
|
39
|
+
Перед созданием, редактированием или приёмкой сюжетной фотографической иллюстрации полностью прочитай `references/photographic-illustrations.md`. Он владеет семантическими ролями изображений, распределением ролей между референсами, карточкой сложной сцены, масштабом, физическими контактами, перспективой жёстких объектов и проверкой увеличений. Документы конкретной колоды загружай только для её персонажей, предметов, окружения и сюжета.
|