diff --git a/.gitignore b/.gitignore
index 3bc2aaa..04b487a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,5 +33,6 @@ content/*
!content/main
.impeccable/critique/
+.impeccable/review/
.agents/
-.codex/
\ No newline at end of file
+.codex/
diff --git a/.impeccable/design.json b/.impeccable/design.json
index 8042374..6994a2d 100644
--- a/.impeccable/design.json
+++ b/.impeccable/design.json
@@ -1,6 +1,6 @@
{
"schemaVersion": 2,
- "generatedAt": "2026-10-06T21:19:06Z",
+ "generatedAt": "2026-10-10T10:40:00Z",
"title": "Design System: ES Doc",
"extensions": {
"colorMeta": {
@@ -168,6 +168,12 @@
{ "name": "narrow", "value": "400px" }
],
"surfaceGuidance": {
+ "tools-characters": {
+ "layout": "Scoped 78rem settings-left / scene-and-code-right workbench, .9 / 1.15 columns with a 40px gap. Section navigation sticks at 64px and result at 132px; heading/field scroll margins are 140px. At 960px columns become equal, gap 24px and parameter groups one column. At 720px settings precede the static result with a 32px gap, common fields return to two columns, and section buttons become a fixed safe-area bottom bar. Reserve 88px below the page and use 80px top / 88px bottom scroll margins on mobile. Jumps focus destinations; Settings returns to the last connected edited field. Keep 44px controls and body-sized mobile inputs.",
+ "behavior": "Variable/name and five common fields lead; name semantics, inheritance, fonts, typography, behavior, screen and engine settings start in native disclosures. Each field discloses its key, type, explicit-value checkbox and reset. HEX preserves text alpha; the native chooser displays RGB without mutating text and an explicit selection sets an opaque color. Added fields have visible removal even when empty, returning focus to adding a property. Removing a configured value is undoable. Catalog search matches keys, Russian help/labels, inflections and task synonyms; a scope-switch recovery names and focuses matching results elsewhere. Existing-property selection reveals and focuses its input. Preserve omission versus explicit empty/False/None/zero and typed export; Python is unexecuted text with structural checks only. Per-field errors have accessible descriptions, block copy/.rpy and offer first-error focus. The approximate 1920 × 1080 scene starts enlarged 2× desktop / 3× mobile; full-scene view remains available. Reflect supported literal styling and converted padding; list unsupported configured properties. Explicit replay safely preserves supported text-tag spans and respects motion/speed/interruption settings. Local draft status names readiness, restored state, successful writes or storage failure. A 64-step history includes reset/removal/import; shortcuts act outside text editors. JSON settings and definition-only .rpy are distinct; collapsed usage shows the current valid variable. Failed import retains state and failed copy selects code. Preview files stay local/outside JSON. Navigation flushes edits, releases listeners/timers/URLs/font faces and rejects stale loads.",
+ "dataColors": "Name #e2c778, dialogue #f5ecd7, scene #253846 and textbox #18232ee8 are functional preview data, independent of site theme. User colors, local backgrounds and specimen fonts add no global accents or shipping assets. Controls, focus, errors and code stay on incumbent theme tokens.",
+ "typography": "PT Serif page title, Inter controls and help, monospace parameter keys/code. Scene-pixel, game-font-path and outline-tuple help uses the existing --fs-ui step. Mobile inputs use body size; preview text scales with scene width. No new global typography or color tokens."
+ },
"tools-colors": {
"layout": "72rem workbench: code formats left, visual editor right, contrast below both. DOM and focus order match visual order. At 640px and below, formats/editor/contrast stack in the same order, quick section buttons scroll and focus without navigation or state reset, and four supporting tabs wrap. Keep this measure scoped to the color tool.",
"behavior": "One sRGB selection synchronizes field/wheel, RGB/HSV/HSL/alpha, six always-visible editable format rows, harmonies, OKLab shades and composited contrast. Preserve active typing and valid color on errors. Arrow keys step the focusable area and numeric channels; picker-wide undo restores the preview pair and role. The role selector edits the existing text/background color, swap exchanges them, and Lucide reset restores defaults. Native EyeDropper is feature-detected. Clipboard failure selects the affected row or a manual palette-copy field. Local image metadata shows dimensions without filenames; loading/errors remain explicit. Each invalid field owns its visible error and accessible description. Saved collections join undo/redo; clearing also exposes immediate recovery preserving later color edits. Recent colors remain separate. The named-color palette preserves all 1012 supplied name/HEX pairs, searches names and full/short HEX case-insensitively with е/ё equivalence, shows names beside HEX and renders another 100 matches only on request. Delegate palette clicks and avoid repainting unchanged samples. Keyboard help sits beside the editor. Alpha controls use Russian transparency labels.",
diff --git a/DESIGN.md b/DESIGN.md
index 6fb31b8..982efa6 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -315,6 +315,8 @@ Standard header spacing is 32px above / 28px below, 40/32 for the homepage, and
The color workbench uses a 72rem maximum and two desktop columns, with the editor beside the code result. At 640px and below, code and copy come first, the editor follows, then contrast; supporting tabs wrap rather than hiding destinations behind horizontal scrolling. This is a scoped tool layout, not a replacement for article or directory measures.
+The character workbench uses a scoped 78rem maximum: settings left, approximate scene above code right, with .9 / 1.15 columns and a 40px gap. Section navigation sticks at 64px and the result at 132px. At 960px and below the columns become equal with a 24px gap and single-column parameter groups. At 720px and below the result becomes static after settings, separated by 32px; common fields return to two columns. Section navigation becomes a fixed bottom bar padded for the safe area, with 88px reserved below the page. Heading and field scroll margins are 140px on desktop and 80px above / 88px below on mobile. Section jumps focus their destination, and Settings returns to the last connected edited field. Controls retain 44px targets and mobile inputs use body-sized text.
+
### Responsive and accessibility contract
- Breakpoints use em and reflect content fit: 1280px (TOC), 1140px (header links), 992px (article tree), 800px (gutters), 768px (directory groups), 640px (search sheet).
@@ -540,7 +542,7 @@ Tools warnings use the site's shared attention circle with an exclamation mark,
Adding files or a folder displays every supported source together without decompiling or extracting them. Loose scripts are listed immediately without loading Python. RPA files load their validated indexes only. Opening a file extracts or decompiles that entry on demand; the current preview is cached, and changing decompilation options updates only the open file. The primary download below the browser processes all sources and automatically downloads the resulting ZIPs. Completed ZIP links remain available for retrying a blocked download. The retry button is for reading the catalog after cancellation or a loading error, never a required processing step.
-`/tools` is a lightweight hub with flat linked rows for `/tools/unpack` and `/tools/colors`, followed by community tools. Individual tool pages link back to the hub and keep the Tools navigation active. Page-specific code loads only where needed.
+`/tools` is a lightweight hub with flat linked rows for `/tools/unpack`, `/tools/colors` and `/tools/characters`, followed by community tools. Individual tool pages link back to the hub and keep the Tools navigation active. Page-specific code loads only where needed.
`/tools/unpack` is the local Ren'Py workbench in the existing page frame: PT Serif headings, Inter controls, and a 66rem column. One queue accepts .rpa, .rpyc and .rpymc; there are no processing modes to select. Archive extraction also decompiles contained scripts. The source selector groups standalone «Сценарии» and individual archives without changing processing scope. «Скачать всё в ZIP» always exports the whole queue. Clearing or completing a download clears every source; cancellation keeps sources. The queue renders at most 200 rows and the browser at most 500 entries as DOM optimizations; all sources are still processed. The dashed drop area accepts files and recursive folders, turns solid with `--accent-muted` during drag, and offers file/folder selection through its button, context menu and keyboard. Controls keep 44px targets and the shared focus ring. Settings and «Подробнее» remain flat native disclosures; the filename search, breadcrumbs, file-type icons and current-file state support scanning. The list and preview sit side by side on desktop and stack on mobile. Download controls stay below the browser, with per-file downloads below each preview.
@@ -560,6 +562,14 @@ Image selection accepts PNG, JPEG, WebP, GIF and AVIF locally, up to 40 MiB. Sam
Processing runs in a lazily loaded local module Worker using Pyodide and full upstream unrpyc/unrpa. Files stay on the device. There are no application quotas on queue size, script size, archive-index size, entry count or ZIP output; device memory determines capacity. Integrity validation remains: restricted pickle globals, safe paths, valid segment ranges and complete compressed streams. Shared archive segments are validated once and reused as immutable tuples. Each RPA produces its own ZIP named from the archive basename (`data.rpa` → `data.zip`), preserving exact internal paths. Extraction retains compiled originals, adds recovered code and preserves existing source files; ZIPs contain no generated report. Standalone scripts share `unrpyc.zip`. Previewing a compiled archive entry also preserves an existing source, matching export behavior. Local Pygments uses the same Ren'Py lexer and `syntax.css` as the resource browser, with whitespace dots, line numbers excluded from selection, and the shared clipboard control. Image, audio (including Opus and FLAC), video and font previews stay local too. Text above 2 MiB remains downloadable without rendering hundreds of thousands of lines; this is only a preview limit, not a processing or export quota. Warning counts expand to show actual diagnostics. Source links and format help remain visible without JavaScript. Soft navigation terminates the worker and releases listeners, font registrations and download URLs. No new design tokens or raster assets are introduced.
+### Character creator
+
+`/tools/characters` is the simple character creator. Only name, dynamic, kind and the who_/what_ color, prefix, suffix (labelled postfix), font, bold, italic, strikethrough and underline properties are editable. Empty names export as None. Daytime game bases come from game/media.rpy; custom kind accepts a character variable and preserves all entered style overrides. Old drafts and imported JSON retain only these properties. The native color chooser and editable HEX support explicit colors; the owner-requested link to /tools/colors opens in a new tab to preserve the draft.
+
+The preview uses the original daytime dialogue_box.png through the game resource route and locally installed Calibri, the original main_font from screens.rpy. Calibri falls back to sans-serif when unavailable; game font binaries are not included in the assets. Other font paths cannot be previewed. The local specimen font alias ES Dialogue loads Calibri. The game's name color #e1dd7d, dialogue color #ffdd7d, black drop shadow, window and font are specimen data rather than UI tokens. Dynamic scrambles the preview name every 100ms; reduced motion shows a single scrambled value. A visible warning explains that name must identify a variable defined in the game, and that scrambling only represents a dynamic value. The original 1572 × 151 dialogue artwork keeps its aspect ratio. A crop of the camp entrance background and four decorative game control images reproduce the original small-font say screen. Name and dialogue positions follow screens.rpy (20/15 and 20/48 relative to the artwork), with 28px text scaled uniformly. Long dialogue scrolls inside the fixed window rather than stretching its border. A zoom toggle enlarges the specimen to at least 960px and allows horizontal scrolling on narrow viewports.
+
+Local draft persistence, undo/redo, JSON import/export, Python quoting, field errors, manual clipboard fallback and .rpy export remain. Soft navigation aborts listeners, clears animation timers and releases download URLs. Arbitrary Python modes, engine/window properties, replay and local background/font uploads are removed from this simple surface.
+
## Do's and Don'ts
### Do:
diff --git a/app/routes/docs.py b/app/routes/docs.py
index 0d2fb7b..f1ed9d1 100644
--- a/app/routes/docs.py
+++ b/app/routes/docs.py
@@ -1,3 +1,4 @@
+import asyncio
import re
from urllib.parse import quote
@@ -89,7 +90,7 @@ async def page(doc, request: Request):
if any(i in request.query_params for i in ['s', 'search']):
return PlainTextResponse(re.sub(r'^(#+ )|(- )|(> )|(```\w*)|(:::\w*)|(---)$', '', text, flags=re.MULTILINE).replace('\n\n', '\n'))
- title, nav, body = render(text)
+ title, nav, body = await asyncio.to_thread(render, text)
if any(i in request.query_params for i in ['r', 'raw']):
return PlainTextResponse(body)
diff --git a/app/routes/res.py b/app/routes/res.py
index 9c4b86e..5a2c101 100644
--- a/app/routes/res.py
+++ b/app/routes/res.py
@@ -89,11 +89,11 @@ async def _ensure_tinted(kind, name):
if not source:
raise HTTPException(404, f'Исходный файл для "{kind} {name}" не существует.')
- if not is_tinted(kind, name, source):
+ if not is_tinted(kind, name, source, item['tint']):
async with compose_lock:
# Re-check: the request holding the lock before us may have
# composed this very image.
- if not is_tinted(kind, name, source):
+ if not is_tinted(kind, name, source, item['tint']):
try:
await asyncio.to_thread(compose_tint, kind, name, source, item['tint'])
except Exception:
diff --git a/app/routes/resources.py b/app/routes/resources.py
index 1a475c3..b37ad5a 100644
--- a/app/routes/resources.py
+++ b/app/routes/resources.py
@@ -1,3 +1,4 @@
+import asyncio
import html
import os
from pathlib import Path
@@ -218,7 +219,7 @@ async def browser(request: Request, path=''):
if target.is_file():
size = target.stat().st_size
kind = FILE_KINDS.get(target.suffix.lower(), 'other')
- view = _file_view(target)
+ view = await asyncio.to_thread(_file_view, target)
sub = [KIND_LABELS[kind], _human_size(size)]
if view['mode'] == 'code':
sub.append(view['lines'])
diff --git a/app/routes/search_discovery.py b/app/routes/search_discovery.py
index b32a9f1..e73c44c 100644
--- a/app/routes/search_discovery.py
+++ b/app/routes/search_discovery.py
@@ -1,9 +1,9 @@
"""Crawler entry points for the public pages of the site."""
-from urllib.parse import quote
+from urllib.parse import quote, urlsplit, urlunsplit
from xml.etree import ElementTree
-from fastapi import Response
+from fastapi import HTTPException, Response
from fastapi.responses import PlainTextResponse
from ..utils.config import CONFIG
@@ -15,17 +15,40 @@
STATIC_PAGES = (
'/', '/docs/', '/authors', '/specialists', '/materials', '/news-resources',
- '/support', '/resources/', '/resources/community', '/tools', '/tools/unpack', '/tools/colors', '/tools/warpers',
+ '/support', '/resources/', '/resources/community', '/tools', '/tools/unpack', '/tools/colors', '/tools/warpers', '/tools/characters',
)
+def _site_origin():
+ value = CONFIG.site_url
+ if not isinstance(value, str) or not value.strip():
+ return None
+ origin = value.strip().rstrip('/')
+ if any(char.isspace() for char in origin):
+ return None
+ try:
+ parsed = urlsplit(origin)
+ if (parsed.scheme not in ('http', 'https') or not parsed.hostname
+ or parsed.username is not None or parsed.password is not None
+ or parsed.path or parsed.query or parsed.fragment):
+ return None
+ parsed.port # Validate the optional port before publishing the URL.
+ return urlunsplit((parsed.scheme, parsed.netloc, '', '', ''))
+ except ValueError:
+ return None
+
+
@main_router.get('/robots.txt', response_class=PlainTextResponse)
async def robots_txt():
- return f'User-agent: *\nAllow: /\n\nSitemap: {CONFIG.site_url}/sitemap.xml\n'
+ origin = _site_origin()
+ return 'User-agent: *\nAllow: /\n' + (f'\nSitemap: {origin}/sitemap.xml\n' if origin else '')
@main_router.get('/sitemap.xml')
async def sitemap_xml():
+ origin = _site_origin()
+ if not origin:
+ raise HTTPException(404, 'Адрес сайта не настроен.')
paths = list(STATIC_PAGES)
paths.extend(f'/docs/{quote(doc["slug"], safe="")}' for doc in CONFIG.search_index)
paths.extend(
@@ -39,7 +62,7 @@ async def sitemap_xml():
root = ElementTree.Element(f'{{{SITEMAP_NS}}}urlset')
for path in dict.fromkeys(paths):
entry = ElementTree.SubElement(root, f'{{{SITEMAP_NS}}}url')
- ElementTree.SubElement(entry, f'{{{SITEMAP_NS}}}loc').text = CONFIG.site_url + path
+ ElementTree.SubElement(entry, f'{{{SITEMAP_NS}}}loc').text = origin + path
return Response(
content=ElementTree.tostring(root, encoding='utf-8', xml_declaration=True),
diff --git a/app/routes/static.py b/app/routes/static.py
index 438af54..7c97bbd 100644
--- a/app/routes/static.py
+++ b/app/routes/static.py
@@ -19,6 +19,19 @@
_VERSIONED_CACHE = 'public, max-age=31536000, immutable'
+def _asset_path(folder: str, name: str):
+ if not name or any(char in name for char in ('/', '\\', ':', '\x00')):
+ raise HTTPException(404, 'Файл не существует.')
+ root = (ROOT / 'static' / folder).resolve()
+ try:
+ path = (root / name).resolve()
+ if path.is_relative_to(root) and path.is_file():
+ return path
+ except (OSError, ValueError):
+ pass
+ raise HTTPException(404, 'Файл не существует.')
+
+
def _text_asset(request: Request, rel: str, media_type: str) -> Response:
try:
body, digest = load_asset(rel)
@@ -44,10 +57,12 @@ async def favicon():
@router.get('/static/css/{name}')
async def css(name, request: Request):
+ _asset_path('css', name)
return _text_asset(request, f'static/css/{name}', 'text/css; charset=utf-8')
@router.get('/static/js/{name}')
async def js(name, request: Request):
+ _asset_path('js', name)
return _text_asset(request, f'static/js/{name}', 'application/javascript; charset=utf-8')
@@ -62,7 +77,7 @@ async def font(name):
# Binary asset: FileResponse, not read_text (which decodes as UTF-8).
# Immutable, content-hashed by weight/subset, so cache hard.
return FileResponse(
- str(ROOT / 'static' / 'fonts' / name),
+ str(_asset_path('fonts', name)),
media_type='font/woff2',
headers={
'Cache-Control': 'public, max-age=31536000, immutable',
diff --git a/app/routes/tools.py b/app/routes/tools.py
index f6a9e5f..e3d4b51 100644
--- a/app/routes/tools.py
+++ b/app/routes/tools.py
@@ -15,7 +15,7 @@
'engine.py': 'text/plain', 'recovery.py': 'text/plain', 'vendor.zip': 'application/zip',
'pyc_decompiler.py': 'text/plain', 'bytecode.zip': 'application/zip',
'syntax.zip': 'application/zip', 'renpy_lexer.py': 'text/plain',
- 'manifest.json': 'application/json', 'NOTICE.txt': 'text/plain',
+ 'manifest.json': 'application/json', 'NOTICE.txt': 'text/plain', 'rpa.py': 'text/plain',
**{f'pyodide/{RUNTIME_VERSION}/{name}': media for name, media in {
'pyodide.mjs': 'application/javascript', 'pyodide.asm.js': 'application/javascript',
'pyodide.asm.wasm': 'application/wasm', 'python_stdlib.zip': 'application/zip',
@@ -50,11 +50,17 @@ async def colors_page(request: Request):
return templates.TemplateResponse(request, 'tools_colors.html', {})
+@main_router.get('/tools/characters')
+async def characters_page(request: Request):
+ return templates.TemplateResponse(request, 'tools_characters.html', {})
+
+
@main_router.get('/tools/unpack')
async def unpack_page(request: Request):
return templates.TemplateResponse(request, 'tools_unpack.html', {
'tools_config': {
'worker': asset_url('/static/js/tools-worker.js'),
+ 'packer': f'/static/tools/rpa.py?v={digest("rpa.py")}',
'engine': f'/static/tools/engine.py?v={digest("engine.py")}',
'recovery': f'/static/tools/recovery.py?v={digest("recovery.py")}',
'pyc': f'/static/tools/pyc_decompiler.py?v={digest("pyc_decompiler.py")}',
diff --git a/app/utils/lifespan/refresh.py b/app/utils/lifespan/refresh.py
index 0d772de..48c129a 100644
--- a/app/utils/lifespan/refresh.py
+++ b/app/utils/lifespan/refresh.py
@@ -105,6 +105,7 @@ def _watchers():
assets / 'nsfw.yaml',
)
community_files = _under(assets / 'community')
+ game_files = _under(res)
materials_yaml = _one_of(assets / 'materials.yaml')
materials_files = _under(assets / 'materials')
@@ -119,7 +120,7 @@ def _watchers():
res / 'scenario' / 'zhenya.rpy',
), _refresh_sprites),
- ('resources', lambda path: resource_sources(path) or community_files(path), _refresh_resources),
+ ('resources', lambda path: resource_sources(path) or community_files(path) or game_files(path), _refresh_resources),
('browser', _under(res), _refresh_browser),
('specialists.yaml', _one_of(assets / 'specialists.yaml'), _refresh_specialists),
diff --git a/app/utils/lifespan/search_cache.py b/app/utils/lifespan/search_cache.py
index 47ecbd5..304d803 100644
--- a/app/utils/lifespan/search_cache.py
+++ b/app/utils/lifespan/search_cache.py
@@ -17,8 +17,9 @@
('Специалисты', '/specialists'),
('Материалы', '/materials'),
('Инструменты', '/tools'),
- ('Распаковщик RPA/RPYC/PYC RenPy / Python / UnRpyc / UnRPA / uncompyle6', '/tools/unpack'),
+ ('Архивация RPA и декомпиляция RPYC/PYC / Упаковщик и распаковщик RPA / RenPy / Python / UnRpyc / UnRPA / uncompyle6', '/tools/unpack'),
('Палитра цветов / Цвет / HEX / RGB / Палитры', '/tools/colors'),
+ ('Создатель простых персонажей / Character / RenPy / Имя / Диалог / Повествование / Стиль текста', '/tools/characters'),
('Варперы / Сглаживание ATL / Тестер формул', '/tools/warpers'),
('Площадки для публикации', '/news-resources'),
('Авторы', '/authors'),
diff --git a/app/utils/lifespan/sprites_cache.py b/app/utils/lifespan/sprites_cache.py
index d2b515d..a00cac2 100644
--- a/app/utils/lifespan/sprites_cache.py
+++ b/app/utils/lifespan/sprites_cache.py
@@ -39,11 +39,18 @@ def sprite_file(name):
return sprites_path() / f'{name}.webp'
def is_composed(name):
- # The disk cache survives restarts; a cached sprite is only stale if
- # a declaration source changed after it was composed.
+ # Both the declaration and every layer contribute to the composed bytes.
path = sprite_file(name)
- newest = max(p.stat().st_mtime for p in _sprite_sources() if p.exists())
- return path.is_file() and path.stat().st_mtime >= newest
+ layers = CONFIG.sprite_layers.get(name)
+ if not path.is_file() or not layers:
+ return False
+ sources = [p for p in _sprite_sources() if p.exists()]
+ sources.extend(CONFIG.res_path / layer for layer in layers)
+ try:
+ newest = max(source.stat().st_mtime_ns for source in sources)
+ return path.stat().st_mtime_ns >= newest
+ except OSError:
+ return False
def compose_sprite(name):
diff --git a/app/utils/lifespan/tint_cache.py b/app/utils/lifespan/tint_cache.py
index c2aa85c..995338f 100644
--- a/app/utils/lifespan/tint_cache.py
+++ b/app/utils/lifespan/tint_cache.py
@@ -1,3 +1,4 @@
+import json
import os, time
from PIL import Image
@@ -12,17 +13,29 @@ def tinted_path():
def tinted_file(kind, name):
return tinted_path() / kind / f'{name}.webp'
-def is_tinted(kind, name, source):
- # The disk cache survives restarts; a cached tint is only stale if the
- # source image changed after it was composed.
+def _signature(source, tint):
+ stat = source.stat()
+ return [str(source.resolve()), stat.st_mtime_ns, stat.st_size,
+ list(tint), CONFIG.setting('images.tint-quality')]
+
+
+def is_tinted(kind, name, source, tint):
+ # Persist every input so changes are detected even across restarts.
path = tinted_file(kind, name)
- return path.is_file() and path.stat().st_mtime >= source.stat().st_mtime
+ if not path.is_file():
+ return False
+ try:
+ saved = json.loads(path.with_suffix('.json').read_text('utf-8'))
+ return saved == _signature(source, tint)
+ except (OSError, ValueError):
+ return False
def compose_tint(kind, name, source, tint):
"""Ren'Py's `im.MatrixColor(file, im.matrix.tint(r, g, b))`: a diagonal
color matrix that scales the R/G/B channels independently and leaves
alpha untouched — exactly a per-channel multiply, no channel mixing."""
r, g, b = tint
+ signature = _signature(source, tint)
starttime = time.time()
@@ -47,6 +60,10 @@ def compose_tint(kind, name, source, tint):
tmp = path.with_suffix('.webp.tmp')
img.save(tmp, 'WEBP', quality=CONFIG.setting('images.tint-quality'))
os.replace(tmp, path)
+ metadata = path.with_suffix('.json')
+ temporary_metadata = metadata.with_suffix('.json.tmp')
+ temporary_metadata.write_text(json.dumps(signature), encoding='utf-8')
+ os.replace(temporary_metadata, metadata)
logger.info(f'Tinted image "{kind} {name}" composed in {time.time() - starttime:.4f}s')
diff --git a/app/utils/md/banner.py b/app/utils/md/banner.py
index ccb9f9b..867f631 100644
--- a/app/utils/md/banner.py
+++ b/app/utils/md/banner.py
@@ -35,6 +35,20 @@ def render_banner_open(self, tokens, idx, options, env):
def render_banner_close(self, tokens, idx, options, env):
return ''
+def banner_end(state: StateBlock, startLine: int, endLine: int):
+ """Optional bounded note body and the line where parsing resumes."""
+ nextLine = startLine + 1
+ while nextLine < endLine:
+ pos = state.bMarks[nextLine] + state.tShift[nextLine]
+ line = state.src[pos:state.eMarks[nextLine]].strip()
+ if line == ':::':
+ return nextLine, nextLine + 1
+ if not line or line.startswith(':::'):
+ break
+ nextLine += 1
+ return startLine + 1, startLine + 1
+
+
def banner():
# `:::stub`, or `:::stub <короткая приписка>` on the same line. A multi-line
# note may follow, closed by `:::` — but only while the lines run on without
@@ -62,26 +76,7 @@ def block(state: StateBlock, startLine: int, endLine: int, silent: bool):
# Find the optional note body. bodyEnd stays at startLine + 1 for the
# bare single-line form; nextLine is where parsing resumes afterwards.
- bodyEnd = nextLine = startLine + 1
-
- while nextLine < endLine:
- pos = state.bMarks[nextLine] + state.tShift[nextLine]
- line = state.src[pos:state.eMarks[nextLine]].strip()
-
- if line == ':::':
- bodyEnd = nextLine
- nextLine += 1
- break
-
- # Blank line, or the start of some other block — the banner was
- # written in its bare form and this belongs to the document.
- if not line or line.startswith(':::'):
- bodyEnd = nextLine = startLine + 1
- break
-
- nextLine += 1
- else:
- bodyEnd = nextLine = startLine + 1
+ bodyEnd, nextLine = banner_end(state, startLine, endLine)
token = state.push('banner_open', 'aside', 1)
token.meta = {'kind': name}
diff --git a/app/utils/md/containers.py b/app/utils/md/containers.py
index 82d482b..34a47d0 100644
--- a/app/utils/md/containers.py
+++ b/app/utils/md/containers.py
@@ -22,8 +22,7 @@
Banners are a deliberate exception on the *inside* — they keep their own
bounded scan (banner.py), because a banner is one or two sentences of page
status and should never grow into a container. They are still counted here,
-because from the outside a banner opens and closes with the same markers as
-everything else.
+with the same bounded rule here, so a standalone banner consumes no closer.
"""
import re
@@ -37,6 +36,7 @@
# without the lead text that follows on the same line. Only the name matters
# here — what a block does with its lead is the parser's own business.
_OPEN_RE = re.compile(r'^:::[ \t]*([A-Za-z][\w-]*)')
+_FENCE_RE = re.compile(r'^(`{3,}|~{3,})(.*)$')
CLOSER = ':::'
@@ -84,18 +84,31 @@ def find_closer(state, startLine: int, endLine: int) -> int | None:
"""
depth = 0
line = startLine + 1
+ fence = None
while line < endLine:
# bMarks + tShift skips the indent, so a closer indented inside a list
# item still reads as a closer — which the corpus relies on.
text = state.src[state.bMarks[line] + state.tShift[line]:state.eMarks[line]].strip()
- if text == CLOSER:
+ marker = _FENCE_RE.match(text)
+ if fence:
+ if (marker and marker[1][0] == fence[0]
+ and len(marker[1]) >= len(fence) and not marker[2].strip()):
+ fence = None
+ elif marker and (marker[1][0] != '`' or '`' not in marker[2]):
+ fence = marker[1]
+ elif text == CLOSER:
if depth == 0:
return line
depth -= 1
- elif opener_name(text):
- depth += 1
+ elif name := opener_name(text):
+ if name in _NAMES:
+ depth += 1
+ else:
+ from .banner import banner_end
+ _, next_line = banner_end(state, line, endLine)
+ line = next_line - 1
line += 1
diff --git a/scripts/prepare_character_catalog.py b/scripts/prepare_character_catalog.py
new file mode 100644
index 0000000..e8f345a
--- /dev/null
+++ b/scripts/prepare_character_catalog.py
@@ -0,0 +1,32 @@
+"""Refresh the simple creator's base characters from the original media.rpy.
+
+Run: python scripts/prepare_character_catalog.py /path/to/game/media.rpy
+The constructor's small property whitelist is maintained in characters-core.js.
+"""
+import json
+import re
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+LABELS = {'voice': 'Голос', 'me': 'Семён', 'my': 'Я', 'narrator': 'Повествование', 'th': 'Мысли', 'el': 'Электроник', 'elp': 'elp', 'ro': 'Роутер', 'un': 'Лена', 'unp': 'unp', 'dv': 'Алиса', 'dvp': 'dvp', 'dvg': 'dvg', 'sl': 'Славя', 'slp': 'slp', 'slg': 'slg', 'sa': 'Саша', 'us': 'Ульяна', 'usp': 'usp', 'usg': 'usg', 'mt': 'Ольга Дмитриевна', 'mtp': 'mtp', 'cs': 'Виола', 'csp': 'csp', 'mz': 'Женя', 'mzp': 'mzp', 'mi': 'Мику', 'mip': 'mip', 'ma': 'Маша', 'uv': 'Юля', 'uvp': 'uvp', 'sh': 'Шурик', 'shp': 'shp', 'pi': 'Пионер', 'all': 'Все', 'dreamgirl': 'dreamgirl', 'bush': 'bush', 'FIXME_voice': 'FIXME_voice', 'odn': 'odn', 'message': 'message', 'mt_voice': 'mt_voice'}
+
+
+def generate(source):
+ colors = {}
+ for key, rgba in re.findall(r"colors\[\s*'([^']+)'\].*?'day':\s*\(([^)]+)\)", source):
+ colors[key] = '#' + ''.join(f'{int(channel.strip()):02x}' for channel in rgba.split(',')[:3])
+ keys = list(dict.fromkeys(re.findall(r"names_list.append\('([^']+)'\)", source)))
+ if not keys or 'me' not in keys or 'narrator' not in keys:
+ raise ValueError('Incomplete game character definitions')
+ bases = [{'key': key, 'label': LABELS.get(key, key), 'color': colors.get(key, '#e1dd7d')} for key in keys]
+ output = '/* Daytime base characters from game/media.rpy. */\n'
+ output += 'window.ESDocCharacterCatalog = ' + json.dumps({'bases': bases}, ensure_ascii=False, indent=2) + ';\n'
+ (ROOT / 'static/js/characters-catalog.js').write_text(output, 'utf-8')
+ print(f'{len(bases)} game bases written')
+
+
+if __name__ == '__main__':
+ if len(sys.argv) != 2:
+ raise SystemExit('Pass the path to game/media.rpy')
+ generate(Path(sys.argv[1]).read_text('utf-8'))
diff --git a/static/css/characters.css b/static/css/characters.css
new file mode 100644
index 0000000..102b620
--- /dev/null
+++ b/static/css/characters.css
@@ -0,0 +1,116 @@
+.characters-page .page-main { max-width: 78rem; }
+.characters-page [hidden] { display: none !important; }
+.characters-page .page-main a { color: var(--accent); text-decoration-color: color-mix(in srgb, var(--accent) 45%, transparent); text-underline-offset: 3px; }
+.characters-page .page-main a:hover { color: var(--accent-hover); text-decoration-color: currentColor; }
+.characters-page :is(input, textarea, select, button) { font: inherit; font-family: var(--font-sans); }
+.characters-page :is(input:not([type="checkbox"]):not([type="color"]), select, textarea) { box-sizing: border-box; min-width: 0; width: 100%; min-height: 44px; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--input-bg); color: var(--text); font-size: var(--fs-ui); }
+.characters-page :is(input, textarea, select)::placeholder { color: var(--text-soft); opacity: 1; }
+.characters-page :is(input, textarea, button, select, summary, h2):focus-visible { outline: var(--focus-ring); outline-offset: 3px; }
+.characters-page [aria-invalid="true"] { border-color: var(--danger) !important; }
+.character-toolbar, .character-section-head, .character-actions, .character-project-actions { display: flex; align-items: center; gap: 8px; }
+.character-toolbar { justify-content: space-between; gap: 24px; margin-bottom: 26px; }
+.character-toolbar p { margin: 0; color: var(--text-soft); max-width: 62ch; font-size: var(--fs-ui); }
+.character-button, .character-icon { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); background: var(--bg); cursor: pointer; font-size: var(--fs-ui) !important; }
+.character-icon { padding: 10px; min-width: 44px; border-color: transparent; }
+.character-button:hover:enabled, .character-icon:hover:enabled { color: var(--accent); background: var(--bg-light); }
+.character-button:disabled, .character-icon:disabled { opacity: .5; cursor: default; }
+.character-primary { color: var(--bg); background: var(--accent); border-color: var(--accent); }
+.character-primary:hover:enabled { color: var(--bg); background: var(--accent-hover); border-color: var(--accent-hover); }
+.character-workbench { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.15fr); align-items: start; gap: 40px; }
+.character-settings, .character-result { min-width: 0; }
+.character-result { position: sticky; top: 132px; }
+.character-settings h2, .character-section-head h2 { margin: 0; font: 600 1rem/1.4 var(--font-sans); }
+.character-settings h2 { margin-bottom: 18px; }
+.character-identity, .character-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 12px; }
+.character-identity { margin-bottom: 16px; }
+.character-field { display: grid; align-content: start; gap: 6px; min-width: 0; }
+.character-wide, .character-parameter { grid-column: 1 / -1; }
+.character-field label, .character-preview-controls label, .character-property-picker label { font-size: var(--fs-ui); }
+.character-field-key { font: var(--fs-ui)/1.4 var(--font-mono); color: var(--text-soft); overflow-wrap: anywhere; }
+.character-field-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+.character-enable { display: inline-flex; align-items: center; gap: 6px; color: var(--text-soft); font-size: var(--fs-ui) !important; min-height: 44px; cursor: pointer; }
+.character-enable input { margin: 0; width: 16px; height: 16px; }
+.character-field-value { display: flex; align-items: center; gap: 6px; min-width: 0; }
+.character-value { flex: 1; min-width: 0; }
+.character-mode { width: 100% !important; }
+.character-field-help { font-size: var(--fs-ui); line-height: 1.5; margin: 0; color: var(--text-soft); overflow-wrap: anywhere; }
+.character-field-options { min-width: 0; font-size: var(--fs-ui); }
+.character-field-options > summary { min-height: 44px; align-content: center; color: var(--text-soft); cursor: pointer; }
+.character-field-options[open] { display: grid; gap: 8px; padding-bottom: 8px; }
+.character-color { flex: none; width: 44px; height: 44px; padding: 4px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--input-bg); cursor: pointer; }
+#character-quick-fields > .character-field:last-child { grid-column: 1 / -1; }
+#character-identity-fields { margin-top: 18px; }
+.character-usage { padding: 12px; overflow: auto; background: var(--code-bg); font: var(--fs-ui)/1.6 var(--font-mono); border-radius: var(--radius-sm); }
+.character-disclosure { border-top: 1px solid var(--border); margin-top: 24px; }
+.character-disclosure > summary { padding: 16px 0; font-weight: 600; cursor: pointer; font-size: var(--fs-ui); }
+.character-disclosure > summary > span { font: 12px/1.5 var(--font-mono); color: var(--text-soft); margin-left: 8px; }
+.character-disclosure > .character-fields { padding: 4px 0 8px; }
+.character-help { font-size: var(--fs-ui); line-height: 1.6; color: var(--text-soft); margin: 12px 0; }
+.character-error { color: var(--danger); font-size: var(--fs-ui); line-height: 1.5; margin: 0; overflow-wrap: anywhere; }
+.character-section-head { justify-content: space-between; margin-bottom: 12px; flex-wrap: wrap; }
+@font-face { font-family: "ES Dialogue"; src: local("Calibri"); font-weight: 400; }
+@font-face { font-family: "ES Dialogue"; src: local("Calibri Bold"); font-weight: 700; }
+@font-face { font-family: "ES Dialogue"; src: local("Calibri Italic"); font-style: italic; }
+@font-face { font-family: "ES Dialogue"; src: local("Calibri Bold Italic"); font-style: italic; font-weight: 700; }
+.character-preview-viewport { overflow-x: auto; overscroll-behavior-x: contain; border-radius: var(--radius-sm); }
+.character-stage { position: relative; width: 100%; aspect-ratio: 1572 / 151; container-type: inline-size; background: #253846 url('/resource/raw/images/bg/ext_camp_entrance_day.jpg') 50% 98.600646% / 122.137405% auto no-repeat; }
+.character-preview-viewport.is-zoomed .character-stage { width: max(100%, 960px); }
+.character-dialogue-window { position: absolute; inset: 0; background: url('/resource/raw/images/gui/dialogue_box/day/dialogue_box.png') center / 100% 100% no-repeat; color: #ffdd7d; font-family: "ES Dialogue", Calibri, sans-serif; }
+.character-namebox { position: absolute; left: 1.272265%; top: 9.933775%; width: 83%; }
+#character-preview-who, #character-preview-what { font-size: 1.78117cqw; font-weight: 400; line-height: 1.071429; text-shadow: .127226cqw .127226cqw #000; white-space: pre-wrap; overflow-wrap: anywhere; }
+#character-preview-who { color: #e1dd7d; white-space: pre; overflow: hidden; }
+#character-preview-what { position: absolute; left: 1.272265%; top: 31.78808%; width: 98.02799%; bottom: 2.649007%; overflow: auto; scrollbar-width: thin; }
+.character-dialogue-controls { position: absolute; inset: 0; pointer-events: none; }
+.character-dialogue-controls img { position: absolute; top: 11.258278%; width: 1.717557%; height: auto; }
+.character-dialogue-controls img:nth-child(1) { left: 84.860051%; }
+.character-dialogue-controls img:nth-child(2) { left: 88.613232%; }
+.character-dialogue-controls img:nth-child(3) { left: 92.302799%; }
+.character-dialogue-controls img:nth-child(4) { left: 95.928753%; }
+.character-sample { margin-top: 18px; }
+.character-sample textarea { resize: vertical; min-height: 76px; }
+.character-preview-options { margin-top: 12px; font-size: var(--fs-ui); }
+.character-preview-options > summary, .character-custom-disclosure > summary { min-height: 44px; display: list-item; align-content: center; cursor: pointer; color: var(--text-soft); }
+.character-preview-options ul { margin: 8px 0 0; padding-left: 22px; }
+.character-preview-options li { overflow-wrap: anywhere; }
+.character-preview-controls { display: grid; gap: 8px; margin: 8px 0 16px; }
+.character-preview-controls input[type="color"] { min-width: 64px; height: 44px; border: 1px solid var(--border); padding: 4px; background: var(--bg); }
+.character-code-section { margin-top: 28px; }
+.characters-page .character-code { font: var(--fs-ui)/1.65 var(--font-mono); background: var(--code-bg); min-height: 100px; max-height: 400px; resize: vertical; tab-size: 4; }
+.character-status { min-height: 1.5em; font-size: var(--fs-ui); color: var(--text-soft); margin: 8px 0; }
+.character-project-actions { flex-wrap: wrap; margin-top: 16px; }
+.character-docs { display: flex; flex-wrap: wrap; gap: 8px 18px; font-size: var(--fs-ui); margin-top: 20px; }
+.character-property-picker { display: grid; gap: 8px; padding: 8px 0 16px; }
+.character-property-picker select { text-overflow: ellipsis; }
+.character-custom-disclosure { margin-bottom: 18px; }
+.character-custom-add { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
+.character-custom-add input { flex: 1; }
+.character-custom-add button { flex: none; }
+.character-parameter { border-top: 1px solid var(--border); padding-top: 16px; }
+.character-parameter .character-field-head > label { overflow-wrap: anywhere; }
+.character-jumps { display: flex; gap: 8px; position: sticky; top: 64px; z-index: 5; background: var(--bg); padding: 8px 0; margin-bottom: 16px; border-bottom: 1px solid var(--border); }
+.character-jumps button { min-height: 44px; padding: 8px 12px; background: var(--bg-light); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--fs-ui); cursor: pointer; }
+.character-jumps button:hover { color: var(--accent); }
+.characters-page :is(h2, .character-field input, .character-field select) { scroll-margin-top: 140px; }
+@media (max-width: 960px) {
+ .character-workbench { gap: 24px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
+ .character-fields { grid-template-columns: 1fr; }
+}
+@media (max-width: 720px) {
+ .character-toolbar { align-items: flex-start; flex-direction: column; gap: 12px; }
+ .character-workbench { display: flex; flex-direction: column; gap: 32px; }
+ .character-settings, .character-result { width: 100%; }
+ .character-result { position: static; }
+ .character-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .character-field-value { flex-wrap: wrap; }
+ .character-field-value { flex-wrap: nowrap; }
+ .character-parameter .character-field-head { flex-wrap: wrap; }
+ .character-enable { min-height: 44px; }
+ .characters-page :is(input:not([type="checkbox"]), textarea, select) { font-size: 1rem; }
+ .character-jumps { position: fixed; top: auto; bottom: 0; left: 0; right: 0; margin: 0; padding: 8px 12px calc(8px + env(safe-area-inset-bottom)); border-top: 1px solid var(--border); border-bottom: 0; }
+ .character-jumps button { flex: 1; padding: 8px 4px; }
+ .characters-page .page-main { padding-bottom: 88px; }
+ .characters-page :is(h2, .character-field input, .character-field select) { scroll-margin-top: 80px; scroll-margin-bottom: 88px; }
+}
+@media (prefers-reduced-motion: no-preference) {
+ .character-button, .character-icon { transition: background-color 150ms ease-out, color 150ms ease-out; }
+}
diff --git a/static/css/colors.css b/static/css/colors.css
index 48b8dfc..07a52b6 100644
--- a/static/css/colors.css
+++ b/static/css/colors.css
@@ -15,6 +15,10 @@
.picker-current span { display: block; width: 100%; height: 100%; }
.picker-button, .picker-icon { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; flex: none; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); font: inherit; font-size: var(--fs-ui); cursor: pointer; padding: 8px 12px; }
.picker-icon { width: 44px; border-color: transparent; padding: 10px; color: var(--text-soft); }
+.picker-eyedropper-warning { position: relative; display: inline-flex; flex: none; }
+.picker-eyedropper-warning .picker-icon { width: 32px; padding: 6px; color: var(--warning); }
+.picker-eyedropper-tooltip { position: absolute; top: 100%; right: 0; z-index: 10; width: min(300px, calc(100vw - 48px)); padding-top: 8px; box-sizing: border-box; transform: translateX(var(--tooltip-shift, 0px)); }
+.picker-eyedropper-tooltip > span { display: block; padding: 12px 16px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); font-size: var(--fs-ui); line-height: 1.5; overflow-wrap: anywhere; }
.picker-button:hover:enabled, .picker-icon:hover:enabled { color: var(--accent); background: var(--bg-light); }
.picker-primary { background: var(--accent); border-color: var(--accent); color: var(--bg); }
.picker-primary:hover:enabled { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--bg); }
@@ -72,6 +76,8 @@
.picker-shortcuts p { color: var(--text-soft); margin: 12px 0; }
.picker-shortcuts .picker-section-head { margin-bottom: 20px; }
.picker-eyedropper-overlay { position: fixed; inset: 0; z-index: 10000; background: rgb(0 0 0 / .6); cursor: crosshair; }
+.picker-eyedropper-overlay-capture { background: var(--bg); }
+.picker-screen-canvas { position: absolute; top: 16px; left: 50%; transform: translateX(-50%); max-width: calc(100% - 32px); max-height: calc(100% - 220px); width: auto; height: auto; }
.picker-eyedropper-help { position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); width: min(360px, calc(100% - 32px)); box-sizing: border-box; padding: 20px 24px; border-radius: var(--radius-block); background: var(--bg); color: var(--text); box-shadow: 0 8px 32px #0004; }
.picker-eyedropper-help h2 { display: flex; align-items: center; gap: 8px; margin: 0 0 14px; font-size: var(--fs-body); }
.picker-eyedropper-help p { display: flex; align-items: center; gap: 16px; margin: 8px 0; font-size: var(--fs-ui); }
diff --git a/static/css/tools.css b/static/css/tools.css
index db93d21..1bb5370 100644
--- a/static/css/tools.css
+++ b/static/css/tools.css
@@ -1,17 +1,68 @@
.tools-page .page-main { max-width: 66rem; }
.tools-heading h2, .tools-links h2, #site-tools-heading { font-family: var(--font-serif); font-size: var(--fs-subtitle); font-weight: 700; margin: 0 0 8px; }
.tools-heading p { color: var(--text-soft); margin: 0 0 24px; }
+.tools-mode { display: flex; flex-wrap: wrap; gap: 8px 24px; border: 0; padding: 0; margin: 0 0 20px; font-size: var(--fs-ui); }
+.tools-mode label { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; cursor: pointer; }
+.tools-mode label:has(:checked) { font-weight: 600; }
+.tools-pack-settings { margin-block: 20px 24px; }
+.tools-pack-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-bottom: 20px; }
+.tools-pack-settings label { display: grid; gap: 8px; font-size: var(--fs-ui); font-weight: 600; min-width: 0; }
+.tools-pack-settings :is(input, select) { width: 100%; min-width: 0; min-height: 44px; padding: 8px 10px; border: 1px solid var(--text-soft); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); font: inherit; font-weight: 400; caret-color: var(--accent); }
+.tools-pack-fields input::placeholder { color: var(--text-soft); opacity: 1; }
+.tools-pack-fields [aria-invalid="true"] { border-color: var(--danger); }
+@media (max-width: 700px) { .tools-pack-fields { grid-template-columns: minmax(0, 1fr); } }
+.tools-pack-name-field { max-width: 34rem; margin-bottom: 28px; }
+.tools-pack-settings .tools-meta { margin-top: 8px; }
+.tools-pack-path-settings { border: 0; padding: 0; margin: 0; min-width: 0; }
+.tools-pack-path-settings legend { font-size: var(--fs-body); font-weight: 600; padding: 0; }
+.tools-pack-path-settings > p { margin: 8px 0 20px; }
+.tools-pack-example { background: var(--bg-light); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 16px; }
+.tools-pack-example p { margin: 0; }
+.tools-pack-path-comparison { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin: 16px 0 0; }
+.tools-pack-path-comparison dt { color: var(--text-soft); font-size: var(--fs-ui); margin-bottom: 6px; }
+.tools-pack-path-comparison dd { margin: 0; overflow-wrap: anywhere; }
+#tools-app[data-mode="pack"] .tools-export { flex-direction: column; align-items: flex-start; }
+.tools-pack-notice { margin-bottom: 4px; max-width: 70ch; font-size: var(--fs-ui); }
+.tools-pack-notice p { margin: 0 0 8px; }
+.tools-pack-integration { margin-top: 32px; max-width: 70ch; }
+.tools-pack-integration h3 { font-size: var(--fs-body); margin: 0 0 12px; }
+.tools-pack-integration p { margin: 12px 0; }
+.tools-pack-integration a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
+.tools-pack-install-code pre { font-size: var(--fs-ui); margin: 12px 0; }
+.tools-pack-notice a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
+.tools-pack-path-comparison code { white-space: normal; overflow-wrap: anywhere; }
+#tools-app[data-mode="pack"] .tools-clear { width: auto; gap: 8px; padding-inline: 8px; font: inherit; font-size: var(--fs-ui); }
+@media (max-width: 700px) { .tools-pack-path-comparison { grid-template-columns: minmax(0, 1fr); } }
.tools-drop { border: 1px dashed var(--border); padding: 30px 20px 22px; text-align: center; background: var(--bg-light); border-radius: var(--radius-sm); }
.tools-drop.is-dragging { border-color: var(--accent); border-style: solid; background: var(--accent-muted); }
.tools-drop-icon { color: var(--text-soft); margin-bottom: 10px; }
.tools-drop-title { margin: 0 0 6px; font-size: var(--fs-body); font-weight: 600; }
.tools-drop p:not(.tools-drop-title) { color: var(--text-soft); font-size: var(--fs-ui); }
+.tools-drop.is-compact { display: flex; align-items: center; justify-content: space-between; gap: 12px 20px; padding: 12px 16px; text-align: start; }
+.tools-drop.is-compact .tools-drop-icon { display: none; }
+.tools-drop.is-compact .tools-drop-title { margin: 0; color: var(--text-soft); font-size: var(--fs-ui); font-weight: 400; }
+.tools-drop.is-compact .tools-button { flex: none; }
+.tools-export-dialog { width: min(42rem, calc(100% - 32px)); max-height: calc(100dvh - 32px); box-sizing: border-box; padding: 24px; border: 0; border-radius: var(--radius-block); background: var(--bg-elevated); color: var(--text); box-shadow: var(--shadow-modal); overflow-y: auto; scrollbar-color: var(--border) var(--bg-elevated); }
+.tools-export-dialog::backdrop { background: var(--scrim); }
+html:has(.tools-export-dialog[open]) { overflow: hidden; }
+.tools-dialog-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
+.tools-dialog-heading h2 { margin: 0; font-family: var(--font-serif); font-size: var(--fs-subtitle); }
+.tools-dialog-close { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 44px; height: 44px; padding: 0; border: 0; border-radius: var(--radius-sm); background: none; color: var(--text-soft); cursor: pointer; }
+.tools-dialog-close:hover { color: var(--text); background: var(--bg-light); }
+.tools-export-dialog .tools-status { margin: 0 0 16px; }
+.tools-export-dialog .tools-status:empty { display: none; }
+.tools-export-dialog .tools-result-meta { margin-block: 16px; font-size: var(--fs-ui); }
+.tools-dialog-downloads { display: flex; align-items: flex-start; flex-direction: column; gap: 12px; margin-top: 20px; }
+.tools-dialog-downloads:not(:has(> :not([hidden]))) { display: none; }
+.tools-dialog-downloads .tools-meta { margin: 0; overflow-wrap: anywhere; }
+.tools-export-dialog .tools-warnings[open] > ul { position: static; width: auto; max-width: 100%; margin-top: 12px; }
+.tools-export-dialog .tools-pack-integration { padding-top: 24px; border-top: 1px solid var(--border); }
.tools-button { display: inline-flex; justify-content: center; align-items: center; gap: 8px; min-height: 44px; padding: 10px 18px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); font: inherit; font-size: var(--fs-ui); font-weight: 600; cursor: pointer; text-decoration: none; }
.tools-button:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; }
.tools-button-primary { background: var(--accent); border-color: var(--accent); color: var(--bg); }
.tools-button-primary:hover { background: var(--accent-hover); color: var(--bg); }
.tools-button:disabled { background: var(--bg-light); color: var(--text-soft); border-color: var(--border); cursor: default; }
-.tools-page :is(button, a, input, summary, .tools-drop):focus-visible { outline: var(--focus-ring); outline-offset: 3px; }
+.tools-page :is(button, a, input, select, summary, .tools-drop):focus-visible { outline: var(--focus-ring); outline-offset: 3px; }
.tools-add-menu { position: fixed; z-index: 110; min-width: 210px; padding: 6px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-sm); }
.tools-add-menu button { display: block; width: 100%; min-height: 44px; padding: 10px 14px; background: none; border: 0; text-align: start; color: var(--text); font: inherit; font-size: var(--fs-ui); cursor: pointer; }
.tools-add-menu button:hover { background: var(--bg-light); }
@@ -31,6 +82,11 @@
.tools-queue-heading { display: flex; justify-content: space-between; gap: 12px; align-items: center; }
.tools-queue-summary { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 16px; min-width: 0; }
.tools-queue-summary .tools-meta { margin: 0; }
+.tools-queue-tools { margin-top: 16px; }
+.tools-queue-tools > label { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; font-size: var(--fs-ui); font-weight: 600; }
+.tools-browser-search.tools-queue-search { border-color: var(--text-soft); }
+.tools-queue-navigation, .tools-queue-pages { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; }
+.tools-queue-navigation { justify-content: space-between; margin-top: 12px; }
.tools-clear { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 44px; height: 44px; padding: 0; border: 0; border-radius: var(--radius-sm); background: none; color: var(--text-soft); cursor: pointer; }
.tools-clear:hover:enabled { color: var(--danger); background: var(--bg-light); }
.tools-clear:disabled { opacity: 0.5; cursor: default; }
@@ -41,6 +97,7 @@
.tools-file-list li { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 12px; min-height: 52px; padding: 6px 4px; }
.tools-file-list li + li { border-top: 1px solid var(--border); }
.tools-file-name { overflow-wrap: anywhere; font-size: var(--fs-ui); }
+.tools-pack-path { display: block; padding-block: 6px; color: var(--text); }
.tools-file-size { font-size: var(--fs-ui); color: var(--text-soft); font-variant-numeric: tabular-nums; }
.tools-file-state { display: block; color: var(--text-soft); font-size: var(--fs-ui); }
.tools-file-state.is-error { color: var(--danger); }
@@ -161,6 +218,9 @@
.tools-page [hidden] { display: none !important; }
@media (max-width: 640px) {
.tools-drop { padding: 24px 14px 20px; }
+ .tools-drop.is-compact { flex-direction: column; align-items: stretch; padding: 12px 14px; text-align: center; }
+ .tools-export-dialog { padding: 20px; }
+ .tools-dialog-downloads > .tools-button, .tools-dialog-downloads .tools-downloads, .tools-dialog-downloads .tools-downloads > a { width: 100%; }
.tools-link-list a { grid-template-columns: 1fr auto; gap: 4px 12px; }
.tools-link-list a span:nth-child(2) { grid-column: 1; }
.tools-link-list a span:last-child { grid-column: 2; grid-row: 1 / 3; }
diff --git a/static/js/characters-catalog.js b/static/js/characters-catalog.js
new file mode 100644
index 0000000..40db09f
--- /dev/null
+++ b/static/js/characters-catalog.js
@@ -0,0 +1,210 @@
+/* Daytime base characters from game/media.rpy. */
+window.ESDocCharacterCatalog = {
+ "bases": [
+ {
+ "key": "voice",
+ "label": "Голос",
+ "color": "#e1dd7d"
+ },
+ {
+ "key": "me",
+ "label": "Семён",
+ "color": "#e1dd7d"
+ },
+ {
+ "key": "my",
+ "label": "Я",
+ "color": "#e1dd7d"
+ },
+ {
+ "key": "narrator",
+ "label": "Повествование",
+ "color": "#e1dd7d"
+ },
+ {
+ "key": "th",
+ "label": "Мысли",
+ "color": "#e1dd7d"
+ },
+ {
+ "key": "el",
+ "label": "Электроник",
+ "color": "#ffff00"
+ },
+ {
+ "key": "elp",
+ "label": "elp",
+ "color": "#ffff00"
+ },
+ {
+ "key": "ro",
+ "label": "Роутер",
+ "color": "#ffff00"
+ },
+ {
+ "key": "un",
+ "label": "Лена",
+ "color": "#b956ff"
+ },
+ {
+ "key": "unp",
+ "label": "unp",
+ "color": "#b956ff"
+ },
+ {
+ "key": "dv",
+ "label": "Алиса",
+ "color": "#ffaa00"
+ },
+ {
+ "key": "dvp",
+ "label": "dvp",
+ "color": "#ffaa00"
+ },
+ {
+ "key": "dvg",
+ "label": "dvg",
+ "color": "#ffaa00"
+ },
+ {
+ "key": "sl",
+ "label": "Славя",
+ "color": "#ffd200"
+ },
+ {
+ "key": "slp",
+ "label": "slp",
+ "color": "#ffd200"
+ },
+ {
+ "key": "slg",
+ "label": "slg",
+ "color": "#ffd200"
+ },
+ {
+ "key": "sa",
+ "label": "Саша",
+ "color": "#ffd200"
+ },
+ {
+ "key": "us",
+ "label": "Ульяна",
+ "color": "#ff3200"
+ },
+ {
+ "key": "usp",
+ "label": "usp",
+ "color": "#ff3200"
+ },
+ {
+ "key": "usg",
+ "label": "usg",
+ "color": "#ff3200"
+ },
+ {
+ "key": "mt",
+ "label": "Ольга Дмитриевна",
+ "color": "#00ea32"
+ },
+ {
+ "key": "mtp",
+ "label": "mtp",
+ "color": "#00ea32"
+ },
+ {
+ "key": "cs",
+ "label": "Виола",
+ "color": "#a5a5ff"
+ },
+ {
+ "key": "csp",
+ "label": "csp",
+ "color": "#a5a5ff"
+ },
+ {
+ "key": "mz",
+ "label": "Женя",
+ "color": "#4a86ff"
+ },
+ {
+ "key": "mzp",
+ "label": "mzp",
+ "color": "#4a86ff"
+ },
+ {
+ "key": "mi",
+ "label": "Мику",
+ "color": "#00deff"
+ },
+ {
+ "key": "mip",
+ "label": "mip",
+ "color": "#00deff"
+ },
+ {
+ "key": "ma",
+ "label": "Маша",
+ "color": "#00deff"
+ },
+ {
+ "key": "uv",
+ "label": "Юля",
+ "color": "#4eff00"
+ },
+ {
+ "key": "uvp",
+ "label": "uvp",
+ "color": "#4eff00"
+ },
+ {
+ "key": "sh",
+ "label": "Шурик",
+ "color": "#fff226"
+ },
+ {
+ "key": "shp",
+ "label": "shp",
+ "color": "#fff226"
+ },
+ {
+ "key": "pi",
+ "label": "Пионер",
+ "color": "#e60101"
+ },
+ {
+ "key": "all",
+ "label": "Все",
+ "color": "#ed4444"
+ },
+ {
+ "key": "dreamgirl",
+ "label": "dreamgirl",
+ "color": "#c0c0c0"
+ },
+ {
+ "key": "bush",
+ "label": "bush",
+ "color": "#c0c0c0"
+ },
+ {
+ "key": "FIXME_voice",
+ "label": "FIXME_voice",
+ "color": "#c0c0c0"
+ },
+ {
+ "key": "odn",
+ "label": "odn",
+ "color": "#c0c0c0"
+ },
+ {
+ "key": "message",
+ "label": "message",
+ "color": "#c0c0c0"
+ },
+ {
+ "key": "mt_voice",
+ "label": "mt_voice",
+ "color": "#00ea32"
+ }
+ ]
+};
diff --git a/static/js/characters-core.js b/static/js/characters-core.js
new file mode 100644
index 0000000..166b284
--- /dev/null
+++ b/static/js/characters-core.js
@@ -0,0 +1,146 @@
+/* Typed Character export. User Python is stored as text, never executed. */
+(function () {
+ 'use strict';
+ const args = [
+ {key:'kind', label:'Основа персонажа', mode:'expression', group:'identity'},
+ {key:'dynamic', label:'Динамическое имя', mode:'boolean', group:'identity'},
+ ];
+ const labels = {color:'Цвет', prefix:'Префикс', suffix:'Постфикс', font:'Шрифт', bold:'Полужирный', italic:'Курсив', strikethrough:'Перечёркивание', underline:'Подчёркивание'};
+ const fields = [...args];
+ for (const group of ['who','what']) for (const [prop,label] of Object.entries(labels)) {
+ fields.push({key:group + '_' + prop, label, group, mode:['bold','italic','strikethrough','underline'].includes(prop) ? 'boolean' : 'text'});
+ }
+ const byKey = new Map(fields.map(item => [item.key,item]));
+ function simpleState(state) {
+ const values = Object.create(null);
+ for (const [key,entry] of Object.entries(state.values || {})) {
+ const field = byKey.get(key);
+ if (field && entry.mode === field.mode && (field.mode !== 'boolean' || ['True','False'].includes(entry.value))) values[key] = {...entry};
+ }
+ if (!values.dynamic) values.dynamic = {mode:'boolean',value:'False'};
+ return {...state, name:state.nameMode === 'none' ? '' : state.name, nameMode:'text', values};
+ }
+ const keywords = new Set('False None True and as assert async await break class continue def del elif else except finally for from global if import in is lambda nonlocal not or pass raise return try while with yield match case'.split(' '));
+ const identifier = value => typeof value === 'string' && /^[\p{ID_Start}_][\p{ID_Continue}_]*$/u.test(value) && !keywords.has(value);
+ const quote = value => JSON.stringify(String(value)).replace(/\u2028/g,'\\u2028').replace(/\u2029/g,'\\u2029');
+
+ // This is a structural check, not a Python interpreter or full parser.
+ function expression(value) {
+ const source = value.trim();
+ if (!source || source.length > 20000) throw new Error('Введите Python-выражение (до 20 000 символов).');
+ const stack = []; let string = '', escaped = false;
+ for (const ch of source) {
+ if (string) {
+ if (escaped) escaped = false;
+ else if (ch === '\\') escaped = true;
+ else if (ch === string) string = '';
+ else if (ch === '\n' || ch === '\r') throw new Error('Используйте \\n внутри строки.');
+ continue;
+ }
+ if (ch === '"' || ch === "'") string = ch;
+ else if ('([{'.includes(ch)) stack.push(ch);
+ else if (')]}'.includes(ch)) {
+ if (stack.pop() !== ({')':'(',']':'[','}':'{'})[ch]) throw new Error('Проверьте скобки в Python-выражении.');
+ } else if (ch === ';' || ch === '#' || ((ch === '\n' || ch === '\r') && !stack.length)) {
+ throw new Error('Введите одно выражение, без комментариев и операторов через точку с запятой.');
+ }
+ }
+ if (string || stack.length || /[+\-*/=,:.]$/.test(source)) throw new Error('Завершите Python-выражение: проверьте кавычки и скобки.');
+ return source;
+ }
+ function serialize(entry) {
+ if (!entry || typeof entry.value !== 'string') throw new Error('Введите значение.');
+ if (entry.value.length > 20000) throw new Error('Максимум 20 000 символов в одном значении.');
+ if (entry.mode === 'text') return quote(entry.value);
+ if (entry.mode === 'boolean') {
+ if (!['True','False','None'].includes(entry.value)) throw new Error('Выберите True, False или None.');
+ return entry.value;
+ }
+ if (entry.mode === 'number') {
+ const raw = entry.value.trim();
+ if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(raw) || !Number.isFinite(Number(raw))) throw new Error('Введите конечное число, дробную часть отделите точкой.');
+ if (/^[+-]?0\d+$/.test(raw) && Number(raw) !== 0) throw new Error('Уберите ведущие нули из целого числа.');
+ return raw.replace(/^\+/, '');
+ }
+ if (entry.mode === 'expression') return expression(entry.value);
+ throw new Error('Неизвестный тип значения.');
+ }
+ function compile(state) {
+ const errors = [], parts = [];
+ if (!identifier(state.variable)) errors.push({key:'variable', message:'Введите имя переменной Python: буквы, цифры и _, без пробелов; не ключевое слово.'});
+ try {
+ if (state.nameMode === 'none') parts.push('None');
+ else if (state.nameMode === 'text') parts.push(state.name === '' ? 'None' : quote(state.name));
+ else if (state.nameMode === 'expression') parts.push(expression(state.name));
+ else if (state.nameMode !== 'inherit') throw new Error('Выберите способ задания имени.');
+ } catch (error) { errors.push({key:'name',message:error.message}); }
+ for (const [key, entry] of Object.entries(state.values || {})) {
+ try {
+ if (!identifier(key) || ['name','__proto__','constructor','prototype'].includes(key)) throw new Error('Недопустимое имя или повторный параметр name.');
+ parts.push(key + '=' + serialize(entry));
+ } catch (error) { errors.push({key,message:error.message}); }
+ }
+ const call = parts.length < 2 ? 'Character(' + parts.join(', ') + ')' : 'Character(\n ' + parts.join(',\n ') + ',\n)';
+ return {code: errors.length ? '' : 'define ' + state.variable + ' = ' + call, errors};
+ }
+ function project(state) { return JSON.stringify({tool:'es-doc-character', version:1, state}, null, 2); }
+ function literal(source) {
+ // Only scalar literals and nested lists/tuples. No names, calls or eval.
+ let at = 0, depth = 0;
+ const skip = () => { while (/\s/.test(source[at] || '') && at < source.length) at++; };
+ const parse = () => {
+ skip(); if (++depth > 32) throw new Error('Too deep');
+ const ch = source[at]; let value;
+ if (ch === '[' || ch === '(') {
+ at++; const close = ch === '[' ? ']' : ')'; value = []; skip();
+ while (source[at] !== close) {
+ value.push(parse()); skip();
+ if (source[at] !== ',') break;
+ at++; skip();
+ }
+ if (source[at++] !== close) throw new Error('Incomplete literal');
+ } else if (ch === '"' || ch === "'") {
+ const quote = source[at++]; value = ''; let ended = false;
+ while (at < source.length) {
+ let next = source[at++];
+ if (next === quote) { ended = true; break; }
+ if (next === '\\') {
+ next = source[at++];
+ if (next === 'u' || next === 'x') {
+ const count = next === 'u' ? 4 : 2, hex = source.slice(at, at + count);
+ if (!new RegExp('^[0-9a-f]{' + count + '}$','i').test(hex)) throw new Error('Escape');
+ value += String.fromCharCode(parseInt(hex,16)); at += count; continue;
+ }
+ value += ({n:'\n',r:'\r',t:'\t',b:'\b',f:'\f',"'":"'",'"':'"','\\':'\\'})[next] ?? ('\\' + next);
+ } else value += next;
+ }
+ if (!ended) throw new Error('String');
+ } else {
+ const match = /^(True|False|None|[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)/i.exec(source.slice(at));
+ if (!match) throw new Error('Not a literal');
+ at += match[0].length;
+ value = ({True:true,False:false,None:null})[match[0]];
+ if (value === undefined) { value = Number(match[0]); if (!Number.isFinite(value)) throw new Error('Number'); }
+ }
+ depth--; return value;
+ };
+ try { const value = parse(); skip(); return at === source.length ? {ok:true,value} : {ok:false}; } catch (_) { return {ok:false}; }
+ }
+ function importProject(source) {
+ if (typeof source !== 'string' || source.length > 1000000) throw new Error('Проект слишком большой (максимум 1 МБ).');
+ let data;
+ try { data = JSON.parse(source); } catch (_) { throw new Error('Не удалось прочитать JSON. Выберите проект, сохранённый этим инструментом.'); }
+ if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Неподдерживаемый формат проекта.');
+ const s = data.state;
+ if (data.tool !== 'es-doc-character' || data.version !== 1 || !s || !['text','none','inherit','expression'].includes(s.nameMode)
+ || ['variable','name','sample'].some(key => typeof s[key] !== 'string' || s[key].length > 20000)
+ || !s.values || Array.isArray(s.values) || typeof s.values !== 'object' || Object.keys(s.values).length > 500) throw new Error('Неподдерживаемый формат проекта.');
+ const values = Object.create(null);
+ for (const [key,value] of Object.entries(s.values)) {
+ if (!identifier(key) || ['name','__proto__','constructor','prototype'].includes(key) || !value || !['text','boolean','number','expression'].includes(value.mode) || typeof value.value !== 'string' || value.value.length > 20000) throw new Error('Некорректный параметр в проекте: ' + key);
+ values[key] = {mode:value.mode,value:value.value};
+ }
+ return {variable:s.variable,name:s.name,nameMode:s.nameMode,sample:s.sample,values};
+ }
+ window.ESDocCharacters = {simpleState, fields, args, field:key => byKey.get(key), identifier, quote, expression, serialize, literal, compile, project, importProject};
+})();
diff --git a/static/js/characters.js b/static/js/characters.js
new file mode 100644
index 0000000..75e9f29
--- /dev/null
+++ b/static/js/characters.js
@@ -0,0 +1,134 @@
+(function () {
+ 'use strict';
+ window.__esdocCharactersCleanup?.();
+ const app = document.getElementById('characters-app');
+ if (!app) return;
+ const c = window.ESDocCharacters, get = id => document.getElementById('character-' + id);
+ const controller = new AbortController();
+ const on = (el,event,fn) => el.addEventListener(event,fn,{signal:controller.signal});
+ const defaults = () => ({variable:'e',name:'Семён',nameMode:'text',sample:'Я говорю так! Добро пожаловать в «Совёнок».',values:{dynamic:{mode:'boolean',value:'False'}}});
+ const clone = s => JSON.parse(JSON.stringify(s)), storageKey = 'es-doc-character-draft-v1';
+ const rows = new Map(), undos = [], redos = [], urls = new Set(), bases = window.ESDocCharacterCatalog.bases;
+ let state = defaults(), committed, disposed = false, timer, scrambleTimer;
+ const status = message => { if (!disposed) get('status').textContent = message; };
+ try {
+ const saved = localStorage.getItem(storageKey);
+ if (saved) { state = c.simpleState(c.importProject(saved)); get('storage-note').textContent = 'Черновик восстановлен. Доступны только свойства простого персонажа.'; }
+ } catch (_) { get('storage-note').textContent = 'Не удалось прочитать черновик. Сохраните настройки в файл.'; }
+ committed = clone(state);
+ const element = (tag,text) => { const el = document.createElement(tag); if (text !== undefined) el.textContent = text; return el; };
+ const option = (select,value,label) => { const el = element('option',label); el.value = value; select.append(el); };
+ for (const base of bases) option(get('kind'),base.key,base.label + ' / ' + base.key);
+ option(get('kind'),'custom','Кастомная основа');
+ for (const field of c.fields.filter(field => ['who','what'].includes(field.group))) {
+ const row = element('div'); row.className = 'character-field';
+ const label = element('label',field.label + ' / ' + field.key); label.htmlFor = 'character-value-' + field.key;
+ const input = element(field.mode === 'boolean' ? 'select' : 'input'); input.id = label.htmlFor;
+ if (field.mode === 'boolean') { option(input,'','По умолчанию'); option(input,'True','Да'); option(input,'False','Нет'); }
+ else { input.type = 'text'; input.maxLength = 20000; input.placeholder = 'По умолчанию'; input.autocomplete = 'off'; }
+ const error = element('p'); error.className = 'character-error'; error.id = 'character-error-' + field.key; error.hidden = true;
+ input.setAttribute('aria-describedby',error.id); row.append(label);
+ let picker;
+ if (field.key.endsWith('_color')) {
+ const controls = element('div'); controls.className = 'character-field-value';
+ picker = element('input'); picker.type = 'color'; picker.id = 'character-color-' + field.key;
+ picker.setAttribute('aria-label','Выбрать цвет / ' + field.key); picker.className = 'character-color';
+ controls.append(input,picker); row.append(controls);
+ on(picker,'input',() => { input.value = picker.value; state.values[field.key] = {mode:'text',value:picker.value}; edit(); }); on(picker,'change',remember);
+ } else row.append(input);
+ if (field.key.endsWith('_font')) {
+ const help = element('p','Путь в game, например fonts/my-font.ttf. Другой шрифт невозможно предсмотреть в браузере.');
+ help.className = 'character-field-help'; help.id = 'character-help-' + field.key; row.append(help); input.setAttribute('aria-describedby',help.id + ' ' + error.id);
+ }
+ if (field.key.endsWith('_suffix')) input.title = 'Постфикс передаётся в Ren\'Py как ' + field.key;
+ row.append(error); get(field.group + '-fields').append(row); rows.set(field.key,{input,picker,error});
+ on(input,field.mode === 'boolean' ? 'change' : 'input',() => { if (input.value === '') delete state.values[field.key]; else state.values[field.key] = {mode:field.mode,value:input.value}; edit(field.mode === 'boolean'); });
+ on(input,'change',remember);
+ }
+ function remember() {
+ clearTimeout(timer);
+ if (JSON.stringify(state) !== JSON.stringify(committed)) { undos.push(committed); if (undos.length > 64) undos.shift(); committed = clone(state); redos.length = 0; }
+ get('undo').disabled = !undos.length; get('redo').disabled = !redos.length;
+ }
+ function persist() {
+ try { localStorage.setItem(storageKey,c.project(state)); get('storage-note').textContent = 'Черновик сохранён в этом браузере.'; }
+ catch (_) { get('storage-note').textContent = 'Не удалось сохранить черновик. Используйте «Сохранить настройки».'; }
+ }
+ function edit(immediate = false) { render(); persist(); clearTimeout(timer); if (immediate) remember(); else timer = setTimeout(remember,450); }
+ const value = (key,fallback) => { const entry = state.values[key]; if (!entry) return fallback; return entry.mode === 'boolean' ? entry.value === 'True' : entry.value; };
+ function scramble() {
+ const chars = 'АБВГДЖЗИЙКЛМНОПРСТУФХЦЧШЩЭЮЯ0123456789!?%#';
+ get('preview-who').textContent = value('who_prefix','') + Array.from(state.name || 'Имя',ch => /\s/u.test(ch) ? ch : chars[Math.floor(Math.random()*chars.length)]).join('') + value('who_suffix','');
+ }
+ function preview() {
+ clearInterval(scrambleTimer);
+ const base = bases.find(base => base.key === state.values.kind?.value);
+ for (const prefix of ['who','what']) {
+ const target = get('preview-' + prefix); target.removeAttribute('style');
+ const color = value(prefix + '_color',prefix === 'who' ? base?.color || '#e1dd7d' : '#ffdd7d');
+ if (/^#(?:[a-f\d]{3,4}|[a-f\d]{6}|[a-f\d]{8})$/i.test(color)) target.style.color = color;
+ target.style.fontWeight = value(prefix + '_bold',false) ? '700' : '400'; target.style.fontStyle = value(prefix + '_italic',false) ? 'italic' : 'normal';
+ target.style.textDecoration = [value(prefix + '_underline',false) ? 'underline' : '',value(prefix + '_strikethrough',false) ? 'line-through' : ''].filter(Boolean).join(' ') || 'none';
+ }
+ get('namebox').hidden = state.name === '';
+ get('preview-who').textContent = value('who_prefix','') + state.name + value('who_suffix','');
+ get('preview-what').textContent = value('what_prefix',base?.key === 'th' ? '~ ' : '') + state.sample + value('what_suffix',base?.key === 'th' ? ' ~' : '');
+ if (value('dynamic',false) && state.name !== '') { scramble(); if (!matchMedia('(prefers-reduced-motion: reduce)').matches) scrambleTimer = setInterval(scramble,100); }
+ }
+ function render() {
+ get('dynamic-warning').hidden = !value('dynamic',false); get('custom-kind-control').hidden = get('kind').value !== 'custom';
+ for (const id of ['variable','name','kind']) { get(id + '-error').hidden = true; (id === 'kind' ? get('custom-kind') : get(id)).removeAttribute('aria-invalid'); }
+ for (const [key,row] of rows) {
+ row.error.hidden = true; row.input.removeAttribute('aria-invalid');
+ if (row.picker) {
+ const hex = value(key,key.startsWith('who') ? bases.find(base => base.key === state.values.kind?.value)?.color || '#e1dd7d' : '#ffdd7d');
+ if (/^#[a-f\d]{3,4}$/i.test(hex)) row.picker.value = '#' + hex.slice(1,4).split('').map(ch => ch+ch).join('');
+ else if (/^#[a-f\d]{6}(?:[a-f\d]{2})?$/i.test(hex)) row.picker.value = hex.slice(0,7);
+ }
+ }
+ const result = c.compile(state);
+ if (state.values.kind && !c.identifier(state.values.kind.value)) result.errors.push({key:'kind',message:'Введите имя переменной персонажа: буквы, цифры и _, без пробелов.'});
+ for (const [key] of rows) if (key.endsWith('_color') && state.values[key] && !/^#(?:[a-f\d]{3,4}|[a-f\d]{6}|[a-f\d]{8})$/i.test(state.values[key].value)) result.errors.push({key,message:'Введите HEX-цвет, например #aabbcc или #aabbccdd.'});
+ get('code').value = result.errors.length ? 'Исправьте отмеченные поля, чтобы получить код.' : result.code;
+ get('code').rows = Math.max(4,Math.min(13,result.code.split('\n').length+1)); get('copy').disabled = get('download').disabled = Boolean(result.errors.length);
+ get('validation').hidden = !result.errors.length; get('validation').textContent = 'Ошибок в настройках: ' + result.errors.length + '.';
+ get('fix-error').hidden = !result.errors.length; get('fix-error').dataset.key = result.errors[0]?.key || '';
+ for (const error of result.errors) {
+ const row = rows.get(error.key), input = row?.input || (error.key === 'kind' ? get('custom-kind') : get(error.key));
+ const hint = row?.error || get(error.key + '-error'); input?.setAttribute('aria-invalid','true'); if (hint) { hint.hidden = false; hint.textContent = error.message; }
+ }
+ get('usage').textContent = (c.identifier(state.variable) ? state.variable : 'e') + ' "Привет!"'; preview();
+ }
+ function sync() {
+ for (const key of ['variable','name','sample']) get(key).value = state[key];
+ const kind = state.values.kind?.value || ''; get('kind').value = !kind || bases.some(base => base.key === kind) ? kind : 'custom';
+ get('custom-kind').value = get('kind').value === 'custom' ? kind : ''; get('dynamic').checked = value('dynamic',false);
+ for (const [key,row] of rows) row.input.value = state.values[key]?.value || ''; render();
+ }
+ for (const key of ['variable','name','sample']) { on(get(key),'input',() => { state[key] = get(key).value; edit(); }); on(get(key),'change',remember); }
+ on(get('kind'),'change',() => { const kind = get('kind').value === 'custom' ? get('custom-kind').value : get('kind').value; if (get('kind').value) state.values.kind = {mode:'expression',value:kind}; else delete state.values.kind; edit(true); });
+ on(get('custom-kind'),'input',() => { state.values.kind = {mode:'expression',value:get('custom-kind').value}; edit(); });
+ on(get('dynamic'),'change',() => { state.values.dynamic = {mode:'boolean',value:get('dynamic').checked ? 'True' : 'False'}; edit(true); });
+ on(get('zoom'),'click',() => {
+ const zoomed = get('preview-viewport').classList.toggle('is-zoomed');
+ get('zoom').setAttribute('aria-pressed',String(zoomed));
+ get('zoom').textContent = zoomed ? 'Исходный размер' : 'Увеличить';
+ get('preview-viewport').scrollLeft = 0;
+ });
+ on(get('settings'),'submit',event => event.preventDefault());
+ function undo() { remember(); if (!undos.length) return; redos.push(clone(state)); state = undos.pop(); committed = clone(state); persist(); sync(); get('undo').disabled = !undos.length; get('redo').disabled = !redos.length; }
+ function redo() { if (!redos.length) return; undos.push(clone(state)); state = redos.pop(); committed = clone(state); persist(); sync(); get('undo').disabled = !undos.length; get('redo').disabled = !redos.length; }
+ on(get('undo'),'click',undo); on(get('redo'),'click',redo);
+ on(document,'keydown',event => { if (!(event.ctrlKey || event.metaKey) || event.altKey || event.target.matches('input,textarea,select,[contenteditable]')) return; if (event.key.toLowerCase() === 'z') { event.preventDefault(); event.shiftKey ? redo() : undo(); } else if (event.key.toLowerCase() === 'y') { event.preventDefault(); redo(); } });
+ on(get('reset'),'click',() => { remember(); state = defaults(); persist(); sync(); remember(); status('Настройки сброшены. Их можно вернуть кнопкой «Отменить».'); });
+ on(get('fix-error'),'click',() => { const key = get('fix-error').dataset.key, input = rows.get(key)?.input || (key === 'kind' ? get('custom-kind') : get(key)); for (let el = input?.parentElement; el; el = el.parentElement) if (el.tagName === 'DETAILS') el.open = true; input?.focus(); });
+ on(get('copy'),'click',async () => { if (get('copy').disabled) return; try { await navigator.clipboard.writeText(get('code').value); status('Определение скопировано.'); } catch (_) { if (!disposed) { get('code').focus(); get('code').select(); status('Код выделен. Нажмите Ctrl+C.'); } } });
+ function download(name,content,type) { const url = URL.createObjectURL(new Blob([content],{type})); urls.add(url); const link = element('a'); link.href = url; link.download = name; document.body.append(link); link.click(); link.remove(); setTimeout(() => { URL.revokeObjectURL(url); urls.delete(url); },1000); }
+ on(get('download'),'click',() => { if (!get('download').disabled) download(state.variable + '.rpy',get('code').value + '\n','text/plain;charset=utf-8'); });
+ on(get('save-project'),'click',() => download((c.identifier(state.variable) ? state.variable : 'character') + '.character.json',c.project(state),'application/json'));
+ on(get('load-project'),'click',() => get('project-file').click());
+ on(get('project-file'),'change',async () => { const file = get('project-file').files[0]; if (!file) return; try { if (file.size > 1000000) throw new Error('Выберите JSON размером до 1 МБ.'); const loaded = c.simpleState(c.importProject(await file.text())); if (disposed) return; remember(); state = loaded; sync(); persist(); remember(); status('Настройки открыты.'); } catch (error) { status(error.message); } finally { if (!disposed) get('project-file').value = ''; } });
+ for (const button of app.querySelectorAll('[data-character-jump]')) on(button,'click',() => get(button.dataset.characterJump + '-heading')?.focus());
+ window.__esdocCharactersCleanup = () => { remember(); disposed = true; controller.abort(); clearTimeout(timer); clearInterval(scrambleTimer); for (const url of urls) URL.revokeObjectURL(url); window.__esdocCharactersCleanup = null; };
+ sync(); app.hidden = false;
+})();
diff --git a/static/js/colors.js b/static/js/colors.js
index 6639a95..7df241f 100644
--- a/static/js/colors.js
+++ b/static/js/colors.js
@@ -11,7 +11,7 @@
let color = c.parseHex('#2f7524'), hsv = c.rgbToHsv(color), other = c.parseHex('#fff');
let role = 'text', surface = 'field', pointer = null, disposed = false, imageRequest = 0, imagePoint = {x: 0, y: 0};
const undos = [], redos = [], copyTimers = new Map();
- let eyedropperOverlayTimer, eyedropperController;
+ let eyedropperOverlayTimer, eyedropperController, screenCaptureStream, screenCaptureVideo, screenPickResolve;
const same = (first, second) => JSON.stringify(first) === JSON.stringify(second);
const readList = key => {
try {
@@ -437,20 +437,109 @@
on(document, 'pointerdown', event => { if (event.button === 2) cancelEyedropper(event); });
on(document, 'contextmenu', cancelEyedropper);
on(document, 'keydown', event => { if (event.key === 'Escape') cancelEyedropper(event); });
- if (window.EyeDropper && window.isSecureContext) {
- get('eyedropper').hidden = false;
+ on(get('eyedropper-overlay'), 'pointerdown', event => {
+ if (!screenCaptureVideo || event.button !== 0 || get('eyedropper-overlay').hidden) return;
+ const sampleCanvas = get('screen-canvas');
+ if (event.target !== sampleCanvas) return;
+ event.preventDefault();
+ try {
+ const rect = sampleCanvas.getBoundingClientRect();
+ if (!rect.width || !rect.height) return;
+ const context = sampleCanvas.getContext('2d', {willReadFrequently: true});
+ const x = c.clamp(Math.floor((event.clientX - rect.left) / rect.width * sampleCanvas.width), 0, sampleCanvas.width - 1);
+ const y = c.clamp(Math.floor((event.clientY - rect.top) / rect.height * sampleCanvas.height), 0, sampleCanvas.height - 1);
+ const pixel = context.getImageData(x, y, 1, 1).data;
+ screenPickResolve?.({r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3] / 255});
+ } catch (_) {
+ get('status').textContent = 'Не удалось прочитать цвет снимка. Попробуйте снова или откройте изображение.';
+ }
+ });
+ const nativeEyedropper = window.EyeDropper && window.isSecureContext;
+ const screenEyedropper = window.isSecureContext && window.navigator.mediaDevices?.getDisplayMedia;
+ get('eyedropper').hidden = false;
+ get('eyedropper-warning').hidden = !/(?:Firefox|FxiOS)\//.test(window.navigator.userAgent || '');
+ const warning = get('eyedropper-warning');
+ const warningTrigger = get('eyedropper-warning-trigger');
+ const warningTooltip = get('eyedropper-tooltip');
+ let warningTimer, warningHovered = false, warningFocused = false;
+ const hideWarning = () => { clearTimeout(warningTimer); warningTooltip.hidden = true; };
+ const showWarning = () => {
+ clearTimeout(warningTimer);
+ if (disposed || warning.hidden) return;
+ warningTooltip.hidden = false;
+ warningTooltip.style.setProperty('--tooltip-shift', '0px');
+ const rect = warningTooltip.getBoundingClientRect();
+ if (Number.isFinite(window.innerWidth)) {
+ const shift = Math.max(8 - rect.left, Math.min(0, window.innerWidth - 8 - rect.left - rect.width));
+ warningTooltip.style.setProperty('--tooltip-shift', `${shift}px`);
+ }
+ };
+ on(warning, 'pointerenter', event => {
+ if (event.pointerType === 'touch') return;
+ warningHovered = true;
+ warningTimer = setTimeout(showWarning, 200);
+ });
+ on(warning, 'pointerleave', () => { warningHovered = false; clearTimeout(warningTimer); if (!warningFocused) hideWarning(); });
+ on(warningTrigger, 'focus', () => { warningFocused = true; showWarning(); });
+ on(warningTrigger, 'blur', () => { warningFocused = false; if (!warningHovered) hideWarning(); });
+ on(warningTrigger, 'click', showWarning);
+ on(document, 'keydown', event => { if (event.key === 'Escape' && !warningTooltip.hidden) hideWarning(); });
+ controller.signal.addEventListener('abort', hideWarning, {once: true});
+ if (nativeEyedropper || screenEyedropper) {
on(get('eyedropper'), 'click', async () => {
const pickController = new AbortController();
eyedropperController = pickController;
const abortPick = () => pickController.abort();
controller.signal.addEventListener('abort', abortPick, {once: true});
get('eyedropper').disabled = true;
- // Chromium snapshots the screen on open. Delay the dimmer until after
- // that capture so the magnifier samples the original page colors.
- eyedropperOverlayTimer = setTimeout(() => { if (!disposed) get('eyedropper-overlay').hidden = false; }, 300);
- try { const result = await new window.EyeDropper().open({signal: pickController.signal}); if (!disposed && !pickController.signal.aborted) choose(c.parseHex(result.sRGBHex)); }
- catch (error) { if (!disposed) get('status').textContent = error.name === 'AbortError' ? 'Выбор пипеткой отменён.' : 'Не удалось открыть пипетку. Используйте поле или изображение.'; }
- finally { controller.signal.removeEventListener('abort', abortPick); if (eyedropperController === pickController) eyedropperController = null; clearTimeout(eyedropperOverlayTimer); if (!disposed) { get('eyedropper').disabled = false; get('eyedropper-overlay').hidden = true; get('eyedropper').focus({preventScroll: true}); } }
+ try {
+ let result;
+ if (nativeEyedropper) {
+ // Chromium snapshots the screen on open. Delay the dimmer until after
+ // that capture so the magnifier samples the original page colors.
+ eyedropperOverlayTimer = setTimeout(() => { if (!disposed) get('eyedropper-overlay').hidden = false; }, 300);
+ result = c.parseHex((await new window.EyeDropper().open({signal: pickController.signal})).sRGBHex);
+ } else {
+ get('status').textContent = 'Выберите экран или окно для снимка.';
+ screenCaptureStream = await window.navigator.mediaDevices.getDisplayMedia({video: true, audio: false});
+ if (pickController.signal.aborted || disposed) return;
+ screenCaptureVideo = document.createElement('video');
+ screenCaptureVideo.muted = true; screenCaptureVideo.playsInline = true; screenCaptureVideo.srcObject = screenCaptureStream;
+ await screenCaptureVideo.play();
+ if (!screenCaptureVideo.videoWidth || !screenCaptureVideo.videoHeight) throw new Error('Captured tab is not ready');
+ if (pickController.signal.aborted || disposed) return;
+ const sampleCanvas = get('screen-canvas');
+ const scale = Math.min(1, 4096 / screenCaptureVideo.videoWidth, 4096 / screenCaptureVideo.videoHeight, Math.sqrt(12000000 / (screenCaptureVideo.videoWidth * screenCaptureVideo.videoHeight)));
+ sampleCanvas.width = Math.max(1, Math.round(screenCaptureVideo.videoWidth * scale));
+ sampleCanvas.height = Math.max(1, Math.round(screenCaptureVideo.videoHeight * scale));
+ sampleCanvas.getContext('2d', {willReadFrequently: true}).drawImage(screenCaptureVideo, 0, 0, sampleCanvas.width, sampleCanvas.height);
+ screenCaptureStream.getTracks().forEach(track => track.stop());
+ sampleCanvas.hidden = false;
+ get('eyedropper-overlay').classList.add('picker-eyedropper-overlay-capture');
+ get('eyedropper-overlay').hidden = false;
+ get('status').textContent = 'Выберите цвет на снимке. Esc — отмена.';
+ result = await new Promise((resolve, reject) => {
+ screenPickResolve = resolve;
+ pickController.signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), {once: true});
+ });
+ }
+ if (!disposed && !pickController.signal.aborted) choose(result);
+ }
+ catch (error) { if (!disposed) get('status').textContent = error.name === 'AbortError' ? 'Выбор пипеткой отменён.' : error.name === 'NotAllowedError' ? 'Захват экрана или окна отменён. Разрешите его или выберите цвет из изображения.' : 'Не удалось открыть пипетку. Используйте поле или изображение.'; }
+ finally {
+ controller.signal.removeEventListener('abort', abortPick);
+ if (eyedropperController === pickController) eyedropperController = null;
+ screenPickResolve = null; screenCaptureVideo = null;
+ screenCaptureStream?.getTracks().forEach(track => track.stop()); screenCaptureStream = null;
+ clearTimeout(eyedropperOverlayTimer);
+ get('screen-canvas').hidden = true;
+ if (!disposed) { get('eyedropper').disabled = false; get('eyedropper-overlay').hidden = true; get('eyedropper-overlay').classList.remove('picker-eyedropper-overlay-capture'); get('eyedropper').focus({preventScroll: true}); }
+ }
+ });
+ }
+ if (!nativeEyedropper && !screenEyedropper) {
+ on(get('eyedropper'), 'click', () => {
+ get('status').textContent = 'Пипетка недоступна в вашем браузере.';
});
}
const canvas = get('image-canvas');
@@ -521,7 +610,8 @@
window.__esdocColorsCleanup = () => {
disposed = true; imageRequest++; controller.abort();
clearTimeout(eyedropperOverlayTimer);
- shortcuts.close(); get('eyedropper-overlay').hidden = true;
+ screenCaptureStream?.getTracks().forEach(track => track.stop()); screenCaptureStream = null; screenCaptureVideo = null; screenPickResolve = null;
+ shortcuts.close(); get('eyedropper-overlay').hidden = true; get('eyedropper-overlay').classList.remove('picker-eyedropper-overlay-capture');
for (const timer of copyTimers.values()) clearTimeout(timer);
canvas.width = canvas.height = 0;
window.__esdocColorsCleanup = null;
diff --git a/static/js/docs.js b/static/js/docs.js
index 4d5fa8e..c996b09 100644
--- a/static/js/docs.js
+++ b/static/js/docs.js
@@ -1,5 +1,22 @@
/* Documentation navigation; native disclosures also work without scripts. */
(function () {
+ window.__esdocDocsCleanup?.();
+ const controller = new AbortController();
+ const signal = controller.signal;
+ let disposed = false;
+ let frame = 0;
+ let navigationFrame = 0;
+ let observer;
+ window.__esdocDocsCleanup = () => {
+ if (disposed) return;
+ disposed = true;
+ controller.abort();
+ observer?.disconnect();
+ cancelAnimationFrame(frame);
+ cancelAnimationFrame(navigationFrame);
+ window.__esdocDocsCleanup = null;
+ };
+
const tree = document.getElementById('sidebar-all');
const contents = document.getElementById('sidebar-contents');
const desktopTree = matchMedia('(min-width: 62em)');
@@ -11,20 +28,22 @@
const hadFocus = details.contains(document.activeElement);
details.open = media.matches;
if (hadFocus && !details.open) details.querySelector('summary').focus({preventScroll: true});
- });
+ }, {signal});
details.addEventListener('keydown', event => {
if (event.key !== 'Escape' || !details.open) return;
details.open = false;
details.querySelector('summary').focus({preventScroll: true});
- });
+ }, {signal});
}
adaptDisclosure(tree, desktopTree);
adaptDisclosure(contents, desktopContents);
if (contents && window.ResizeObserver) {
const summary = contents.querySelector('summary');
- new ResizeObserver(() => {
+ observer = new ResizeObserver(() => {
+ if (disposed) return;
document.documentElement.style.setProperty('--doc-contents-h', summary.offsetHeight + 'px');
- }).observe(summary);
+ });
+ observer.observe(summary);
}
// Scroll only the independent rail; preserve the document's deep link.
@@ -45,8 +64,8 @@
const headings = [...document.querySelectorAll('.content .heading')].filter(heading => links.has(heading.id));
if (!headings.length) return;
let active = null;
- let frame = 0;
function update() {
+ if (disposed) return;
frame = 0;
const offset = (parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 64) + 24;
let heading;
@@ -81,12 +100,13 @@
active.setAttribute('aria-current', 'location');
}
function scheduleUpdate() {
+ if (disposed) return;
if (!frame) frame = requestAnimationFrame(update);
}
- addEventListener('scroll', scheduleUpdate, {passive: true});
- addEventListener('resize', scheduleUpdate);
- addEventListener('hashchange', scheduleUpdate);
- addEventListener('load', scheduleUpdate);
+ addEventListener('scroll', scheduleUpdate, {passive: true, signal});
+ addEventListener('resize', scheduleUpdate, {signal});
+ addEventListener('hashchange', scheduleUpdate, {signal});
+ addEventListener('load', scheduleUpdate, {signal});
if (document.fonts) document.fonts.ready.then(scheduleUpdate);
update();
@@ -100,12 +120,15 @@
if (!target) return;
event.preventDefault();
contents.open = false;
- requestAnimationFrame(() => {
+ cancelAnimationFrame(navigationFrame);
+ navigationFrame = requestAnimationFrame(() => {
+ navigationFrame = 0;
+ if (disposed) return;
history.pushState(null, '', link.hash);
target.setAttribute('tabindex', '-1');
target.focus({preventScroll: true});
target.scrollIntoView({block: 'start', behavior: 'auto'});
scheduleUpdate();
});
- });
+ }, {signal});
})();
diff --git a/static/js/navigation.js b/static/js/navigation.js
index 5c306e7..127382d 100644
--- a/static/js/navigation.js
+++ b/static/js/navigation.js
@@ -12,7 +12,8 @@
if (!content || !window.fetch || !window.DOMParser) return;
let navigationId = 0;
- let navigating = false;
+ let navigationController;
+ let pageQueue = Promise.resolve();
const sectionFor = pathname => {
if (pathname === '/') return '';
@@ -37,7 +38,8 @@
});
};
- const updateStyles = async nextDocument => {
+ const updateStyles = async (nextDocument, signal) => {
+ if (signal.aborted) return;
const hrefs = [...nextDocument.head.querySelectorAll('link[data-page-style]')]
.map(link => link.href)
.filter(Boolean);
@@ -51,21 +53,29 @@
});
await Promise.all(newLinks.map(link => new Promise(resolve => {
- link.addEventListener('load', resolve, { once: true });
- link.addEventListener('error', resolve, { once: true });
+ const finish = () => {
+ signal.removeEventListener('abort', cancel);
+ resolve();
+ };
+ const cancel = () => { link.remove(); finish(); };
+ signal.addEventListener('abort', cancel, { once: true });
+ link.addEventListener('load', finish, { once: true });
+ link.addEventListener('error', finish, { once: true });
document.head.appendChild(link);
})));
- oldLinks.forEach(link => link.remove());
+ if (signal.aborted) newLinks.forEach(link => link.remove());
+ else oldLinks.forEach(link => link.remove());
};
- const loadPageScripts = async nextDocument => {
+ const loadPageScripts = async (nextDocument, id) => {
document.querySelectorAll('script[data-soft-page-script]').forEach(script => script.remove());
const sources = [...nextDocument.head.querySelectorAll('script[data-page-script][src]')]
.map(script => script.src);
for (const src of sources) {
+ if (id !== navigationId) return;
await new Promise(resolve => {
const script = document.createElement('script');
script.src = src;
@@ -78,39 +88,16 @@
}
};
- const loadPage = async (href, pushHistory) => {
- const id = ++navigationId;
- const url = new URL(href, location.href);
-
- let response;
- try {
- response = await fetch(url.href, {
- credentials: 'same-origin',
- headers: { Accept: 'text/html' },
- });
- } catch (error) {
- location.href = url.href;
- return;
- }
-
- if (!response.ok || !response.headers.get('content-type')?.includes('text/html')) {
- location.href = url.href;
- return;
- }
-
- const nextDocument = new DOMParser().parseFromString(await response.text(), 'text/html');
- const nextContent = nextDocument.getElementById('site-content');
- if (!nextContent || id !== navigationId) {
- if (id === navigationId) location.href = url.href;
- return;
- }
-
- await updateStyles(nextDocument);
+ const commitPage = async (nextDocument, nextContent, url, pushHistory, id, signal) => {
+ if (id !== navigationId) return;
+ await updateStyles(nextDocument, signal);
if (id !== navigationId) return;
+ window.__esdocDocsCleanup?.();
window.__esdocWarperCleanup?.();
window.__esdocToolsCleanup?.();
window.__esdocColorsCleanup?.();
+ window.__esdocCharactersCleanup?.();
window.__esdocAnimationsCleanup?.();
content.innerHTML = nextContent.innerHTML;
document.title = nextDocument.title;
@@ -128,9 +115,10 @@
updateNavigation(url);
if (pushHistory) history.pushState({}, '', url.href);
+ else if (location.href !== url.href) history.replaceState(history.state, '', url.href);
if (!url.hash) window.scrollTo(0, 0);
- await loadPageScripts(nextDocument);
+ await loadPageScripts(nextDocument, id);
if (id !== navigationId) return;
// Soft navigation replaces the document without the browser's native
@@ -147,6 +135,40 @@
window.dispatchEvent(new CustomEvent('esdoc:navigation', { detail: { url: url.href } }));
};
+ const loadPage = async (href, pushHistory) => {
+ const id = ++navigationId;
+ navigationController?.abort();
+ navigationController = new AbortController();
+ const signal = navigationController.signal;
+ const url = new URL(href, location.href);
+
+ try {
+ const response = await fetch(url.href, {
+ credentials: 'same-origin',
+ headers: { Accept: 'text/html' },
+ signal,
+ });
+ if (id !== navigationId) return;
+ if (!response.ok || !response.headers.get('content-type')?.includes('text/html')) {
+ location.href = url.href;
+ return;
+ }
+ const text = await response.text();
+ if (id !== navigationId) return;
+ const nextDocument = new DOMParser().parseFromString(text, 'text/html');
+ const nextContent = nextDocument.getElementById('site-content');
+ if (!nextContent) { location.href = url.href; return; }
+
+ // A pending classic script must finish against its original DOM.
+ // Fetches can overlap, but page replacement and scripts run in order.
+ const job = pageQueue.then(() => commitPage(nextDocument, nextContent, url, pushHistory, id, signal));
+ pageQueue = job.catch(() => {});
+ await job;
+ } catch (error) {
+ if (id === navigationId) location.href = url.href;
+ }
+ };
+
const shouldHandle = (event, link) => {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey
|| event.shiftKey || event.altKey || link.target || link.hasAttribute('download')) return false;
@@ -159,16 +181,13 @@
document.addEventListener('click', event => {
const link = event.target.closest?.('a');
- if (!link || !shouldHandle(event, link) || navigating) return;
+ if (!link || !shouldHandle(event, link)) return;
event.preventDefault();
- navigating = true;
- loadPage(link.href, true).finally(() => { navigating = false; });
+ loadPage(link.href, true);
});
window.addEventListener('popstate', () => {
- if (navigating) return;
- navigating = true;
- loadPage(location.href, false).finally(() => { navigating = false; });
+ loadPage(location.href, false);
});
})();
diff --git a/static/js/player.js b/static/js/player.js
index b48988b..c8721e4 100644
--- a/static/js/player.js
+++ b/static/js/player.js
@@ -19,6 +19,11 @@
if (!clearPlayerState || !readPlayerState || !shouldClearOnPageHide || !writePlayerState) return;
+ let volumeStorage = null;
+ let playerStorage = null;
+ try { volumeStorage = localStorage; } catch (error) {}
+ try { playerStorage = sessionStorage; } catch (error) {}
+
let buttons = [];
let status = document.getElementById('res-copy-status');
@@ -32,7 +37,8 @@
const audio = new Audio();
audio.preload = 'none';
- const storedVolume = parseFloat(localStorage.getItem('es-doc-volume') ?? '1');
+ let storedVolume = 1;
+ try { storedVolume = parseFloat(volumeStorage?.getItem('es-doc-volume') ?? '1'); } catch (error) {}
audio.volume = Number.isFinite(storedVolume) ? Math.min(Math.max(storedVolume, 0), 1) : 1;
let current = null;
@@ -99,7 +105,7 @@
const persist = (playing = !audio.paused, time = audio.currentTime) => {
if (!currentSrc) return;
- writePlayerState(sessionStorage, {
+ writePlayerState(playerStorage, {
src: currentSrc,
name: currentName,
time: Number.isFinite(time) && time >= 0 ? time : 0,
@@ -151,7 +157,7 @@
const setVolume = value => {
const volume = Math.min(Math.max(value, 0), 1);
audio.volume = volume;
- localStorage.setItem('es-doc-volume', String(volume));
+ try { volumeStorage?.setItem('es-doc-volume', String(volume)); } catch (error) {}
barVolume.value = volume;
setFill(barVolume);
};
@@ -167,7 +173,7 @@
currentSrc = '';
currentName = '';
finished = false;
- clearPlayerState(sessionStorage);
+ clearPlayerState(playerStorage);
};
const finish = () => {
@@ -256,7 +262,7 @@
bindButtons();
- const saved = readPlayerState(sessionStorage);
+ const saved = readPlayerState(playerStorage);
if (saved) {
current = buttons.find(button => button.dataset.playSrc === saved.src) || null;
currentSrc = saved.src;
@@ -284,7 +290,7 @@
// object alive without a gap (especially important in Firefox).
if (shouldClearOnPageHide(event)) {
audio.pause();
- clearPlayerState(sessionStorage);
+ clearPlayerState(playerStorage);
} else {
persist(!audio.paused);
}
diff --git a/static/js/search.js b/static/js/search.js
index f1b939b..34ef52f 100644
--- a/static/js/search.js
+++ b/static/js/search.js
@@ -37,6 +37,8 @@
function open() { list.hidden = false; input.setAttribute('aria-expanded', 'true'); }
function close() {
+ ++seq;
+ clearTimeout(timer);
list.hidden = true;
input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant');
@@ -153,7 +155,11 @@
// ── Events ───────────────────────────────────────────────
var timer;
input.addEventListener('input', function () {
- clearTimeout(timer);
+ close();
+ matches = [];
+ list.innerHTML = '';
+ announce('');
+ if (!input.value.trim()) return;
timer = setTimeout(update, 120);
});
@@ -192,6 +198,7 @@
input.focus();
}
function closeSheet(returnFocus) {
+ close();
var wasOpen = document.body.classList.contains('search-open');
document.body.classList.remove('search-open');
if (toggle) toggle.setAttribute('aria-expanded', 'false');
diff --git a/static/js/tools-core.js b/static/js/tools-core.js
index 1a76dc7..e63025b 100644
--- a/static/js/tools-core.js
+++ b/static/js/tools-core.js
@@ -9,6 +9,35 @@
};
const accepts = (path, mode) => (mode !== 'unrpa' && /\.(?:rpym?c|pyc)$/i.test(path))
|| (mode !== 'unrpyc' && /\.rpa$/i.test(path));
+ const archiveName = value => {
+ const name = value.trim().replace(/\.rpa$/i, '') + '.rpa';
+ if (!name || name === '.rpa' || name.length > 255 || /[\\/:*?"<>|\x00-\x1f]/.test(name)
+ || name.startsWith('.') || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])\./i.test(name)) {
+ throw new Error('Укажите имя архива без пути и символов \\ / : * ? " < > |.');
+ }
+ return name;
+ };
+ const packPath = (path, strip = '', prefix = '') => {
+ path = safePath(path);
+ if (strip && !path.startsWith(strip)) throw new Error('Удаляемая папка должна быть общей для всех файлов.');
+ prefix = prefix.trim().replaceAll('\\', '/').replace(/\/$/, '');
+ if (prefix) {
+ try { prefix = safePath(prefix) + '/'; }
+ catch { throw new Error('Укажите относительный путь папки без .., начального / и двоеточия. Например: my_mod.'); }
+ }
+ return safePath(prefix + path.slice(strip.length));
+ };
+ const commonFolders = paths => {
+ if (!paths.length) return [];
+ const parts = safePath(paths[0]).split('/');
+ const folders = [];
+ for (let i = 1; i < parts.length; i++) {
+ const prefix = parts.slice(0, i).join('/') + '/';
+ if (!paths.every(path => path.startsWith(prefix))) break;
+ folders.push(prefix);
+ }
+ return folders;
+ };
const walkEntry = async (entry, parent, add) => {
const path = safePath(parent + entry.name);
if (entry.isFile) {
@@ -88,6 +117,9 @@
} else if (/Failed to fetch|NetworkError|Load failed|dynamically imported module|Не удалось загрузить инструменты/i.test(normalized)) {
title = 'Не удалось загрузить инструменты';
explanation = 'Проверьте соединение с сайтом и повторите действие. Файлы остаются на вашем устройстве.';
+ } else if (/WebAssembly|CompileError|wasm.*(?:unsupported|not supported)|Браузер не поддерживает/i.test(normalized)) {
+ title = 'Браузер не смог запустить обработку';
+ explanation = 'Для локальной обработки нужен доступный WebAssembly. Обновите браузер или попробуйте актуальный Firefox, Chrome или Edge. Python устанавливать не нужно.';
} else if (/UnknownArchive|unsupported archive|archive.*(?:unknown|unsupported)|неподдерживаем.+архив/i.test(normalized)) {
title = 'Не удалось распознать формат архива';
explanation = 'Выберите оригинальный .rpa из папки игры. Изменённые или зашифрованные архивы могут не поддерживаться.';
@@ -110,5 +142,6 @@
const category = pluralRules.select(count);
return `${count.toLocaleString('ru-RU')} ${{ one, few, many }[category] || many}`;
};
- root.ESDocTools = { safePath, accepts, walkEntry, size, explainWarning, explainError, countLabel };
+ root.ESDocTools = { safePath, accepts, walkEntry, size, explainWarning, explainError, countLabel,
+ archiveName, packPath, commonFolders };
})(globalThis);
diff --git a/static/js/tools-worker.js b/static/js/tools-worker.js
index bd94833..1e3509b 100644
--- a/static/js/tools-worker.js
+++ b/static/js/tools-worker.js
@@ -1,6 +1,8 @@
/* All runtime and Python code is served by ES Doc; user Files stay in this
worker. WORKERFS reads Blob slices instead of copying a whole RPA to MEMFS. */
let runtimePromise;
+let packRuntimePromise;
+let packSyntaxReady = false;
let inputIndices = [];
let browseMounted = false;
let commandQueue = Promise.resolve();
@@ -13,13 +15,19 @@ const checkedFetch = async url => {
return response;
};
-async function initialize(config) {
+async function loadRuntime(config) {
self.postMessage({ type: 'loading', text: 'Загрузка инструментов для локальной обработки…' });
const { loadPyodide } = await import(new URL(config.runtime + 'pyodide.mjs', self.location.origin).href);
const pyodide = await loadPyodide({
indexURL: new URL(config.runtime, self.location.origin).href,
stdout: () => {}, stderr: () => {},
});
+ if (!pyodide.FS.filesystems.WORKERFS) throw new Error('Браузер не поддерживает чтение файлов инструментом. Попробуйте актуальный Firefox, Chrome или Edge.');
+ return pyodide;
+}
+
+async function initialize(config) {
+ const pyodide = await loadRuntime(config);
const [vendor, engine, recovery, syntax, lexer, bytecode, pyc] = await Promise.all([
checkedFetch(config.vendor).then(response => response.arrayBuffer()),
checkedFetch(config.engine).then(response => response.text()),
@@ -52,6 +60,47 @@ import engine
return pyodide;
}
+async function initializePacker(config) {
+ const pyodide = await loadRuntime(config);
+ const packer = await checkedFetch(config.packer).then(response => response.text());
+ pyodide.FS.mkdirTree('/tools');
+ pyodide.FS.writeFile('/tools/rpa.py', packer, { encoding: 'utf8' });
+ await pyodide.runPythonAsync("import sys\nsys.path.insert(0, '/tools')\nimport rpa");
+ return pyodide;
+}
+
+async function pack(data) {
+ let pyodide, mounted = false;
+ try {
+ packRuntimePromise ||= initializePacker(data.config).catch(error => { packRuntimePromise = null; throw error; });
+ pyodide = await packRuntimePromise;
+ pyodide.FS.mkdirTree('/pack');
+ pyodide.FS.mount(pyodide.FS.filesystems.WORKERFS, {
+ blobs: data.files.map((entry, index) => ({ name: `input-${index}`, data: entry.file })),
+ }, '/pack');
+ mounted = true;
+ inputIndices = [];
+ self.postMessage({ type: 'ready' });
+ pyodide.globals.set('pack_json', JSON.stringify(data.files.map((entry, index) => ({
+ path: entry.path, source: `/pack/input-${index}`,
+ }))));
+ self.postMessage({ type: 'output-start' });
+ const result = JSON.parse(await pyodide.runPythonAsync(`
+import json
+from js import esdocNotify, esdocEmit
+pack_result = rpa.pack(json.loads(pack_json), esdocEmit, esdocNotify)
+json.dumps(pack_result)
+`));
+ self.postMessage({ type: 'output', name: data.name, mime: 'application/octet-stream', result });
+ self.postMessage({ type: 'done', result });
+ } catch (error) {
+ self.postMessage({ type: 'fatal', error: String(error.message || error) });
+ } finally {
+ if (mounted) pyodide.FS.unmount('/pack');
+ if (pyodide) await pyodide.runPythonAsync("globals().pop('pack_json', None)\nglobals().pop('pack_result', None)\nimport gc\ngc.collect()");
+ }
+}
+
self.esdocNotify = event => {
const message = event.toJs({ dict_converter: Object.fromEntries });
if (typeof message.index === 'number') message.index = inputIndices[message.index] ?? message.index;
@@ -226,9 +275,53 @@ gc.collect()
}
}
+async function packPreview(data) {
+ let pyodide;
+ try {
+ const buffer = await data.file.arrayBuffer();
+ packRuntimePromise ||= initializePacker(data.config).catch(error => { packRuntimePromise = null; throw error; });
+ pyodide = await packRuntimePromise;
+ if (!packSyntaxReady) {
+ const [syntax, lexer] = await Promise.all([
+ checkedFetch(data.config.syntax).then(response => response.arrayBuffer()),
+ checkedFetch(data.config.lexer).then(response => response.text()),
+ ]);
+ pyodide.FS.writeFile('/tools/syntax.zip', new Uint8Array(syntax));
+ pyodide.FS.writeFile('/tools/renpy_lexer.py', lexer, { encoding: 'utf8' });
+ await pyodide.runPythonAsync("import zipfile\nwith zipfile.ZipFile('/tools/syntax.zip') as bundled:\n bundled.extractall('/tools')");
+ packSyntaxReady = true;
+ }
+ pyodide.FS.writeFile('/tools/pack-preview-input', new Uint8Array(buffer));
+ pyodide.globals.set('pack_preview_name', data.name);
+ const html = await pyodide.runPythonAsync(`
+from pygments import highlight
+from pygments.formatters import HtmlFormatter
+from pygments.lexers import get_lexer_for_filename, TextLexer
+from pygments.util import ClassNotFound
+from renpy_lexer import RenPyLexer
+with open('/tools/pack-preview-input', 'rb') as preview_source:
+ preview_text = preview_source.read().decode('utf-8', errors='replace')
+try:
+ preview_lexer = RenPyLexer() if pack_preview_name.lower().endswith(('.rpy', '.rpym')) else get_lexer_for_filename(pack_preview_name)
+except ClassNotFound:
+ preview_lexer = TextLexer()
+highlight(preview_text, preview_lexer, HtmlFormatter(nowrap=True))
+`);
+ self.postMessage({ type: 'source-file', name: data.name, buffer, html, warnings: [], requestId: data.requestId }, [buffer]);
+ } catch (error) {
+ self.postMessage({ type: 'source-error', operation: 'pack-preview', error: String(error.message || error), requestId: data.requestId });
+ } finally {
+ if (pyodide) {
+ try { pyodide.FS.unlink('/tools/pack-preview-input'); } catch {}
+ await pyodide.runPythonAsync("for key in ('pack_preview_name', 'preview_text', 'preview_lexer', 'preview_source'):\n globals().pop(key, None)");
+ }
+ }
+}
+
self.onmessage = ({ data }) => {
- if (!['run', 'catalog', 'source-read', 'browse-list', 'browse-read'].includes(data.type)) return;
- commandQueue = commandQueue.then(() => data.type === 'run' ? run(data)
+ if (!['pack', 'pack-preview', 'run', 'catalog', 'source-read', 'browse-list', 'browse-read'].includes(data.type)) return;
+ commandQueue = commandQueue.then(() => data.type === 'pack' ? pack(data) : data.type === 'run' ? run(data)
+ : data.type === 'pack-preview' ? packPreview(data)
: ['catalog', 'source-read'].includes(data.type) ? source(data) : browse(data))
.catch(error => self.postMessage({ type: 'fatal', error: String(error.message || error) }));
return commandQueue;
diff --git a/static/js/tools.js b/static/js/tools.js
index 4e7e4f4..2a001c3 100644
--- a/static/js/tools.js
+++ b/static/js/tools.js
@@ -2,7 +2,8 @@
window.__esdocToolsCleanup?.();
const app = document.getElementById('tools-app');
if (!app) return;
- const { accepts, safePath, walkEntry, size, explainWarning, explainError, countLabel } = window.ESDocTools;
+ const { accepts, safePath, walkEntry, size, explainWarning, explainError, countLabel,
+ archiveName, packPath, commonFolders } = window.ESDocTools;
const config = JSON.parse(app.dataset.config);
const get = id => document.getElementById('tools-' + id);
const controls = get('controls'), drop = get('drop'), menu = get('add-menu');
@@ -12,15 +13,26 @@
const queue = get('queue'), list = get('file-list'), progress = get('progress');
const progressBar = get('progress-bar'), current = get('current');
const resultPanel = get('result'), downloads = get('downloads'), downloadAll = get('download-all');
+ const exportDialog = get('export-dialog'), exportStart = get('export-start');
+ const exportStatus = get('export-status'), exportErrors = get('export-errors');
+ const downloadButton = 'Скачать';
const warningsPanel = get('warnings'), warningList = get('warning-list');
const browseActions = get('browse-actions'), browseArchive = get('archive');
const browser = get('browser'), browserList = get('browser-list');
const browserPreview = get('browser-preview'), browserStatus = get('browser-status');
const browserSearch = get('browser-search'), browserCrumbs = get('browser-crumbs');
const errorsPanel = get('errors');
+ const packName = get('pack-name'), packStrip = get('pack-strip'), packPrefix = get('pack-prefix');
+ let mode = new URLSearchParams(window.location.search).get('mode') === 'pack' ? 'pack' : 'unpack';
+ let packValid = true, pathPreviewValid = true;
+ let validPackPaths = new Map(), queuePage = 0, queueMatches = 0;
+ const queuePageSize = 100;
+ const queueSearch = browserSearch;
+ const queuePrevious = get('queue-previous'), queueNext = get('queue-next');
const controller = new AbortController();
const on = (element, event, callback) => element.addEventListener(event, callback, { signal: controller.signal });
- const files = new Map();
+ const modeFiles = { pack: new Map(), unpack: new Map() };
+ let files = modeFiles[mode];
const rows = new Map();
const supported = Boolean(window.Worker && window.WebAssembly);
let busy = false, enumerating = false, disposed = false, retryAvailable = false;
@@ -30,6 +42,7 @@
let browseEntries = [], browseFolder = '', browseRequest = 0, previewUrl = null;
let catalogEntries = [], catalogFiles = [], catalogRequest = 0, selectedEntry = null;
let task = '', exportReady = false, catalogSubmitted = false, previewFont = null, clearOnDownload = false;
+ let packDownloadStarted = false, exportFinished = false;
let operation = 0;
let previewPending = null, pendingPreviewKey = '';
const previewCache = new Map();
@@ -50,13 +63,28 @@
previewCacheBytes += bytes;
};
- const say = (message, error = false) => {
- status.textContent = message;
- status.classList.toggle('is-error', error);
- errorsPanel.hidden = true;
- errorsPanel.replaceChildren();
+ const say = (message, error = false, inExport = task === 'export') => {
+ const target = inExport ? exportStatus : status;
+ const diagnostics = inExport ? exportErrors : errorsPanel;
+ target.textContent = message;
+ target.classList.toggle('is-error', error);
+ diagnostics.hidden = true;
+ diagnostics.replaceChildren();
+ };
+ const notifyDownload = () => {
+ exportStatus.textContent = 'Скачивание запущено.';
+ exportStatus.classList.remove('is-error');
};
const clearResult = () => {
+ exportFinished = false;
+ if (exportDialog.open) exportDialog.close();
+ task = '';
+ exportStatus.textContent = '';
+ exportErrors.hidden = true;
+ exportErrors.replaceChildren();
+ get('export-summary').hidden = true;
+ packDownloadStarted = false;
+ get('pack-integration').hidden = true;
previewCache.clear();
previewCacheBytes = 0;
pendingPreviewKey = '';
@@ -73,7 +101,7 @@
selectedEntry = null;
exportReady = false;
clearOnDownload = false;
- browseFolder = '';
+ if (mode !== 'pack') browseFolder = '';
browser.hidden = true;
browseActions.hidden = true;
warningsPanel.hidden = true;
@@ -82,6 +110,8 @@
outputs.length = 0;
chunks = [];
downloads.replaceChildren();
+ downloads.hidden = true;
+ get('export-info').hidden = true;
downloadAll.hidden = true;
resultPanel.hidden = true;
get('result-description').textContent = '';
@@ -109,16 +139,34 @@
const isArchive = entry => /\.rpa$/i.test(entry.path);
const updateControls = () => {
const locked = busy || enumerating || !supported;
- start.disabled = locked || !selected().length;
- start.hidden = !retryAvailable || locked || !selected().length;
+ const hasFiles = files.size > 0;
+ exportStart.hidden = !hasFiles && !exportFinished;
+ exportStart.disabled = locked || (!exportFinished && mode === 'pack' && !packValid);
+ exportStart.innerHTML = exportFinished ? 'Открыть результат' : downloadButton;
+ get('export-close').setAttribute('aria-label', busy && task === 'export' ? 'Отменить обработку и закрыть окно' : 'Закрыть окно');
+ get('pack-settings').hidden = mode !== 'pack' || !hasFiles;
+ get('options').hidden = mode === 'pack' || !hasFiles;
+ get('pack-integration').hidden = mode !== 'pack' || !packDownloadStarted;
+ drop.classList.toggle('is-compact', hasFiles);
+ get('drop-title').textContent = hasFiles ? 'Перетащите сюда ещё файлы или папку' : 'Перетащите файлы или папку сюда';
+ get('drop-help').hidden = hasFiles;
+ drop.setAttribute('aria-describedby', hasFiles ? 'tools-drop-title' : 'tools-drop-help');
+ get('add-label').textContent = hasFiles ? 'Добавить ещё' : 'Добавить файлы или папку';
+ start.disabled = locked || !selected().length || (mode === 'pack' && !packValid);
+ start.hidden = mode === 'pack' || !retryAvailable || locked || !selected().length;
addButton.disabled = locked;
get('clear').disabled = locked || !selected().length;
for (const field of app.querySelectorAll('.tools-options input')) field.disabled = locked;
+ for (const field of [packName, packStrip, packPrefix, get('mode-pack'), get('mode-unpack')]) field.disabled = locked;
for (const button of list.querySelectorAll('button')) button.disabled = locked;
+ queueSearch.disabled = locked;
+ queuePrevious.disabled = locked || queuePage === 0;
+ queueNext.disabled = locked || (queuePage + 1) * queuePageSize >= queueMatches;
cancel.hidden = !busy && !enumerating;
- const cancelHost = busy && task === 'preview' && previewPending ? previewPending : operationActions;
+ const cancelHost = busy && task === 'export' ? get('export-actions')
+ : busy && task === 'preview' && previewPending ? previewPending : operationActions;
if (cancel.parentNode !== cancelHost) cancelHost.appendChild(cancel);
- downloadAll.disabled = locked || (!selected().length && !outputs.length);
+ downloadAll.disabled = locked || (!selected().length && !outputs.length) || (mode === 'pack' && !packValid);
browseArchive.disabled = locked;
browser.setAttribute('aria-busy', String(busy));
for (const button of browserList.querySelectorAll('button')) button.disabled = locked;
@@ -126,22 +174,29 @@
const render = () => {
list.replaceChildren();
rows.clear();
- const visible = selected();
- queue.hidden = !visible.length;
- list.hidden = false;
- get('count').textContent = `${countLabel(visible.length, 'файл', 'файла', 'файлов')} / ${size(visible.reduce((total, entry) => total + entry.file.size, 0))}`;
+ const allFiles = selected();
+ queue.hidden = !allFiles.length;
+ list.hidden = mode === 'pack';
+ get('count').textContent = `${countLabel(allFiles.length, 'файл', 'файла', 'файлов')} / ${size(allFiles.reduce((total, entry) => total + entry.file.size, 0))}`;
+ get('pack-preview-note').hidden = mode !== 'pack' || pathPreviewValid;
+ if (mode === 'pack') { updateControls(); return; }
+ const visible = allFiles.slice(0, 200);
const fragment = document.createDocumentFragment();
// Bound the DOM for folders with thousands of scripts; all queued files
// are still processed even when their rows are not rendered.
- for (const entry of visible.slice(0, 200)) {
+ for (const entry of visible) {
const row = document.createElement('li');
const name = document.createElement('div');
name.className = 'tools-file-name';
- const open = document.createElement('button');
+ const open = document.createElement(mode === 'pack' ? 'span' : 'button');
open.type = 'button';
open.className = 'tools-text-button';
- open.textContent = entry.path;
+ if (mode === 'pack') {
+ open.textContent = validPackPaths.get(entry.path) || 'Путь не рассчитан';
+ open.className = 'tools-pack-path';
+ } else open.textContent = entry.path;
open.addEventListener('click', () => {
+ if (mode === 'pack') return;
browseArchive.value = isArchive(entry) ? String(catalogFiles.findIndex(item => item.path === entry.path)) : 'scripts';
openBrowse();
if (!isArchive(entry)) {
@@ -152,7 +207,8 @@
name.appendChild(open);
const state = document.createElement('span');
state.className = 'tools-file-state';
- state.textContent = 'Добавлен';
+ state.textContent = mode === 'pack' && !validPackPaths.has(entry.path) ? 'Исходный файл: ' + entry.path
+ : mode === 'pack' && open.textContent !== entry.path ? 'Из ' + entry.path : 'Добавлен';
name.appendChild(state);
const bytes = document.createElement('span');
bytes.className = 'tools-file-size';
@@ -162,14 +218,28 @@
remove.className = 'tools-remove';
remove.setAttribute('aria-label', 'Убрать ' + entry.path);
remove.innerHTML = window.ESDocIcons.svg('x', 16);
- remove.addEventListener('click', () => { files.delete(entry.path); clearResult(); retryAvailable = false; render(); loadCatalog(); });
+ remove.addEventListener('click', () => {
+ const focused = document.activeElement === remove;
+ const index = visible.indexOf(entry);
+ files.delete(entry.path);
+ validPackPaths.delete(entry.path);
+ clearResult();
+ retryAvailable = false;
+ if (mode === 'pack') refreshPackFolders();
+ render();
+ loadCatalog();
+ if (focused) {
+ const buttons = list.querySelectorAll('.tools-remove');
+ (buttons[Math.min(index, buttons.length - 1)] || addButton).focus();
+ }
+ });
row.append(name, bytes, remove);
- rows.set(entry.path, state);
+ rows.set(open.textContent, state);
fragment.appendChild(row);
}
- if (visible.length > 200) {
+ if (mode !== 'pack' && allFiles.length > 200) {
const remainder = document.createElement('li');
- remainder.textContent = `И ещё ${countLabel(visible.length - 200, 'файл', 'файла', 'файлов')}. Будут обработаны все.`;
+ remainder.textContent = `И ещё ${countLabel(allFiles.length - 200, 'файл', 'файла', 'файлов')}. Будут обработаны все.`;
fragment.appendChild(remainder);
}
list.appendChild(fragment);
@@ -189,7 +259,7 @@
await collect(entry => {
if (disposed || id !== operation) throw new Error('Добавление отменено.');
entry.path = safePath(entry.path);
- if (!accepts(entry.path, 'combined')) { skipped++; return; }
+ if (mode !== 'pack' && !accepts(entry.path, 'combined')) { skipped++; return; }
if (files.has(entry.path)) { duplicates++; return; }
files.set(entry.path, entry);
added++;
@@ -197,12 +267,13 @@
if (id === operation) say(`Добавлено файлов: ${added}.`
+ (skipped ? ` Другие форматы пропущены: ${skipped}.` : '')
+ (duplicates ? ` Повторные пути пропущены: ${duplicates}.` : '')
- + (!files.size ? ' Выберите .rpyc, .rpymc, .pyc или .rpa.' : ''));
+ + (!files.size ? (mode === 'pack' ? ' Выберите ресурсы для архива.' : ' Выберите .rpyc, .rpymc, .pyc или .rpa.') : ''));
} catch (error) {
if (id === operation) showErrors([error.message]);
} finally {
if (!disposed && id === operation) {
enumerating = false;
+ if (mode === 'pack') refreshPackFolders();
render();
if (added) loadCatalog();
}
@@ -246,8 +317,15 @@
resetWorker();
retryAvailable = false;
progress.hidden = true;
+ if (mode === 'pack') {
+ validPackPaths.clear();
+ queueSearch.value = '';
+ queuePage = 0;
+ refreshPackFolders();
+ }
say('Добавленные файлы удалены.');
render();
+ addButton.focus();
});
const resetWorker = () => {
@@ -268,12 +346,126 @@
const showOutputs = () => {
resultPanel.hidden = false;
start.classList.remove('tools-button-primary');
- downloadAll.hidden = false;
- downloadAll.innerHTML = ''
- + (exportReady ? 'Скачать ещё раз' : 'Скачать всё в ZIP') + '';
- if (outputs.length === 1) outputs[0].link.classList.add('tools-button-primary');
+ const singleResult = exportReady && outputs.length === 1;
+ downloadAll.hidden = exportReady && outputs.length > 1;
+ downloadAll.innerHTML = exportFinished && !exportReady ? 'Повторить обработку' : downloadButton;
+ downloads.hidden = !outputs.length || singleResult;
+ get('export-info').hidden = !singleResult;
+ if (singleResult) get('export-info').textContent = `${outputs[0].name} / ${size(outputs[0].blob.size)}`;
+ if (outputs.length === 1 && mode !== 'pack') outputs[0].link.classList.add('tools-button-primary');
};
const options = () => ({ try_harder: get('try-harder').checked, no_init_offset: get('no-init-offset').checked });
+ const validatePack = () => {
+ let errorMessage = '';
+ let nameError = '', prefixError = '', pathError = '';
+ packName.removeAttribute('aria-invalid');
+ packPrefix.removeAttribute('aria-invalid');
+ try {
+ const filename = archiveName(packName.value);
+ get('pack-filename').textContent = filename;
+ get('pack-install-archive').textContent = 'game/my_mod/' + filename;
+ get('pack-install-string').textContent = JSON.stringify('my_mod/' + filename.slice(0, -4));
+ get('pack-install-example').hidden = false;
+ } catch (error) {
+ nameError = error.message;
+ get('pack-filename').textContent = 'Укажите имя архива';
+ get('pack-install-example').hidden = true;
+ packName.setAttribute('aria-invalid', 'true');
+ }
+ let prefixValid = false;
+ try {
+ packPath('resource', '', packPrefix.value);
+ prefixValid = true;
+ const paths = new Set();
+ const mapped = new Map();
+ for (const entry of selected()) {
+ const path = packPath(entry.path, packStrip.value, packPrefix.value);
+ const canonical = path.normalize('NFC').toLowerCase();
+ if (paths.has(canonical)) throw new Error(`Повторный путь в архиве: ${path}. Уберите один из файлов.`);
+ paths.add(canonical);
+ mapped.set(entry.path, path);
+ }
+ validPackPaths = mapped;
+ pathPreviewValid = true;
+ } catch (error) {
+ pathPreviewValid = false;
+ if (!prefixValid) {
+ packPrefix.setAttribute('aria-invalid', 'true');
+ prefixError = error.message;
+ } else pathError = error.message;
+ }
+ for (const [id, message] of [['pack-name-error', nameError], ['pack-prefix-error', prefixError]]) {
+ get(id).textContent = message;
+ get(id).hidden = !message;
+ }
+ errorMessage = nameError || prefixError || pathError;
+ packValid = !errorMessage;
+ get('pack-error').textContent = pathError || (errorMessage ? 'Исправьте отмеченные поля, чтобы создать архив.' : '');
+ get('pack-error').hidden = packValid;
+ const example = selected()[0];
+ get('pack-example-title').textContent = example ? 'Как изменится путь файла' : 'Пример';
+ get('pack-example-settings').hidden = Boolean(example);
+ get('pack-example-source').textContent = example ? example.path : 'images/bg.png';
+ get('pack-example-target').textContent = example
+ ? pathPreviewValid ? validPackPaths.get(example.path) : 'Исправьте настройки пути'
+ : 'my_mod/images/bg.png';
+ return packValid;
+ };
+ const refreshPackFolders = () => {
+ const previous = packStrip.value;
+ const initial = packStrip.children.length <= 1;
+ const folders = commonFolders(selected().map(entry => entry.path));
+ packStrip.replaceChildren();
+ for (const prefix of ['', ...folders]) {
+ const option = document.createElement('option');
+ option.value = prefix;
+ option.textContent = prefix || 'Ничего не удалять';
+ packStrip.appendChild(option);
+ }
+ packStrip.value = initial && folders.includes('game/') ? 'game/'
+ : folders.includes(previous) ? previous : '';
+ validatePack();
+ };
+ const applyMode = () => {
+ app.dataset.mode = mode;
+ get('pack-clear-label').hidden = mode !== 'pack';
+ get('pack-notice').hidden = mode !== 'pack';
+ get('queue-tools').hidden = mode !== 'pack';
+ get('mode-pack').checked = mode === 'pack';
+ get('mode-unpack').checked = mode !== 'pack';
+ get('pack-help').hidden = mode !== 'pack';
+ get('unpack-help').hidden = mode === 'pack';
+ get('queue-heading').textContent = mode === 'pack' ? 'Файлы в архиве' : 'Добавленные файлы';
+ list.setAttribute('aria-label', mode === 'pack' ? 'Пути внутри архива' : 'Добавленные файлы');
+ get('drop-help').textContent = mode === 'pack' ? 'Изображения, музыка, шрифты и другие файлы' : '.rpa, .rpyc, .rpymc, .pyc';
+ fileInput.accept = mode === 'pack' ? '' : '.rpa,.rpyc,.rpymc,.pyc';
+ if (mode === 'pack') validatePack();
+ };
+ on(queuePrevious, 'click', () => { queuePage--; renderBrowse(); });
+ on(queueNext, 'click', () => { queuePage++; renderBrowse(); });
+ for (const field of [packName, packPrefix, packStrip]) on(field, field === packStrip ? 'change' : 'input', () => {
+ clearResult();
+ validatePack();
+ render();
+ if (selected().length) loadCatalog();
+ });
+ for (const value of ['pack', 'unpack']) on(get('mode-' + value), 'change', () => {
+ if (busy || enumerating || mode === value) return;
+ mode = value;
+ browseFolder = '';
+ browserSearch.value = '';
+ queuePage = 0;
+ files = modeFiles[mode];
+ resetWorker();
+ clearResult();
+ activeFiles = [];
+ retryAvailable = false;
+ fileInput.value = folderInput.value = '';
+ applyMode();
+ render();
+ if (files.size) loadCatalog();
+ else say(mode === 'pack' ? 'Добавьте файлы для нового RPA-архива.' : 'Добавьте архивы или скомпилированные скрипты.');
+ });
const readBrowse = entry => {
if (busy) return;
retryAvailable = false;
@@ -285,6 +477,12 @@
if (button.getAttribute('data-entry-id') === entry.id) button.setAttribute('aria-current', 'true');
else button.removeAttribute('aria-current');
}
+ if (mode === 'pack' && (!/\.(rpy|rpym|txt|md|json|yaml|yml|xml|html|css|js|py|csv|ini|log|sh|bat)$/i.test(entry.path)
+ || entry.size > 2 * 1024 * 1024)) {
+ showPreview({ name: entry.path, file: catalogFiles[entry.source].file, buffer: new ArrayBuffer(0), warnings: [] });
+ updateControls();
+ return;
+ }
const cached = previewCache.get(cacheKey);
if (cached) {
// Touch the entry so the least recently used preview is evicted first.
@@ -313,8 +511,13 @@
updateControls();
try {
ensureWorker();
- if (!catalogSubmitted) submitCatalog();
- worker.postMessage({ type: 'source-read', id: entry.id, options: previewOptions, requestId: ++browseRequest });
+ if (mode === 'pack') {
+ worker.postMessage({ type: 'pack-preview', name: entry.path, file: catalogFiles[entry.source].file,
+ config, requestId: ++browseRequest });
+ } else {
+ if (!catalogSubmitted) submitCatalog();
+ worker.postMessage({ type: 'source-read', id: entry.id, options: previewOptions, requestId: ++browseRequest });
+ }
} catch (error) { fail(error.message); }
};
const renderBrowse = () => {
@@ -327,6 +530,7 @@
button.disabled = folder === browseFolder && !browserSearch.value;
button.addEventListener('click', () => {
browseFolder = folder;
+ queuePage = 0;
browserSearch.value = '';
renderBrowse();
});
@@ -343,7 +547,8 @@
const entries = [];
for (const entry of browseEntries) {
if (query) {
- if (entry.path.toLocaleLowerCase().includes(query)) entries.push(entry);
+ if (entry.path.toLocaleLowerCase().includes(query)
+ || mode === 'pack' && entry.originalPath.toLocaleLowerCase().includes(query)) entries.push(entry);
} else if (entry.path.startsWith(browseFolder)) {
const rest = entry.path.slice(browseFolder.length);
const slash = rest.indexOf('/');
@@ -370,30 +575,58 @@
item.append(button, meta);
browserList.appendChild(item);
};
- for (const folder of [...folders].sort((a, b) => a.localeCompare(b, 'ru'))) {
- row(folder + '/', 'папка', () => { browseFolder += folder + '/'; renderBrowse(); }, 'folder');
+ const folderNames = [...folders].sort((a, b) => a.localeCompare(b, 'ru'));
+ if (mode === 'pack') entries.sort((a, b) => a.path.localeCompare(b.path, 'ru'));
+ queueMatches = folderNames.length + entries.length;
+ const pageSize = mode === 'pack' ? queuePageSize : 500;
+ queuePage = Math.min(queuePage, Math.max(0, Math.ceil(queueMatches / pageSize) - 1));
+ const offset = mode === 'pack' ? queuePage * pageSize : 0;
+ const visibleFolders = mode === 'pack' ? folderNames.slice(offset, offset + pageSize) : folderNames;
+ const visibleEntries = entries.slice(mode === 'pack' ? Math.max(0, offset - folderNames.length) : 0,
+ mode === 'pack' ? Math.max(0, offset + pageSize - folderNames.length) : 500);
+ const addRemove = (originalPaths, label, title) => {
+ const remove = document.createElement('button');
+ remove.type = 'button';
+ remove.className = 'tools-icon-button tools-remove-source';
+ remove.title = title;
+ remove.setAttribute('aria-label', label);
+ remove.innerHTML = '';
+ remove.disabled = busy;
+ remove.addEventListener('click', () => {
+ if (busy || enumerating) return;
+ const focused = document.activeElement === remove;
+ const index = Array.from(browserList.querySelectorAll('.tools-remove-source')).indexOf(remove);
+ for (const originalPath of originalPaths) {
+ files.delete(originalPath);
+ if (mode === 'pack') validPackPaths.delete(originalPath);
+ }
+ clearResult();
+ if (mode === 'pack') refreshPackFolders();
+ render();
+ loadCatalog();
+ if (focused && mode === 'pack') {
+ const buttons = files.size ? browserList.querySelectorAll('.tools-remove-source') : [];
+ (buttons[Math.min(index, buttons.length - 1)] || addButton).focus();
+ }
+ });
+ browserList.lastElementChild.appendChild(remove);
+ };
+ for (const folder of visibleFolders) {
+ row(folder + '/', 'папка', () => { browseFolder += folder + '/'; queuePage = 0; renderBrowse(); }, 'folder');
+ if (mode === 'pack') {
+ const folderPath = browseFolder + folder + '/';
+ const originalPaths = browseEntries.filter(entry => entry.path.startsWith(folderPath)).map(entry => entry.originalPath);
+ addRemove(originalPaths, 'Убрать папку ' + folderPath + ' и все вложенные файлы', 'Убрать папку и все вложенные файлы');
+ }
}
- for (const entry of entries.slice(0, 500)) {
+ for (const entry of visibleEntries) {
const kind = /\.(rpyc|rpymc|pyc|rpy|rpym|py|js|json|css|txt)$/i.test(entry.path) ? 'code'
: /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(entry.path) ? 'image'
: /\.(ogg|mp3|wav|opus|flac|mp4|webm)$/i.test(entry.path) ? 'media' : 'file';
row(query ? entry.path : entry.path.slice(browseFolder.length), size(entry.size), () => readBrowse(entry), kind,
selectedEntry?.id === entry.id, entry.id);
- if (catalogFiles[entry.source]?.path === entry.path && !isArchive(catalogFiles[entry.source]) && files.has(entry.path)) {
- const remove = document.createElement('button');
- remove.type = 'button';
- remove.className = 'tools-icon-button tools-remove-source';
- remove.title = 'Убрать файл';
- remove.setAttribute('aria-label', 'Убрать ' + entry.path);
- remove.innerHTML = '';
- remove.disabled = busy;
- remove.addEventListener('click', () => {
- files.delete(entry.path);
- clearResult();
- render();
- loadCatalog();
- });
- browserList.lastElementChild.appendChild(remove);
+ if (mode === 'pack' || catalogFiles[entry.source]?.path === entry.path && !isArchive(catalogFiles[entry.source]) && files.has(entry.path)) {
+ addRemove([mode === 'pack' ? entry.originalPath : entry.path], 'Убрать ' + entry.path, 'Убрать файл');
}
}
if (!folders.size && !entries.length) {
@@ -403,7 +636,12 @@
browserList.appendChild(empty);
}
browserStatus.textContent = countLabel(folders.size + entries.length, 'элемент', 'элемента', 'элементов')
- + (entries.length > 500 ? ' (показаны первые 500; уточните поиск)' : '');
+ + (mode !== 'pack' && entries.length > 500 ? ' (показаны первые 500; уточните поиск)' : '');
+ if (mode === 'pack') {
+ get('queue-pages').hidden = queueMatches <= pageSize;
+ get('queue-range').textContent = queueMatches ? `${offset + 1}–${Math.min(offset + pageSize, queueMatches)} из ${queueMatches}` : '';
+ updateControls();
+ }
};
const renderWarnings = (panel, summary, list, count, messages) => {
panel.hidden = !count;
@@ -469,7 +707,7 @@
const mime = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif',
svg: 'image/svg+xml', ogg: 'audio/ogg', mp3: 'audio/mpeg', wav: 'audio/wav', opus: 'audio/ogg', flac: 'audio/flac',
mp4: 'video/mp4', webm: 'video/webm', ttf: 'font/ttf', otf: 'font/otf', woff: 'font/woff', woff2: 'font/woff2' }[extension] || 'application/octet-stream';
- previewUrl = URL.createObjectURL(new Blob([data.buffer], { type: mime }));
+ previewUrl = URL.createObjectURL(data.file || new Blob([data.buffer], { type: mime }));
browserPreview.replaceChildren();
const title = document.createElement('h4');
title.textContent = path;
@@ -519,7 +757,7 @@
specimen.style.fontFamily = 'tools-specimen';
}
}).catch(() => { if (previewFont === font) browserStatus.textContent = 'Не удалось открыть шрифт.'; });
- } else if (/\.(rpy|rpym|txt|md|json|yaml|yml|xml|html|css|js|py|csv|ini|log|sh|bat)$/i.test(path) && data.buffer.byteLength > 2 * 1024 * 1024) {
+ } else if (/\.(rpy|rpym|txt|md|json|yaml|yml|xml|html|css|js|py|csv|ini|log|sh|bat)$/i.test(path) && (data.file?.size || data.buffer.byteLength) > 2 * 1024 * 1024) {
const note = document.createElement('p');
note.textContent = 'Файл слишком большой для предпросмотра. Его можно скачать целиком.';
browserPreview.appendChild(note);
@@ -587,10 +825,12 @@
previewUrl = null;
const source = Number(browseArchive.value) || 0;
const scripts = browseArchive.value === 'scripts';
- browseEntries = catalogEntries.filter(entry => scripts
+ browseEntries = mode === 'pack' ? catalogEntries : catalogEntries.filter(entry => scripts
? catalogFiles[entry.source] && !isArchive(catalogFiles[entry.source]) : entry.source === source);
- browseFolder = '';
- browserSearch.value = '';
+ if (mode !== 'pack') {
+ browseFolder = '';
+ browserSearch.value = '';
+ } else if (!browseEntries.some(entry => entry.path.startsWith(browseFolder))) browseFolder = '';
browserList.replaceChildren();
browserPreview.replaceChildren();
const empty = document.createElement('p');
@@ -598,11 +838,11 @@
empty.textContent = 'Файл не выбран';
browserPreview.appendChild(empty);
browser.hidden = false;
- get('browser-title').textContent = scripts ? 'Сценарии' : catalogFiles[source]?.path || 'Файлы';
+ get('browser-title').textContent = mode === 'pack' ? 'Пути внутри архива' : scripts ? 'Скрипты' : catalogFiles[source]?.path || 'Файлы';
renderBrowse();
};
on(browseArchive, 'change', openBrowse);
- on(browserSearch, 'input', renderBrowse);
+ on(browserSearch, 'input', () => { queuePage = 0; renderBrowse(); });
const errorDiagnostic = error => {
const block = document.createElement('div');
block.className = 'tools-diagnostic';
@@ -630,16 +870,17 @@
block.append(title, explanation, details);
return block;
};
- const showErrors = messages => {
+ const showErrors = (messages, inExport = task === 'export') => {
const errors = messages.slice(0, 100).map(message => explainError(message));
+ const diagnostics = inExport ? exportErrors : errorsPanel;
say(messages.length === 1 ? errors[0].title + (errors[0].path ? `: ${errors[0].path}` : '') + '.'
- : `Не удалось обработать файлов: ${messages.length.toLocaleString('ru-RU')}. Причины указаны ниже.`, true);
- errorsPanel.hidden = false;
- for (const error of errors) errorsPanel.appendChild(errorDiagnostic(error));
+ : `Не удалось обработать файлов: ${messages.length.toLocaleString('ru-RU')}. Причины указаны ниже.`, true, inExport);
+ diagnostics.hidden = false;
+ for (const error of errors) diagnostics.appendChild(errorDiagnostic(error));
if (messages.length > 100) {
const remainder = document.createElement('p');
remainder.textContent = `Показаны причины первых 100 ошибок из ${messages.length.toLocaleString('ru-RU')}. Обрабатывайте файлы меньшими наборами, чтобы увидеть остальные.`;
- errorsPanel.appendChild(remainder);
+ diagnostics.appendChild(remainder);
}
};
const endPreview = message => {
@@ -667,20 +908,26 @@
};
const fail = message => {
const interrupted = task;
+ if (interrupted === 'export') {
+ exportFinished = true;
+ get('export-title').textContent = 'Не удалось обработать файлы';
+ }
task = '';
catalogRequest++;
browseRequest++;
chunks = [];
resetWorker();
- retryAvailable = true;
+ retryAvailable = interrupted !== 'export';
finish();
- if (outputs.length) {
+ if (outputs.length && interrupted === 'export') {
showOutputs();
+ get('export-summary').hidden = false;
get('result-description').textContent = `Готовых архивов: ${outputs.length}.`;
}
if (interrupted === 'preview' || interrupted === 'catalog') endPreview('Просмотр прерван. Повторите действие или выберите другой файл.');
browserStatus.textContent = '';
- showErrors([message]);
+ showErrors([message], interrupted === 'export');
+ if (interrupted === 'export') showOutputs();
};
on(cancel, 'click', () => {
const interrupted = task;
@@ -691,7 +938,18 @@
catalogRequest++;
browseRequest++;
chunks = [];
- retryAvailable = true;
+ retryAvailable = interrupted !== 'export';
+ if (interrupted === 'export') {
+ exportFinished = outputs.length > 0;
+ exportReady = false;
+ get('export-title').textContent = 'Обработка отменена';
+ if (exportFinished) {
+ get('export-summary').hidden = false;
+ get('result-description').textContent = `Готовых архивов: ${outputs.length}.`;
+ showOutputs();
+ }
+ if (exportDialog.open) exportDialog.close();
+ }
finish();
render();
if (interrupted === 'preview' || interrupted === 'catalog') endPreview('Просмотр отменён. Выберите файл, чтобы открыть его ещё раз.');
@@ -740,6 +998,11 @@
if (data.type === 'loading') say(data.text);
else if (data.type === 'ready') { say('Инструменты готовы. Обработка файлов…'); processedFiles = 0; progressBar.max = activeFiles.length; progressBar.value = 0; }
else if (data.type === 'chunk') chunks.push(new Blob([data.buffer]));
+ else if (data.type === 'pack-progress') {
+ current.textContent = `${data.path} / ${size(data.current)} из ${size(data.total)}`;
+ progressBar.max = data.total || 1;
+ progressBar.value = data.current;
+ }
else if (data.type === 'entry') {
current.textContent = `${data.path} / ${data.current} из ${data.total}`;
progressBar.value = processedFiles + data.current / data.total;
@@ -751,21 +1014,32 @@
state.classList.toggle('is-error', data.state === 'error');
}
current.textContent = data.path;
- if (data.state !== 'working') progressBar.value = ++processedFiles;
+ if (data.state !== 'working' && mode !== 'pack') progressBar.value = ++processedFiles;
} else if (data.type === 'output-start') chunks = [];
else if (data.type === 'output') {
if (data.result.written) {
- const blob = new Blob(chunks, { type: 'application/zip' });
+ const blob = new Blob(chunks, { type: data.mime || 'application/zip' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.className = 'tools-button';
link.href = url;
link.download = data.name;
- link.textContent = `${data.name} / ${size(blob.size)}`;
+ link.innerHTML = '';
+ const label = document.createElement('span');
+ label.textContent = 'Скачать ' + data.name;
+ link.appendChild(label);
+ link.title = `${data.name} / ${size(blob.size)}`;
// Downloads do not expose a completion event to the page.
// Clear source selections when the user starts a download;
// keep result URLs available for retries and remaining ZIPs.
on(link, 'click', () => {
+ if (mode === 'pack' && exportReady) {
+ packDownloadStarted = true;
+ get('pack-install-archive').textContent = 'game/my_mod/' + data.name;
+ get('pack-install-string').textContent = JSON.stringify('my_mod/' + data.name.slice(0, -4));
+ updateControls();
+ }
+ notifyDownload();
if (!clearOnDownload) return;
for (const entry of activeFiles) files.delete(entry.path);
activeFiles = [];
@@ -780,37 +1054,70 @@
} else if (data.type === 'fatal') fail(data.error);
else if (data.type === 'done') {
const result = data.result;
+ exportFinished = true;
+ get('export-summary').hidden = false;
+ get('export-title').textContent = result.failed || !outputs.length
+ ? outputs.length ? 'Готово с ошибками' : 'Не удалось обработать файлы' : 'Готово';
chunks = [];
retryAvailable = false;
- clearOnDownload = result.failed === 0;
- exportReady = result.failed === 0;
+ clearOnDownload = result.failed === 0 && mode !== 'pack';
+ exportReady = result.failed === 0 && outputs.length > 0;
finish();
+ if (mode === 'pack') render();
resultPanel.hidden = false;
if (outputs.length) {
showOutputs();
- get('result-description').textContent = `Обработано: ${result.succeeded}.`
+ get('result-description').textContent = (mode === 'pack' ? `Файлов в RPA: ${result.written}.` : `Обработано: ${result.succeeded}.`)
+ (result.written !== result.succeeded ? ` Файлов в результате: ${result.written}.` : '')
+ (result.failed ? ` Ошибок: ${result.failed}.` : '');
renderWarnings(warningsPanel, get('warnings-summary'), warningList,
result.warnings, result.warning_details || []);
if (result.failed) showErrors(result.errors.length ? result.errors : ['Нет готовых файлов.']);
- else say('Готово.');
+ else say('');
for (const output of outputs) output.link.click();
} else {
get('result-description').textContent = 'Файлы не удалось обработать.';
showErrors(result.errors.length ? result.errors : ['Нет готовых файлов.']);
+ showOutputs();
}
+ task = '';
}
};
on(downloadAll, 'click', () => {
if (exportReady) for (const output of outputs) output.link.click();
else processFiles();
});
+ const openExportDialog = () => {
+ closeMenu();
+ if (!exportDialog.open) exportDialog.showModal();
+ get('export-close').focus();
+ };
+ const closeExportDialog = () => {
+ if (busy && task === 'export') cancel.click();
+ if (exportDialog.open) exportDialog.close();
+ };
+ on(exportStart, 'click', () => {
+ if (busy || enumerating) return;
+ if (exportFinished) openExportDialog();
+ else processFiles();
+ });
+ on(get('export-close'), 'click', closeExportDialog);
+ on(exportDialog, 'cancel', event => { event.preventDefault(); closeExportDialog(); });
+ on(exportDialog, 'click', event => {
+ if (event.target !== exportDialog) return;
+ const bounds = exportDialog.getBoundingClientRect();
+ if (event.clientX < bounds.left || event.clientX > bounds.right
+ || event.clientY < bounds.top || event.clientY > bounds.bottom) closeExportDialog();
+ });
+ on(exportDialog, 'close', () => { if (!disposed) (exportStart.hidden ? addButton : exportStart).focus(); });
const ensureWorker = () => {
if (worker) return;
worker = new Worker(config.worker, { type: 'module', name: 'esdoc-renpy-tools' });
worker.onmessage = handleMessage;
- worker.onerror = () => fail('Не удалось запустить инструменты. Обновите страницу или попробуйте другой браузер.');
+ worker.onerror = event => {
+ const location = event.filename ? `\n${event.filename}:${event.lineno || 0}:${event.colno || 0}` : '';
+ fail('Не удалось запустить инструменты.' + (event.message ? `\n${event.message}${location}` : ' Обновите страницу или попробуйте другой браузер.'));
+ };
worker.onmessageerror = () => fail('Браузер не смог передать файлы инструменту. Попробуйте добавить меньше файлов.');
};
const submitCatalog = () => {
@@ -819,6 +1126,20 @@
};
const loadCatalog = () => {
if (!supported || busy || enumerating || !selected().length) return;
+ if (mode === 'pack') {
+ refreshPackFolders();
+ validatePack();
+ resultPanel.hidden = false;
+ downloadAll.hidden = false;
+ catalogFiles = selected();
+ catalogEntries = catalogFiles.map((entry, source) => ({ id: entry.path,
+ originalPath: entry.path, path: validPackPaths.get(entry.path) || entry.path,
+ size: entry.file.size, source }));
+ openBrowse();
+ showOutputs();
+ updateControls();
+ return;
+ }
catalogFiles = selected();
retryAvailable = false;
const archives = catalogFiles.map((entry, source) => ({ entry, source })).filter(({ entry }) => isArchive(entry));
@@ -830,7 +1151,7 @@
if (scripts.length) {
const option = document.createElement('option');
option.value = 'scripts';
- option.textContent = 'Сценарии';
+ option.textContent = 'Скрипты';
browseArchive.appendChild(option);
}
for (const { source, entry } of archives) {
@@ -844,7 +1165,7 @@
get('archive-label').hidden = browseActions.hidden;
resultPanel.hidden = false;
downloadAll.hidden = false;
- downloadAll.innerHTML = 'Скачать всё в ZIP';
+ downloadAll.innerHTML = downloadButton;
openBrowse();
if (!archives.length) {
updateControls();
@@ -862,12 +1183,22 @@
};
const processFiles = () => {
if (!supported || busy || enumerating || !selected().length) return;
+ if (mode === 'pack' && !validatePack()) return;
for (const output of outputs) URL.revokeObjectURL(output.url);
outputs.length = 0;
downloads.replaceChildren();
+ downloads.hidden = true;
+ get('export-info').hidden = true;
warningsPanel.hidden = true;
+ downloadAll.hidden = true;
chunks = [];
exportReady = false;
+ exportFinished = false;
+ get('export-summary').hidden = true;
+ get('export-title').textContent = mode === 'pack' ? 'Упаковка в RPA' : 'Обработка файлов';
+ status.textContent = '';
+ errorsPanel.hidden = true;
+ packDownloadStarted = false;
clearOnDownload = false;
retryAvailable = false;
busy = true;
@@ -878,10 +1209,12 @@
progressBar.removeAttribute('value');
current.textContent = '';
say('Подготовка к обработке…');
+ openExportDialog();
try {
ensureWorker();
- worker.postMessage({ type: 'run', config, mode: 'combined', files: activeFiles,
- options: options() });
+ if (mode === 'pack') worker.postMessage({ type: 'pack', config, name: archiveName(packName.value),
+ files: activeFiles.map(entry => ({ file: entry.file, path: packPath(entry.path, packStrip.value, packPrefix.value) })) });
+ else worker.postMessage({ type: 'run', config, mode: 'combined', files: activeFiles, options: options() });
} catch (error) { fail(error.message); }
};
on(start, 'click', loadCatalog);
@@ -889,10 +1222,14 @@
for (const output of outputs) URL.revokeObjectURL(output.url);
outputs.length = 0;
downloads.replaceChildren();
+ downloads.hidden = true;
+ get('export-info').hidden = true;
exportReady = false;
+ exportFinished = false;
+ if (exportDialog.open) exportDialog.close();
downloadAll.hidden = !selected().length;
updateControls();
- if (selected().length) downloadAll.innerHTML = 'Скачать всё в ZIP';
+ if (selected().length) downloadAll.innerHTML = downloadButton;
if (option === get('try-harder')) {
catalogSubmitted = false;
if (selected().some(isArchive)) {
@@ -942,7 +1279,10 @@
controller.abort();
resetWorker();
clearResult();
- files.clear();
+ modeFiles.pack.clear();
+ modeFiles.unpack.clear();
+ validPackPaths.clear();
+ activeFiles = [];
window.__esdocToolsCleanup = null;
};
if (!supported) {
@@ -952,6 +1292,6 @@
return;
}
controls.hidden = false;
- get('drop-help').textContent = '.rpa, .rpyc, .rpymc, .pyc';
+ applyMode();
render();
})();
diff --git a/static/tools/engine.py b/static/tools/engine.py
index f5a4fa3..39bf751 100644
--- a/static/tools/engine.py
+++ b/static/tools/engine.py
@@ -18,6 +18,7 @@
from unrpa import UnRPA
CHUNK = 256 * 1024
+MAX_PREVIEW_BYTES = 64 * 1024 * 1024
def safe_path(value):
@@ -304,15 +305,22 @@ def read(self, key, options):
warnings.append(f'Исходный файл {target} уже есть в архиве, сохранён без замены.')
path = target
compiled = False
+ size = entry['size'] if segments is None else sum(length for _, length, _ in segments)
+ if size > MAX_PREVIEW_BYTES:
+ raise ValueError('Файл больше 64 МБ. Скачайте архив для просмотра.')
with open(self.files[entry['source']]['source'], 'rb') as source:
if segments is None:
- data = source.read()
+ data = source.read(MAX_PREVIEW_BYTES + 1)
else:
data = b''.join(iter(Segments(source, segments).read, b''))
+ if len(data) > MAX_PREVIEW_BYTES:
+ raise ValueError('Файл больше 64 МБ. Скачайте архив для просмотра.')
if compiled:
data, logs = pyc_decompiler.decompile(data) if path.lower().endswith('.pyc') else decompile(data, options)
warnings.extend(str(log) for log in logs)
path = target
+ if len(data) > MAX_PREVIEW_BYTES:
+ raise ValueError('Файл больше 64 МБ. Скачайте архив для просмотра.')
result = (path, data, warnings)
# Cache the current preview only, not every expanded archive asset.
self.cached = (cache_key, result)
diff --git a/static/tools/rpa.py b/static/tools/rpa.py
new file mode 100644
index 0000000..1fd61fb
--- /dev/null
+++ b/static/tools/rpa.py
@@ -0,0 +1,74 @@
+"""Stream RPA-3.0 resources with a Python 2 compatible index.
+
+Format reference: renpy/renpy launcher/game/archiver.rpy and renpy/loader.py.
+Files are copied unchanged; only the index is compressed.
+"""
+import os
+import pickle
+import unicodedata
+import zlib
+
+CHUNK = 256 * 1024
+KEY = 0x42424242
+HEADER_SIZE = 34
+
+
+def safe_path(value):
+ if not isinstance(value, str):
+ raise ValueError('Путь файла должен быть строкой.')
+ value = value.replace('\\', '/')
+ if (not value or value.startswith('/') or ':' in value
+ or any(ord(c) < 32 for c in value)
+ or any(part in ('', '.', '..') for part in value.split('/'))
+ or len(value) > 4096):
+ raise ValueError(f'Недопустимый путь: {value!r}.')
+ return value
+
+
+def pack(files, emit, notify=lambda event: None):
+ """Validate all paths/sizes before emitting a forward-only archive stream."""
+ if not files:
+ raise ValueError('Добавьте файлы для упаковки.')
+ entries = []
+ names = set()
+ for entry in files:
+ path = safe_path(entry['path'])
+ canonical = unicodedata.normalize('NFC', path).lower()
+ if canonical in names:
+ raise ValueError(f'Повторный путь в архиве: {path}. Измените пути или уберите файл.')
+ names.add(canonical)
+ entries.append((path, entry['source'], os.path.getsize(entry['source'])))
+ for path, _, _ in entries:
+ parts = unicodedata.normalize('NFC', path).lower().split('/')
+ if any('/'.join(parts[:i]) in names for i in range(1, len(parts))):
+ raise ValueError(f'Папка совпадает с именем файла: {path}. Измените пути.')
+ entries.sort(key=lambda entry: entry[0])
+ offset = HEADER_SIZE
+ index = {}
+ for path, _, length in entries:
+ # Two-element records need no byte-string pickle globals on Python 2.
+ index[path] = [(offset ^ KEY, length ^ KEY)]
+ offset += length
+ compressed_index = zlib.compress(pickle.dumps(index, protocol=2))
+ emit(f'RPA-3.0 {offset:016x} {KEY:08x}\n'.encode('ascii'))
+ total_bytes = offset - HEADER_SIZE
+ copied = 0
+ for path, source, length in entries:
+ notify({'type': 'file', 'path': path, 'state': 'working'})
+ remaining = length
+ with open(source, 'rb') as resource:
+ while remaining:
+ data = resource.read(min(CHUNK, remaining))
+ if not data:
+ raise ValueError(f'Не удалось дочитать файл: {path}. Добавьте его заново.')
+ emit(data)
+ copied += len(data)
+ remaining -= len(data)
+ notify({'type': 'pack-progress', 'path': path, 'current': copied, 'total': total_bytes})
+ if resource.read(1):
+ raise ValueError(f'Размер файла изменился: {path}. Добавьте его заново.')
+ notify({'type': 'file', 'path': path, 'state': 'done'})
+ for start in range(0, len(compressed_index), CHUNK):
+ emit(compressed_index[start:start + CHUNK])
+ return {'succeeded': len(entries), 'written': len(entries), 'failed': 0,
+ 'warnings': 0, 'warning_details': [], 'errors': []}
diff --git a/templates/partials/head.html b/templates/partials/head.html
index 054d571..93c237b 100644
--- a/templates/partials/head.html
+++ b/templates/partials/head.html
@@ -59,5 +59,9 @@
{% endif %}
+{% if with_characters_js %}
+
+
+{% endif %}
{% if DEBUG %}
{% endif %}
diff --git a/templates/tools.html b/templates/tools.html
index 1e2860a..1633e4d 100644
--- a/templates/tools.html
+++ b/templates/tools.html
@@ -14,14 +14,19 @@
Инструменты