diff --git a/README.md b/README.md index 9e8112e..777e076 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,11 @@ The landing page at [schuly.dev](https://schuly.dev), in six languages: German, `bun install`, then `bun start` for or `bun run build` for production. [Its docs](https://docs.schuly.dev/SchulyWebsite/) cover the rest. +## Regenerating images + +- [`tools/icons`](tools/icons): favicon, touch and manifest icons. +- [`tools/social-image`](tools/social-image): the `og-image` link preview. +- [`tools/hero-images`](tools/hero-images): the two hero screenshots. +- The framed phone screenshots (`public/assets/schuly-*-page-v2.*`) come from [`tools/store-screenshots`](https://github.com/schulydev/Schuly/tree/main/tools/store-screenshots) in the Schuly app repo. + [PolyForm Noncommercial 1.0.0](LICENSE) diff --git a/public/favicon.ico b/public/favicon.ico index 2105d98..8da7bd9 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/tools/hero-images/README.md b/tools/hero-images/README.md new file mode 100644 index 0000000..332368a --- /dev/null +++ b/tools/hero-images/README.md @@ -0,0 +1,25 @@ +# Hero images + +Makes the two screenshots in the hero section: +- `public/assets/webapp-overview-dark.webp` (1424×890) shows the web app inside the laptop frame. It is also the screenshot in the [social image](../social-image). +- `public/assets/hero-phone-home.webp` (600×1333) shows the app's home screen on the phone beside it. + +This script only crops and converts them. The screenshots themselves come from the other repos: +- **Web app:** `src/Schuly.Frontend/public/screens/overview-dark.png` in SchulyServer, made with its `tools/screenshots`. +- **Phone:** the unframed home screenshot (`home-dark.png`, 1080×2400) from `tools/store-screenshots` in the Schuly repo. The framed phone images in the screenshots section come from the same tool. + +## Prerequisites + +Bun, and `bun install` in the repo root (sharp is a dev dependency). + +## Regenerate + +```sh +bun tools/hero-images/hero.ts --web ../SchulyServer/src/Schuly.Frontend/public/screens/overview-dark.png --phone ../Schuly/tools/store-screenshots/out/raw/home-dark.png +``` + +Point `--web` and `--phone` at wherever those files are; either one can be left out. It writes to `public/assets/` by default, or to `--out `. + +## Licences + +Both screenshots show made-up data only (Mia Muster, Berufsfachschule Beispielstadt). diff --git a/tools/hero-images/hero.ts b/tools/hero-images/hero.ts new file mode 100644 index 0000000..0c53748 --- /dev/null +++ b/tools/hero-images/hero.ts @@ -0,0 +1,18 @@ +// Converts the two hero screenshots into the website's webp files. +// Usage: bun tools/hero-images/hero.ts --web --phone [--out ] +// --web SchulyServer's src/Schuly.Frontend/public/screens/overview-dark.png (1440x900) +// --phone an unframed 1080x2400 home screenshot from the Schuly repo's tools/store-screenshots +import { join, resolve } from 'node:path'; +import sharp from 'sharp'; + +const root = resolve(import.meta.dir, '../..'); +const arg = (name: string) => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : undefined; }; +const out = resolve(arg('--out') ?? join(root, 'public/assets')); +const web = arg('--web'); +const phone = arg('--phone'); +if (!web && !phone) throw new Error('Pass --web , --phone or both.'); + +// Crop 16 px off the right edge (the page scrollbar) and 10 px off the bottom for a 16:10 laptop screen. +if (web) await sharp(web).extract({ left: 0, top: 0, width: 1424, height: 890 }).webp({ quality: 84 }).toFile(join(out, 'webapp-overview-dark.webp')); +if (phone) await sharp(phone).resize(600).webp({ quality: 84 }).toFile(join(out, 'hero-phone-home.webp')); +console.log(`Wrote ${[web && 'webapp-overview-dark.webp', phone && 'hero-phone-home.webp'].filter(Boolean).join(' and ')} to ${out}`); diff --git a/tools/icons/README.md b/tools/icons/README.md new file mode 100644 index 0000000..d6811bd --- /dev/null +++ b/tools/icons/README.md @@ -0,0 +1,29 @@ +# Icons + +Builds the website's icon set from `public/assets/app_icon.png` (849×845, the same artwork as the app icon in the Schuly repo). + +| Output | Size | How | +|---|---|---| +| `public/assets/app_icon_512.png` | 512×512 | padded to a square on transparent (the source isn't square), palette PNG | +| `public/assets/app_icon_64.png` | 64×64 | header, footer and download card | +| `public/assets/apple-touch-icon.png` | 180×180 | flattened on the site background `#07080a`, because iOS doesn't show transparency | +| `public/assets/icon-192.png` | 192×192 | manifest `any` | +| `public/assets/icon-maskable-512.png` | 512×512 | icon at 384 px on `#07080a`, inside the 80% safe zone, for the manifest `maskable` purpose | +| `public/favicon.ico` | 48, 32, 16 | 32-bit BMP entries | + +## Prerequisites + +Bun, and `bun install` in the repo root (sharp is a dev dependency). + +## Regenerate + +```sh +bun tools/icons/icons.ts +``` + +The options are `--src ` for another source image, `--out ` for the PNGs and `--favicon ` for the `.ico`. After you change the icon, also run [`../social-image`](../social-image), because the social image shows `app_icon_512.png`. + + +## Licences + +The icon is Schuly's own artwork; the repo's licence applies. diff --git a/tools/icons/favicon.py b/tools/icons/favicon.py deleted file mode 100644 index ba0ac9c..0000000 --- a/tools/icons/favicon.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Builds public/favicon.ico (16, 32 and 48 px) from public/assets/app_icon_512.png. - -Run from the repo root: python tools/icons/favicon.py (needs Pillow: pip install Pillow) -""" -from pathlib import Path - -from PIL import Image - -root = Path(__file__).resolve().parents[2] -src = Image.open(root / "public/assets/app_icon_512.png").convert("RGBA") -src.save(root / "public/favicon.ico", sizes=[(16, 16), (32, 32), (48, 48)]) -print("wrote public/favicon.ico") diff --git a/tools/icons/icons.ts b/tools/icons/icons.ts new file mode 100644 index 0000000..7b3fb13 --- /dev/null +++ b/tools/icons/icons.ts @@ -0,0 +1,63 @@ +// Builds the website's icon set from public/assets/app_icon.png. +// Usage: bun tools/icons/icons.ts [--src ] [--out ] [--favicon ] +import { writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import sharp from 'sharp'; + +const root = resolve(import.meta.dir, '../..'); +const arg = (name: string) => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : undefined; }; +const src = resolve(arg('--src') ?? join(root, 'public/assets/app_icon.png')); +const out = resolve(arg('--out') ?? join(root, 'public/assets')); +const favicon = resolve(arg('--favicon') ?? join(root, 'public/favicon.ico')); +const BG = '#07080a'; // the site's background and manifest theme_color +const png = (quality: number) => ({ palette: true, quality, compressionLevel: 9, effort: 10 }) as const; + +// app_icon.png is 849x845, so the square sizes either pad it (contain) or crop the 2 px difference (cover, sharp's default). +await sharp(src).resize(512, 512, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png(png(90)).toFile(join(out, 'app_icon_512.png')); +await sharp(src).resize(64).png().toFile(join(out, 'app_icon_64.png')); +await sharp(src).resize(180, 180).flatten({ background: BG }).png(png(95)).toFile(join(out, 'apple-touch-icon.png')); +await sharp(src).resize(192, 192).png(png(95)).toFile(join(out, 'icon-192.png')); + +// Maskable: the icon at 384 px (75%) on a dark square, inside the 80% safe zone. +const inner = await sharp(src).resize(384, 384).toBuffer(); +await sharp({ create: { width: 512, height: 512, channels: 4, background: BG } }).composite([{ input: inner, gravity: 'center' }]).png(png(95)).toFile(join(out, 'icon-maskable-512.png')); + +// favicon.ico: 48, 32 and 16 px as 32-bit BMP entries with an empty AND mask, the layout of the original file. +const sizes = [48, 32, 16]; +const entries: Buffer[] = []; +for (const s of sizes) { + const { data } = await sharp(src).resize(s, s, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + const header = Buffer.alloc(40); + header.writeUInt32LE(40, 0); + header.writeInt32LE(s, 4); + header.writeInt32LE(s * 2, 8); // XOR image + AND mask + header.writeUInt16LE(1, 12); + header.writeUInt16LE(32, 14); + const pixels = Buffer.alloc(s * s * 4); + for (let y = 0; y < s; y++) { + for (let x = 0; x < s; x++) { + const i = (y * s + x) * 4; + const o = ((s - 1 - y) * s + x) * 4; // bottom-up rows, BGRA + pixels[o] = data[i + 2]; pixels[o + 1] = data[i + 1]; pixels[o + 2] = data[i]; pixels[o + 3] = data[i + 3]; + } + } + const mask = Buffer.alloc((Math.ceil(s / 32) * 4) * s); + entries.push(Buffer.concat([header, pixels, mask])); +} +const dir = Buffer.alloc(6 + 16 * sizes.length); +dir.writeUInt16LE(1, 2); +dir.writeUInt16LE(sizes.length, 4); +let offset = dir.length; +sizes.forEach((s, i) => { + const e = 6 + 16 * i; + dir.writeUInt8(s, e); + dir.writeUInt8(s, e + 1); + dir.writeUInt16LE(1, e + 4); + dir.writeUInt16LE(32, e + 6); + dir.writeUInt32LE(entries[i].length, e + 8); + dir.writeUInt32LE(offset, e + 12); + offset += entries[i].length; +}); +writeFileSync(favicon, Buffer.concat([dir, ...entries])); + +console.log(`Wrote app_icon_512, app_icon_64, apple-touch-icon, icon-192, icon-maskable-512 to ${out} and ${favicon}`); diff --git a/tools/social-image/README.md b/tools/social-image/README.md new file mode 100644 index 0000000..187ee9a --- /dev/null +++ b/tools/social-image/README.md @@ -0,0 +1,27 @@ +# Social image + +Makes the 1200×630 link preview `public/assets/og-image.png` and `og-image.webp`. + +`og.html` is the template. `render.ts` serves it on top of `public/`, so it can load the Inter font and two assets from there: +- `public/assets/app_icon_512.png`, from [`../icons`](../icons); +- `public/assets/webapp-overview-dark.webp`, from [`../hero-images`](../hero-images). + +It then screenshots the page with your installed Chrome or Edge in headless mode and compresses the result with sharp. + +## Prerequisites + +- Bun, and `bun install` in the repo root (sharp is a dev dependency). +- Google Chrome or Microsoft Edge. Nothing is downloaded. The script looks in the usual install folders; otherwise pass `--browser ` or set `CHROME_PATH`. + +## Regenerate + +```sh +bun tools/social-image/render.ts +``` + +It writes to `public/assets/` by default; `--out ` writes somewhere else. To change the text or layout, edit `og.html`. Opening it straight from disk won't load the font or the images, so render it to check. + +## Licences + +- The font is Inter (SIL Open Font License 1.1), the same file the site uses. +- The screenshot inside shows made-up data only (Mia Muster). diff --git a/tools/social-image/og.html b/tools/social-image/og.html new file mode 100644 index 0000000..dff5f3f --- /dev/null +++ b/tools/social-image/og.html @@ -0,0 +1,49 @@ + + + + + + + +
+
Schuly
+

Alle deine Schulen,in einer App.

+

Noten, Absenzen und Stundenplan aus Schulnetz und OdA Org. Android, iOS und Web.

+
+
+ + diff --git a/tools/social-image/render.ts b/tools/social-image/render.ts new file mode 100644 index 0000000..1bffa3a --- /dev/null +++ b/tools/social-image/render.ts @@ -0,0 +1,54 @@ +// Renders og.html (1200x630) with the installed Chrome or Edge and writes public/assets/og-image.png and .webp. +// Usage: bun tools/social-image/render.ts [--out ] [--browser ] +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import sharp from 'sharp'; + +const root = resolve(import.meta.dir, '../..'); +const arg = (name: string) => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : undefined; }; +const outDir = resolve(arg('--out') ?? join(root, 'public/assets')); + +const candidates = [ + arg('--browser'), + process.env.CHROME_PATH, + 'C:/Program Files/Google/Chrome/Application/chrome.exe', + 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', + 'C:/Program Files/Microsoft/Edge/Application/msedge.exe', + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + '/usr/bin/google-chrome', + '/usr/bin/chromium', + '/usr/bin/microsoft-edge', +]; +const browser = candidates.find((p) => p && existsSync(p)); +if (!browser) throw new Error('No Chrome or Edge found. Pass --browser or set CHROME_PATH.'); + +// og.html loads /fonts/... and /assets/... from public/, so serve public/ with the template on top. +const template = join(import.meta.dir, 'og.html'); +const server = Bun.serve({ + port: 0, + async fetch(req) { + const path = decodeURIComponent(new URL(req.url).pathname); + if (path === '/og.html') return new Response(Bun.file(template), { headers: { 'content-type': 'text/html; charset=utf-8' } }); + const file = Bun.file(join(root, 'public', path)); + return (await file.exists()) ? new Response(file) : new Response('not found', { status: 404 }); + }, +}); + +const tmp = mkdtempSync(join(tmpdir(), 'schuly-og-')); +const raw = join(tmp, 'og-raw.png'); +try { + const proc = Bun.spawn([browser, '--headless=new', '--disable-gpu', '--hide-scrollbars', '--force-device-scale-factor=1', '--window-size=1200,630', '--virtual-time-budget=5000', `--user-data-dir=${join(tmp, 'profile')}`, `--screenshot=${raw}`, `http://localhost:${server.port}/og.html`], { stdout: 'ignore', stderr: 'ignore' }); + await proc.exited; + if (!existsSync(raw)) throw new Error(`${browser} did not write a screenshot`); + + const meta = await sharp(raw).metadata(); + if (meta.width !== 1200 || meta.height !== 630) throw new Error(`Expected 1200x630, got ${meta.width}x${meta.height}`); + await sharp(raw).png({ palette: true, quality: 90, compressionLevel: 9, effort: 10 }).toFile(join(outDir, 'og-image.png')); + await sharp(raw).webp({ quality: 82 }).toFile(join(outDir, 'og-image.webp')); + console.log(`Wrote ${join(outDir, 'og-image.png')} and og-image.webp`); +} finally { + server.stop(true); + rmSync(tmp, { recursive: true, force: true }); +}