dsh-spell-check 0.0.0-stage → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +125 -2
- package/README.zh-CN.md +110 -0
- package/assets/banner-dark.png +0 -0
- package/assets/banner-light.png +0 -0
- package/assets/icon.png +0 -0
- package/assets/menu.png +0 -0
- package/assets/strip.gif +0 -0
- package/assets/strip.png +0 -0
- package/assets/words.txt +20000 -0
- package/cordis.patch.yml +13 -0
- package/dsh.plugin.json +9 -0
- package/package.json +88 -4
- package/screenshots.json +4 -0
- package/scripts/build-client.mjs +102 -0
- package/scripts/check-styles.mjs +70 -0
- package/scripts/make-banner.py +191 -0
- package/scripts/make-media.py +120 -0
- package/scripts/test-client.mjs +402 -0
- package/scripts/test-engine.mjs +101 -0
- package/scripts/test-host.mjs +82 -0
- package/src/client.js +2327 -0
- package/src/engine.js +981 -0
- package/src/index.js +221 -0
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# dsh-spell-check bundle patch: mount the host half as a NEW loader row.
|
|
2
|
+
#
|
|
3
|
+
# This must be an `insert:` list, not a top-level `- id:` entry. A top-level
|
|
4
|
+
# entry with an `id` targets an EXISTING row to override it, so dsh aborts with
|
|
5
|
+
# `patch: entry "spell-check" not found` before the plugin ever loads.
|
|
6
|
+
#
|
|
7
|
+
# `name` MUST equal the package name: the loader imports that specifier, and
|
|
8
|
+
# `id` is the plugin's storage/prompt/effect key (keep it stable once released).
|
|
9
|
+
- insert:
|
|
10
|
+
- id: spell-check
|
|
11
|
+
name: 'dsh-spell-check'
|
|
12
|
+
config:
|
|
13
|
+
enabled: true
|
package/dsh.plugin.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,90 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-spell-check",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Chrome-style spell check for the dsh composer: the misspelled word under the caret gets a suggestion strip, and Tab accepts it. Local only — no network, no LLM, no telemetry.",
|
|
5
|
+
"icon": "assets/icon.png",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.js",
|
|
10
|
+
"./engine": "./src/engine.js",
|
|
11
|
+
"./client": "./src/client.js",
|
|
12
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src/index.js",
|
|
17
|
+
"src/engine.js",
|
|
18
|
+
"src/client.js",
|
|
19
|
+
"assets/words.txt",
|
|
20
|
+
"assets/banner-light.png",
|
|
21
|
+
"assets/strip.gif",
|
|
22
|
+
"assets/strip.png",
|
|
23
|
+
"assets/menu.png",
|
|
24
|
+
"assets/banner-dark.png",
|
|
25
|
+
"assets/icon.png",
|
|
26
|
+
"cordis.patch.yml",
|
|
27
|
+
"dsh.plugin.json",
|
|
28
|
+
"scripts/test-engine.mjs",
|
|
29
|
+
"scripts/test-host.mjs",
|
|
30
|
+
"scripts/test-client.mjs",
|
|
31
|
+
"scripts/build-client.mjs",
|
|
32
|
+
"scripts/check-styles.mjs",
|
|
33
|
+
"scripts/make-banner.py",
|
|
34
|
+
"scripts/make-media.py",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE",
|
|
37
|
+
"screenshots.json"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "node scripts/build-client.mjs",
|
|
41
|
+
"test": "node scripts/check-styles.mjs && node scripts/test-engine.mjs && node scripts/test-host.mjs && node scripts/test-client.mjs",
|
|
42
|
+
"test:engine": "node scripts/test-engine.mjs",
|
|
43
|
+
"test:host": "node scripts/test-host.mjs",
|
|
44
|
+
"test:client": "node scripts/test-client.mjs",
|
|
45
|
+
"test:styles": "node scripts/check-styles.mjs"
|
|
46
|
+
},
|
|
47
|
+
"dsh": {
|
|
48
|
+
"bundle": {
|
|
49
|
+
"patch": "./cordis.patch.yml"
|
|
50
|
+
},
|
|
51
|
+
"client": {
|
|
52
|
+
"platform": "web",
|
|
53
|
+
"main": "./src/client.js"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=22"
|
|
58
|
+
},
|
|
59
|
+
"keywords": [
|
|
60
|
+
"dsh-plugin",
|
|
61
|
+
"dsh",
|
|
62
|
+
"deepseek-harness",
|
|
63
|
+
"spellcheck",
|
|
64
|
+
"spell-check",
|
|
65
|
+
"autocorrect",
|
|
66
|
+
"typo",
|
|
67
|
+
"offline"
|
|
68
|
+
],
|
|
69
|
+
"repository": {
|
|
70
|
+
"type": "git",
|
|
71
|
+
"url": "git+https://github.com/adithyanraj03/dsh-spell-check.git"
|
|
72
|
+
},
|
|
73
|
+
"homepage": "https://adithyanraj03.github.io/dsh-spell-check/",
|
|
74
|
+
"bugs": {
|
|
75
|
+
"url": "https://github.com/adithyanraj03/dsh-spell-check/issues"
|
|
76
|
+
},
|
|
77
|
+
"author": "Adithya N Raj (adithyanraj03)",
|
|
78
|
+
"license": "MIT",
|
|
79
|
+
"peerDependencies": {
|
|
80
|
+
"@deepseek-ai/dsh-typert-protocol": ">=0.1.0-rc.6"
|
|
81
|
+
},
|
|
82
|
+
"peerDependenciesMeta": {
|
|
83
|
+
"@deepseek-ai/dsh-typert-protocol": {
|
|
84
|
+
"optional": true
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"devDependencies": {
|
|
88
|
+
"@deepseek-ai/dsh-typert-protocol": "0.2.2-rc.1"
|
|
89
|
+
}
|
|
90
|
+
}
|
package/screenshots.json
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate the browser half's embedded engine + dictionary.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
*
|
|
6
|
+
* The client half runs in the browser and cannot read files, so to correct
|
|
7
|
+
* without a round trip it needs the dictionary INSIDE its bundle. Embedding it
|
|
8
|
+
* by hand would mean a second copy of the rules that drifts from src/engine.js
|
|
9
|
+
* — the exact failure this avoids.
|
|
10
|
+
*
|
|
11
|
+
* So src/engine.js stays the single source of truth: this script strips its
|
|
12
|
+
* `export` keywords into a plain script block, prepends the word list, and
|
|
13
|
+
* splices both into the marked region of src/client.js. The host half imports
|
|
14
|
+
* src/engine.js directly, so both halves run the SAME code.
|
|
15
|
+
*
|
|
16
|
+
* Run with `node scripts/build-client.mjs` (also `npm run build`).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { dirname, join } from 'node:path'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
|
|
23
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
24
|
+
const root = join(here, '..')
|
|
25
|
+
|
|
26
|
+
const BEGIN = '/* >>> GENERATED ENGINE BLOCK'
|
|
27
|
+
const END = '/* <<< GENERATED ENGINE BLOCK <<< */'
|
|
28
|
+
|
|
29
|
+
const words = readFileSync(join(root, 'assets', 'words.txt'), 'utf8')
|
|
30
|
+
.split(/\r?\n/)
|
|
31
|
+
.map((line) => line.trim())
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* An OPTIONAL second word list, for swapping in a different dictionary.
|
|
36
|
+
*
|
|
37
|
+
* SCOWL, a Hunspell `en_US.dic`, or a wordfreq export all reduce to "one word
|
|
38
|
+
* per line", so dropping a file here is enough to change the dictionary without
|
|
39
|
+
* touching this plugin. It goes AFTER the shipped list, which keeps the common
|
|
40
|
+
* words ahead of it in the ranking.
|
|
41
|
+
*/
|
|
42
|
+
const EXTRA = join(root, 'assets', 'words-extra.txt')
|
|
43
|
+
let extra = []
|
|
44
|
+
if (existsSync(EXTRA)) {
|
|
45
|
+
extra = readFileSync(EXTRA, 'utf8')
|
|
46
|
+
.split(/\r?\n/)
|
|
47
|
+
.map((line) => line.trim().toLowerCase())
|
|
48
|
+
// Hunspell dictionaries carry an affix marker after a slash (walk/ed) and
|
|
49
|
+
// SCOWL uses a similar suffix; only the plain headword is a word here.
|
|
50
|
+
.map((line) => line.split(/[/\s]/)[0])
|
|
51
|
+
.filter((line) => /^[a-z][a-z'-]{1,24}$/.test(line))
|
|
52
|
+
console.log(`build: extra dictionary ${extra.length} words from assets/words-extra.txt`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const all = [...new Set([...words, ...extra])]
|
|
56
|
+
|
|
57
|
+
if (words.length === 0) {
|
|
58
|
+
console.error('build: assets/words.txt is empty — refusing to write a bundle with no dictionary')
|
|
59
|
+
process.exit(1)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* src/engine.js as a plain script fragment.
|
|
64
|
+
*
|
|
65
|
+
* Only three export shapes are used in that file, and all three are line-initial
|
|
66
|
+
* by convention, so this stays a formatting rule rather than a parser.
|
|
67
|
+
*/
|
|
68
|
+
const engineSource = readFileSync(join(root, 'src', 'engine.js'), 'utf8')
|
|
69
|
+
.split(/\r?\n/)
|
|
70
|
+
.filter((line) => !line.startsWith('export default'))
|
|
71
|
+
.map((line) => (line.startsWith('export ') ? line.slice('export '.length) : line))
|
|
72
|
+
.join('\n')
|
|
73
|
+
|
|
74
|
+
const block = [
|
|
75
|
+
BEGIN + ' — run `npm run build`. Do not edit between the markers. >>> */',
|
|
76
|
+
'',
|
|
77
|
+
' /* The ranked word list, most frequent first. Index is the frequency rank. */',
|
|
78
|
+
' const WORD_LIST = ' + JSON.stringify(all.join('\n')) + ';',
|
|
79
|
+
'',
|
|
80
|
+
engineSource,
|
|
81
|
+
'',
|
|
82
|
+
' /* The one engine instance this page uses. */',
|
|
83
|
+
' const ENGINE = createEngine(WORD_LIST.split("\\n").filter(Boolean), TYPO_MAP);',
|
|
84
|
+
'',
|
|
85
|
+
' ' + END,
|
|
86
|
+
].join('\n')
|
|
87
|
+
|
|
88
|
+
const clientPath = join(root, 'src', 'client.js')
|
|
89
|
+
const client = readFileSync(clientPath, 'utf8')
|
|
90
|
+
|
|
91
|
+
const start = client.indexOf(BEGIN)
|
|
92
|
+
const finish = client.indexOf(END)
|
|
93
|
+
if (start === -1 || finish === -1 || finish < start) {
|
|
94
|
+
console.error('build: generated markers not found in src/client.js')
|
|
95
|
+
process.exit(1)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const next = client.slice(0, start) + block + client.slice(finish + END.length)
|
|
99
|
+
writeFileSync(clientPath, next, 'utf8')
|
|
100
|
+
|
|
101
|
+
const bytes = Buffer.byteLength(next, 'utf8')
|
|
102
|
+
console.log(`build: embedded ${all.length} words into src/client.js (${(bytes / 1024).toFixed(1)} KB)`)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stylesheet sanity check: `node scripts/check-styles.mjs`.
|
|
3
|
+
*
|
|
4
|
+
* The client half builds one CSS string from an array of literals. A rule whose
|
|
5
|
+
* selector line is removed leaves its declarations behind, and those orphans
|
|
6
|
+
* break the parse of every rule after them — which is exactly how the
|
|
7
|
+
* right-click menu lost its positioning and rendered as raw text at the bottom
|
|
8
|
+
* of the page. This asserts the string is structurally composed of real rules.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from 'node:fs'
|
|
12
|
+
import { dirname, join } from 'node:path'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
const source = readFileSync(join(here, '..', 'src', 'client.js'), 'utf8')
|
|
17
|
+
|
|
18
|
+
const start = source.indexOf('function styles()')
|
|
19
|
+
const end = source.indexOf('].join', start)
|
|
20
|
+
if (start === -1 || end === -1) {
|
|
21
|
+
console.error('check-styles: could not locate styles()')
|
|
22
|
+
process.exit(1)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const block = source.slice(start, end)
|
|
26
|
+
const css = block
|
|
27
|
+
.split('\n')
|
|
28
|
+
.map((line) => line.trim())
|
|
29
|
+
.filter((line) => line.startsWith('"'))
|
|
30
|
+
.map((line) => line.replace(/^"/, '').replace(/",?$/, ''))
|
|
31
|
+
.join('')
|
|
32
|
+
|
|
33
|
+
let passed = 0
|
|
34
|
+
const failures = []
|
|
35
|
+
const check = (name, ok, detail) => {
|
|
36
|
+
if (ok) passed += 1
|
|
37
|
+
else failures.push(name + (detail === undefined ? '' : ` — ${detail}`))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const open = (css.match(/\{/g) ?? []).length
|
|
41
|
+
const close = (css.match(/\}/g) ?? []).length
|
|
42
|
+
check('braces balance', open === close, `${open} open vs ${close} close`)
|
|
43
|
+
|
|
44
|
+
// Every top-level statement must start with a selector, an at-rule, or a
|
|
45
|
+
// comment. A bare declaration at the start of a statement is the orphan shape.
|
|
46
|
+
const statements = css.split('}')
|
|
47
|
+
for (const statement of statements) {
|
|
48
|
+
const trimmed = statement.trim()
|
|
49
|
+
if (trimmed === '') continue
|
|
50
|
+
check(
|
|
51
|
+
'statement starts with a selector',
|
|
52
|
+
/^(\.|@|:root|html|body|\/\*)/.test(trimmed),
|
|
53
|
+
trimmed.slice(0, 60),
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The rules that carry the two visible surfaces must survive.
|
|
58
|
+
check('strip rule present', css.includes('.sp-strip{'))
|
|
59
|
+
check('menu rule present', css.includes('.sp-menu{'))
|
|
60
|
+
check('menu open state present', css.includes('.sp-menu.sp-open{'))
|
|
61
|
+
check('menu positions itself', /\.sp-menu\{[^}]*position:fixed/.test(css))
|
|
62
|
+
|
|
63
|
+
// The chip was removed from the UI; nothing should reference it any more.
|
|
64
|
+
check('no chip rules left', css.includes('.sp-chip') === false)
|
|
65
|
+
|
|
66
|
+
console.log(`${passed} passed, ${failures.length} failed`)
|
|
67
|
+
if (failures.length > 0) {
|
|
68
|
+
for (const failure of failures) console.log(' FAIL ' + failure)
|
|
69
|
+
process.exitCode = 1
|
|
70
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generate the README banners.
|
|
3
|
+
|
|
4
|
+
Deliberately scripted rather than image-generated: a banner is a logo, a
|
|
5
|
+
wordmark, a tagline and the real screenshots, and a diffusion model renders text
|
|
6
|
+
and UI chrome badly. Pillow gives crisp type and exact colours.
|
|
7
|
+
|
|
8
|
+
python scripts/make-banner.py # the default variant, both themes
|
|
9
|
+
python scripts/make-banner.py --variant A # one variant, both themes
|
|
10
|
+
python scripts/make-banner.py --variant B --theme dark --suffix=-B
|
|
11
|
+
|
|
12
|
+
The variants differ only in how the screenshots are arranged:
|
|
13
|
+
|
|
14
|
+
A stacked — two cards, one above the other, with a clear gap
|
|
15
|
+
B hero — one large card, the whole right half
|
|
16
|
+
C layered — two cards offset and overlapping, like the sibling repos
|
|
17
|
+
|
|
18
|
+
Nothing is rotated: a tilted screenshot reads as a mock-up, and the point of
|
|
19
|
+
putting a screenshot in the banner is that it is the real thing.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
|
26
|
+
|
|
27
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
28
|
+
ICON = ROOT / "assets" / "icon.png"
|
|
29
|
+
MENU = ROOT / "assets" / "menu.png"
|
|
30
|
+
STRIP = ROOT / "assets" / "strip.png"
|
|
31
|
+
OUT = ROOT / "assets"
|
|
32
|
+
|
|
33
|
+
W, H = 1400, 460
|
|
34
|
+
|
|
35
|
+
LIGHT = {
|
|
36
|
+
"bg": (247, 246, 250),
|
|
37
|
+
"glow": (232, 229, 242),
|
|
38
|
+
"title": (26, 22, 36),
|
|
39
|
+
"tag": (92, 86, 108),
|
|
40
|
+
"accent": (232, 72, 77),
|
|
41
|
+
"edge": (0, 0, 0, 30),
|
|
42
|
+
"shadow": (24, 20, 36, 64),
|
|
43
|
+
}
|
|
44
|
+
DARK = {
|
|
45
|
+
"bg": (18, 18, 22),
|
|
46
|
+
"glow": (32, 30, 42),
|
|
47
|
+
"title": (245, 244, 248),
|
|
48
|
+
"tag": (152, 148, 168),
|
|
49
|
+
"accent": (255, 106, 110),
|
|
50
|
+
"edge": (255, 255, 255, 32),
|
|
51
|
+
"shadow": (0, 0, 0, 140),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
FONT_CANDIDATES = [
|
|
55
|
+
r"C:\Windows\Fonts\segoeuib.ttf",
|
|
56
|
+
r"C:\Windows\Fonts\SegoeUI.ttf",
|
|
57
|
+
r"C:\Windows\Fonts\arialbd.ttf",
|
|
58
|
+
]
|
|
59
|
+
MONO_CANDIDATES = [
|
|
60
|
+
r"C:\Windows\Fonts\consola.ttf",
|
|
61
|
+
r"C:\Windows\Fonts\cour.ttf",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
# Tighter than the raw stills: the interesting part of each capture is the popup
|
|
65
|
+
# plus the line it belongs to, and cropping to that makes a card twice as tall at
|
|
66
|
+
# the same width — which is the whole point of the banner.
|
|
67
|
+
CROPS = {
|
|
68
|
+
"menu": (400, 0, 1350, 314), # 950 x 314
|
|
69
|
+
"strip": (150, 0, 1150, 260), # 1000 x 260
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
LAYOUTS = {
|
|
73
|
+
# name: (width, x, y). Top-left of each card; every right edge stays inside
|
|
74
|
+
# 1400 with a margin, and no card is ever clipped by the canvas.
|
|
75
|
+
"A": {"menu": (690, 690, 10), "strip": (700, 690, 248)},
|
|
76
|
+
"B": {"strip": (900, 470, 126)},
|
|
77
|
+
"C": {"menu": (700, 676, 16), "strip": (700, 700, 234)},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
DEFAULT_VARIANT = "A"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def font(candidates, size):
|
|
84
|
+
for candidate in candidates:
|
|
85
|
+
if Path(candidate).exists():
|
|
86
|
+
return ImageFont.truetype(candidate, size)
|
|
87
|
+
return ImageFont.load_default()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def rounded_glow(size, colour):
|
|
91
|
+
glow = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
92
|
+
draw = ImageDraw.Draw(glow)
|
|
93
|
+
for step in range(size // 2, 0, -2):
|
|
94
|
+
alpha = int(26 * (1 - step / (size / 2)))
|
|
95
|
+
draw.ellipse(
|
|
96
|
+
(size // 2 - step, size // 2 - step, size // 2 + step, size // 2 + step),
|
|
97
|
+
fill=colour + (alpha,),
|
|
98
|
+
)
|
|
99
|
+
return glow
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def rounded_mask(size, radius):
|
|
103
|
+
mask = Image.new("L", size, 0)
|
|
104
|
+
ImageDraw.Draw(mask).rounded_rectangle((0, 0, size[0] - 1, size[1] - 1), radius=radius, fill=255)
|
|
105
|
+
return mask
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def pad_top(image, amount):
|
|
109
|
+
"""
|
|
110
|
+
Give a crop headroom above its content.
|
|
111
|
+
|
|
112
|
+
The strip still was captured tight to the popup's rounded top, so pasting it
|
|
113
|
+
straight into a card clipped those corners and the popup read as flush with
|
|
114
|
+
the card edge rather than floating over the composer. The filler is the
|
|
115
|
+
crop's own corner colour, which is the page background behind the composer.
|
|
116
|
+
"""
|
|
117
|
+
if amount <= 0:
|
|
118
|
+
return image
|
|
119
|
+
filler = image.getpixel((0, 0))
|
|
120
|
+
padded = Image.new("RGBA", (image.width, image.height + amount), filler)
|
|
121
|
+
padded.paste(image, (0, amount))
|
|
122
|
+
return padded
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def card(path, crop, width, theme, radius=18, headroom=0):
|
|
126
|
+
"""A screenshot as a rounded card with its own hairline edge."""
|
|
127
|
+
source = Image.open(path).convert("RGBA").crop(crop)
|
|
128
|
+
source = pad_top(source, headroom)
|
|
129
|
+
height = round(source.height * width / source.width)
|
|
130
|
+
image = source.resize((width, height), Image.LANCZOS)
|
|
131
|
+
|
|
132
|
+
mask = rounded_mask((width, height), radius)
|
|
133
|
+
image.putalpha(mask)
|
|
134
|
+
ImageDraw.Draw(image).rounded_rectangle(
|
|
135
|
+
(0, 0, width - 1, height - 1), radius=radius, outline=theme["edge"], width=1
|
|
136
|
+
)
|
|
137
|
+
return image
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def place(canvas, art, x, y, theme):
|
|
141
|
+
"""Composite a card with a soft shadow behind it."""
|
|
142
|
+
silhouette = Image.new("RGBA", art.size, (0, 0, 0, 0))
|
|
143
|
+
silhouette.paste(Image.new("RGBA", art.size, theme["shadow"]), (0, 0), art.split()[3])
|
|
144
|
+
silhouette = silhouette.filter(ImageFilter.GaussianBlur(18))
|
|
145
|
+
canvas.alpha_composite(silhouette, (x + 2, y + 12))
|
|
146
|
+
canvas.alpha_composite(art, (x, y))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def build(theme, name, variant):
|
|
150
|
+
layout = LAYOUTS[variant]
|
|
151
|
+
canvas = Image.new("RGB", (W, H), theme["bg"]).convert("RGBA")
|
|
152
|
+
canvas.alpha_composite(rounded_glow(460, theme["glow"]), (40, -30))
|
|
153
|
+
|
|
154
|
+
# Screenshots before the wordmark, so no card can ever cover the text.
|
|
155
|
+
for key, path in (("menu", MENU), ("strip", STRIP)):
|
|
156
|
+
if key not in layout:
|
|
157
|
+
continue # a single-card layout simply omits the other
|
|
158
|
+
width, x, y = layout[key]
|
|
159
|
+
headroom = 26 if key == "strip" else 0
|
|
160
|
+
place(canvas, card(path, CROPS[key], width, theme, headroom=headroom), x, y, theme)
|
|
161
|
+
|
|
162
|
+
icon = Image.open(ICON).convert("RGBA")
|
|
163
|
+
icon.thumbnail((168, 168), Image.LANCZOS)
|
|
164
|
+
canvas.alpha_composite(icon, (46, (H - icon.height) // 2 - 4))
|
|
165
|
+
|
|
166
|
+
draw = ImageDraw.Draw(canvas)
|
|
167
|
+
|
|
168
|
+
title_font = font(FONT_CANDIDATES, 74)
|
|
169
|
+
draw.text((232, 156), "spell check", font=title_font, fill=theme["title"])
|
|
170
|
+
caret_x = 232 + draw.textlength("spell check", font=title_font) + 10
|
|
171
|
+
draw.rectangle((caret_x, 168, caret_x + 6, 232), fill=theme["accent"])
|
|
172
|
+
|
|
173
|
+
tag_font = font(MONO_CANDIDATES, 21)
|
|
174
|
+
for index, line in enumerate(["Tab accepts the fix.", "Right-click for every option.", "100% local."]):
|
|
175
|
+
draw.text((236, 258 + index * 29), line, font=tag_font, fill=theme["tag"])
|
|
176
|
+
|
|
177
|
+
path = OUT / name
|
|
178
|
+
canvas.convert("RGB").save(path, "PNG", optimize=True)
|
|
179
|
+
print(f"wrote {path.relative_to(ROOT)} ({path.stat().st_size // 1024} KB)")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
parser = argparse.ArgumentParser()
|
|
183
|
+
parser.add_argument("--variant", default=DEFAULT_VARIANT, choices=sorted(LAYOUTS))
|
|
184
|
+
parser.add_argument("--theme", default="both", choices=["both", "light", "dark"])
|
|
185
|
+
parser.add_argument("--suffix", default="", help="appended to the filename, for comparisons")
|
|
186
|
+
args = parser.parse_args()
|
|
187
|
+
|
|
188
|
+
if args.theme in ("both", "dark"):
|
|
189
|
+
build(DARK, f"banner-dark{args.suffix}.png", args.variant)
|
|
190
|
+
if args.theme in ("both", "light"):
|
|
191
|
+
build(LIGHT, f"banner-light{args.suffix}.png", args.variant)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Clean the recorded demo and build the README media.
|
|
3
|
+
|
|
4
|
+
The recording is made by the harness's own computer-use driver, which paints a
|
|
5
|
+
small blue cursor pill labelled "DSH" wherever it clicks. That pill lands on the
|
|
6
|
+
composer, exactly where the demo needs to be legible, so each frame is patched:
|
|
7
|
+
the pill is found by colour, and the rectangle is filled with the background
|
|
8
|
+
sampled just beside it. It sits on flat paint, so the repair is invisible.
|
|
9
|
+
|
|
10
|
+
python scripts/make-media.py
|
|
11
|
+
|
|
12
|
+
Inputs media/demo2.mp4 the raw recording
|
|
13
|
+
Outputs assets/strip.gif typing -> suggestion strip -> Tab accepts
|
|
14
|
+
assets/strip.png still, for the README
|
|
15
|
+
assets/menu.png still, the right-click menu
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
|
|
22
|
+
from PIL import Image
|
|
23
|
+
|
|
24
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
25
|
+
MEDIA = ROOT / "media"
|
|
26
|
+
ASSETS = ROOT / "assets"
|
|
27
|
+
FRAMES = MEDIA / "frames"
|
|
28
|
+
|
|
29
|
+
FFMPEG = shutil.which("ffmpeg") or (
|
|
30
|
+
r"C:\Users\adith\AppData\Local\Microsoft\WinGet\Packages"
|
|
31
|
+
r"\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-6.1-full_build\bin\ffmpeg.exe"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Crop of the full recording: composer + the strip above it.
|
|
35
|
+
CROP = "crop=900:130:340:840"
|
|
36
|
+
START, DURATION, FPS = 9.6, 10.5, 10
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run(*args):
|
|
40
|
+
subprocess.run([FFMPEG, "-hide_banner", "-loglevel", "error", "-y", *args], check=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def patch_pill(path):
|
|
44
|
+
"""Erase the blue cursor pill from one frame.
|
|
45
|
+
|
|
46
|
+
The search window matters: the composer also contains a blue send button,
|
|
47
|
+
and a whole-frame sweep merged the two into one enormous rectangle that
|
|
48
|
+
painted over the entire row. The pill lives just right of the composer text
|
|
49
|
+
and just above the control row, so the search is confined to that band.
|
|
50
|
+
"""
|
|
51
|
+
image = Image.open(path).convert("RGB")
|
|
52
|
+
pixels = image.load()
|
|
53
|
+
width, height = image.size
|
|
54
|
+
|
|
55
|
+
xs, ys = [], []
|
|
56
|
+
for y in range(40, min(height, 100)):
|
|
57
|
+
for x in range(240, min(width, 640)):
|
|
58
|
+
r, g, b = pixels[x, y]
|
|
59
|
+
if b > 140 and b - r > 60 and b - g > 40:
|
|
60
|
+
xs.append(x)
|
|
61
|
+
ys.append(y)
|
|
62
|
+
if not xs:
|
|
63
|
+
image.save(path)
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
left, right = min(xs) - 2, max(xs) + 2
|
|
67
|
+
top, bottom = min(ys) - 2, max(ys) + 2
|
|
68
|
+
# A cursor pill is small; anything larger is not the pill and is left alone.
|
|
69
|
+
if right - left > 70 or bottom - top > 40:
|
|
70
|
+
image.save(path)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
for y in range(max(0, top), min(height, bottom + 1)):
|
|
74
|
+
# Per-row sample of the flat composer paint, taken clear of the pill.
|
|
75
|
+
fill = pixels[max(0, left - 10), y]
|
|
76
|
+
for x in range(max(0, left), min(width, right + 1)):
|
|
77
|
+
pixels[x, y] = fill
|
|
78
|
+
image.save(path)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main():
|
|
82
|
+
if FRAMES.exists():
|
|
83
|
+
shutil.rmtree(FRAMES)
|
|
84
|
+
FRAMES.mkdir(parents=True)
|
|
85
|
+
|
|
86
|
+
ASSETS.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
|
|
88
|
+
# 1. Frames, cropped to the composer.
|
|
89
|
+
run(
|
|
90
|
+
"-ss", str(START), "-t", str(DURATION), "-i", str(MEDIA / "demo2.mp4"),
|
|
91
|
+
"-vf", f"{CROP},fps={FPS}",
|
|
92
|
+
str(FRAMES / "f%03d.png"),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# 2. Repair every frame.
|
|
96
|
+
frames = sorted(FRAMES.glob("f*.png"))
|
|
97
|
+
for frame in frames:
|
|
98
|
+
patch_pill(frame)
|
|
99
|
+
print(f"patched {len(frames)} frames")
|
|
100
|
+
|
|
101
|
+
# 3. Reassemble as an animated GIF with one shared palette.
|
|
102
|
+
run(
|
|
103
|
+
# The input framerate MUST be declared: without it ffmpeg assumes 25 fps
|
|
104
|
+
# for an image sequence and a 10 fps capture comes back playing 2.5x fast.
|
|
105
|
+
"-framerate", str(FPS), "-i", str(FRAMES / "f%03d.png"),
|
|
106
|
+
"-vf", "split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=sierra2_4a",
|
|
107
|
+
"-loop", "0",
|
|
108
|
+
str(ASSETS / "strip.gif"),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# 4. A still for the README, taken mid-strip.
|
|
112
|
+
still = Image.open(frames[min(len(frames) - 1, int(FPS * 3.4))]).convert("RGB")
|
|
113
|
+
still = still.resize((still.width * 2, still.height * 2), Image.LANCZOS)
|
|
114
|
+
still.save(ASSETS / "strip.png")
|
|
115
|
+
|
|
116
|
+
size = (ASSETS / "strip.gif").stat().st_size // 1024
|
|
117
|
+
print(f"wrote assets/strip.gif ({size} KB) and assets/strip.png")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
main()
|