slidev-theme-practicum 0.2.0 → 0.4.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.
Files changed (50) hide show
  1. package/README.md +225 -22
  2. package/components/Slide.vue +12 -58
  3. package/components/Slot.vue +4 -3
  4. package/components/StepsGrid.vue +117 -0
  5. package/components/Text.vue +5 -0
  6. package/composables/deck-decors.ts +74 -0
  7. package/composables/deck-slot-markup.cjs +19 -5
  8. package/composables/decor-sources.ts +43 -0
  9. package/composables/layout-authoring.ts +34 -9
  10. package/composables/layout-recipes.ts +16 -2
  11. package/composables/layout-shorthands.ts +157 -83
  12. package/composables/local-layout-variant-files.ts +106 -0
  13. package/composables/local-layout-variants.ts +73 -0
  14. package/composables/slide-layout.ts +3 -0
  15. package/composables/text-fit-runtime.ts +7 -3
  16. package/composables/theme-foundation.ts +7 -3
  17. package/composables/typography-guard.cjs +59 -0
  18. package/composables/use-theme-config.ts +17 -10
  19. package/composables/validate-deck-layouts.cjs +95 -7
  20. package/composables/validate-deck-typography.cjs +101 -0
  21. package/env.d.ts +18 -0
  22. package/example.md +1 -1
  23. package/package.json +18 -6
  24. package/scripts/browser-smoke.mjs +85 -23
  25. package/scripts/check-accessibility.mjs +364 -0
  26. package/scripts/check-consumer.mjs +159 -0
  27. package/scripts/check-local-layout-variant-build.mjs +189 -0
  28. package/scripts/check-package.mjs +18 -2
  29. package/scripts/check-pixels.mjs +251 -0
  30. package/scripts/check-typography.mjs +108 -0
  31. package/scripts/requirements-illustrations.txt +1 -0
  32. package/scripts/test-typography.mjs +89 -0
  33. package/scripts/trace-line-art.py +422 -0
  34. package/scripts/typography-browser.mjs +134 -0
  35. package/scripts/validate-deck.cjs +23 -3
  36. package/setup/vite-plugins.ts +109 -2
  37. package/skills/slidev-practicum/SKILL.md +19 -4
  38. package/skills/slidev-practicum/references/contour-illustrations.md +114 -0
  39. package/skills/slidev-practicum/references/deck-project-structure.md +136 -0
  40. package/skills/slidev-practicum/references/illustration-examples/balance-scales.png +0 -0
  41. package/skills/slidev-practicum/references/illustration-examples/balance-scales.svg +88 -0
  42. package/skills/slidev-practicum/references/illustration-examples/chainsaw.png +0 -0
  43. package/skills/slidev-practicum/references/illustration-examples/chainsaw.svg +4 -0
  44. package/skills/slidev-practicum/references/illustration-examples/graduation-cap.png +0 -0
  45. package/skills/slidev-practicum/references/illustration-examples/graduation-cap.svg +23 -0
  46. package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.png +0 -0
  47. package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.svg +4 -0
  48. package/skills/slidev-practicum/references/photographic-illustrations.md +100 -0
  49. package/styles/index.css +20 -27
  50. package/styles/vars.css +6 -2
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { dirname, resolve } from 'node:path'
5
+ import { pathToFileURL } from 'node:url'
6
+ import { parseArgs } from 'node:util'
7
+ import { parseSync } from '@slidev/parser'
8
+ import { createStaticDistServer } from './browser-smoke-runtime.mjs'
9
+ import { inspectRenderedTypography } from './typography-browser.mjs'
10
+
11
+ function loadChromium(deckPath) {
12
+ // В установленной теме Playwright может принадлежать проекту самой колоды.
13
+ const require = createRequire(import.meta.url)
14
+ try {
15
+ return require('playwright-chromium').chromium
16
+ }
17
+ catch {
18
+ try {
19
+ return createRequire(deckPath)('playwright-chromium').chromium
20
+ }
21
+ catch {
22
+ throw new Error('Для браузерной проверки установите в проект колоды playwright-chromium: npm install -D playwright-chromium')
23
+ }
24
+ }
25
+ }
26
+
27
+ export async function checkTypography({ deckPath, distDir }) {
28
+ const absolutePath = resolve(deckPath)
29
+ const slides = parseSync(readFileSync(absolutePath, 'utf8'), absolutePath).slides
30
+ const hashRouter = slides[0]?.frontmatter.routerMode === 'hash'
31
+ if (slides.some(slide => slide.frontmatter.src))
32
+ throw new Error('Браузерная проверка пока требует колоду без включений src. Соберите слайды в один Markdown-файл и повторите проверку его сборки.')
33
+ const chromium = loadChromium(absolutePath)
34
+ const issues = []
35
+ let browser
36
+ let server
37
+ let textCount = 0
38
+ try {
39
+ server = await createStaticDistServer(resolve(distDir))
40
+ browser = await chromium.launch({ args: ['--force-color-profile=srgb'] })
41
+ const context = await browser.newContext({
42
+ colorScheme: 'light',
43
+ reducedMotion: 'reduce',
44
+ serviceWorkers: 'block',
45
+ viewport: { width: 1280, height: 720 },
46
+ })
47
+ await context.grantPermissions(['screen-wake-lock'], { origin: server.origin })
48
+ const page = await context.newPage()
49
+ page.setDefaultTimeout(15_000)
50
+ let currentSlide = 1
51
+ page.on('pageerror', error => issues.push({ page: currentSlide, code: 'browser-error', message: error.message }))
52
+ for (let index = 0; index < slides.length; index += 1) {
53
+ const slideNumber = index + 1
54
+ currentSlide = slideNumber
55
+ try {
56
+ if (!hashRouter || index === 0)
57
+ await page.goto(`${server.origin}/${hashRouter ? '#/' : ''}${slideNumber}`, { waitUntil: 'domcontentloaded' })
58
+ else
59
+ await page.evaluate(number => window.location.hash = `#/${number}`, slideNumber)
60
+ const slide = page.locator(`.slidev-page[data-slidev-no="${slideNumber}"]`)
61
+ await slide.waitFor({ state: 'visible' })
62
+ if (!await slide.locator('.Slide').count())
63
+ throw new Error('В сборке не найден слайд темы Practicum. Проверьте соответствие файла колоды и каталога сборки.')
64
+ const result = await inspectRenderedTypography(slide)
65
+ textCount += result.textCount
66
+ issues.push(...result.issues.map(issue => ({ page: slideNumber, ...issue })))
67
+ }
68
+ catch (error) {
69
+ issues.push({ page: slideNumber, code: 'render-error', message: error.message })
70
+ }
71
+ }
72
+ }
73
+ finally {
74
+ await browser?.close()
75
+ await server?.close()
76
+ }
77
+ return { issues, slideCount: slides.length, textCount }
78
+ }
79
+
80
+ async function main() {
81
+ const { values, positionals } = parseArgs({
82
+ allowPositionals: true,
83
+ options: {
84
+ dist: { type: 'string' },
85
+ help: { type: 'boolean', short: 'h' },
86
+ },
87
+ })
88
+ if (values.help) {
89
+ console.log('Использование: slidev-practicum-check-typography [slides.md] [--dist dist]\n'
90
+ + 'Проверяет типографику показанных слайдов готовой сборки. Сначала соберите эту же колоду через slidev build.')
91
+ return
92
+ }
93
+ if (positionals.length > 1)
94
+ throw new Error('Укажите один файл колоды.')
95
+ const deckPath = resolve(positionals[0] ?? 'example.md')
96
+ const result = await checkTypography({ deckPath, distDir: values.dist ?? resolve(dirname(deckPath), 'dist') })
97
+ for (const issue of result.issues)
98
+ console.error(`Слайд ${issue.page}: ${issue.message}${issue.hint ? `\n ${issue.hint}` : ''}`)
99
+ console.log(`Типографика: слайды=${result.slideCount}, текстовые фрагменты=${result.textCount}, нарушения=${result.issues.length}`)
100
+ process.exitCode = result.issues.length ? 1 : 0
101
+ }
102
+
103
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
104
+ main().catch((error) => {
105
+ console.error(error.message)
106
+ process.exitCode = 1
107
+ })
108
+ }
@@ -0,0 +1 @@
1
+ Pillow==12.2.0
@@ -0,0 +1,89 @@
1
+ import assert from 'node:assert/strict'
2
+ import { spawnSync } from 'node:child_process'
3
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
4
+ import { join, resolve } from 'node:path'
5
+ import { chromium } from 'playwright-chromium'
6
+ import { checkTypography } from './check-typography.mjs'
7
+ import { collectRenderedTypography, inspectRenderedTypography } from './typography-browser.mjs'
8
+
9
+ const root = resolve(import.meta.dirname, '..')
10
+ const deckPath = join(root, 'tests/fixtures/typography-deck.md')
11
+ mkdirSync(join(root, '.slidev'), { recursive: true })
12
+ const workRoot = mkdtempSync(join(root, '.slidev/typography-test-'))
13
+
14
+ async function checkDom() {
15
+ const browser = await chromium.launch()
16
+ try {
17
+ const page = await browser.newPage()
18
+ await page.setContent(`
19
+ <style>
20
+ .Slot-Content { width: 600px; height: 400px; }
21
+ .Text { font-size: 24px; color: rgb(0, 0, 0); }
22
+ </style>
23
+ <div class="slidev-layout">
24
+ <div class="Header" style="font-size: 9px">Служебная шапка 2026</div>
25
+ <div class="Slot"><div class="Slot-Content">
26
+ <div class="Text">Первый <span style="color: #000">тот же цвет</span><span style="color: color(srgb 0 0 0)">цвет sRGB</span></div>
27
+ <div class="Text" style="font-size: 40px">Заголовок</div>
28
+ <data class="Text" style="font-size: 60px">42</data>
29
+ <div class="Logo" style="font-size: 1px">Логотип</div>
30
+ <svg><text style="font-size: 2px">Иллюстрация</text></svg>
31
+ <div style="opacity: 0"><span style="font-size: 3px">Скрытый текст</span></div>
32
+ </div></div>
33
+ </div>`)
34
+ const slide = page.locator('.slidev-layout')
35
+ assert.deepEqual((await inspectRenderedTypography(slide)).issues, [])
36
+ const visible = await slide.evaluate(collectRenderedTypography)
37
+ assert.deepEqual(visible.texts.map(text => text.text), ['Первый', 'тот же цвет', 'цвет sRGB', 'Заголовок', '42'])
38
+ assert.equal(new Set(visible.texts.map(text => text.color)).size, 1)
39
+
40
+ await page.locator('.Text span').last().evaluate((element) => {
41
+ element.style.color = 'color(srgb 1 0 0)'
42
+ })
43
+ assert.ok((await inspectRenderedTypography(slide)).issues.some(issue => issue.code === 'slot-colors'))
44
+
45
+ await page.locator('.Slot-Content').evaluate((content) => {
46
+ const timelineLabel = document.createElement('span')
47
+ timelineLabel.textContent = '2026'
48
+ timelineLabel.style.fontSize = '16px'
49
+ content.append(timelineLabel)
50
+ })
51
+ assert.ok((await inspectRenderedTypography(slide)).issues.some(issue => issue.code === 'text-sizes'))
52
+
53
+ await page.locator('.Text').first().evaluate((element) => {
54
+ element.setAttribute('data-text-color', 'text-muted')
55
+ element.style.transform = 'translateX(650px)'
56
+ })
57
+ const violations = (await inspectRenderedTypography(slide)).issues.map(issue => issue.code)
58
+ assert.ok(violations.includes('muted-text'))
59
+ assert.ok(violations.includes('text-overflow'))
60
+ }
61
+ finally {
62
+ await browser.close()
63
+ }
64
+ }
65
+
66
+ try {
67
+ const build = spawnSync(process.execPath, [
68
+ join(root, 'node_modules/@slidev/cli/bin/slidev.mjs'),
69
+ 'build', deckPath, '--out', workRoot,
70
+ ], { cwd: root, encoding: 'utf8', timeout: 120_000, stdio: ['ignore', 'pipe', 'pipe'] })
71
+ assert.equal(build.status, 0, build.stderr || build.stdout)
72
+ const { issues, slideCount } = await checkTypography({ deckPath, distDir: workRoot })
73
+ assert.equal(slideCount, 9)
74
+ const codes = page => issues.filter(issue => issue.page === page).map(issue => issue.code)
75
+ for (const page of [1, 3, 8])
76
+ assert.deepEqual(codes(page), [], JSON.stringify(issues.filter(issue => issue.page === page)))
77
+ assert.ok(codes(2).includes('text-sizes'))
78
+ assert.ok(codes(4).includes('muted-text'))
79
+ assert.ok(codes(5).includes('text-overflow'))
80
+ assert.ok(codes(6).includes('number-sizes'))
81
+ assert.ok(codes(7).includes('slot-colors'))
82
+ assert.ok(codes(9).includes('text-overflow'), JSON.stringify(issues.filter(issue => issue.page === 9)))
83
+ assert.equal(issues.some(issue => ['render-error', 'browser-error'].includes(issue.code)), false, JSON.stringify(issues))
84
+ await checkDom()
85
+ console.log('Типографика: проверены 9 слайдов с явной разметкой и Markdown, цвета, исключения и геометрия DOM.')
86
+ }
87
+ finally {
88
+ rmSync(workRoot, { recursive: true, force: true })
89
+ }
@@ -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