Add UmbraTools-branded app icon
PageScribe joins the UmbraTools suite, so give it an icon in the shared "umbra" identity: an open bronze book bound along its gutter by a glowing cyan weave, held inside a broken reticle ring on a midnight field. - Full adaptive icon: background + foreground + themed monochrome layer, with the mark sized to the 66dp safe circle so circle, squircle and rounded-square launcher masks all crop it cleanly. - Legacy ic_launcher / ic_launcher_round bitmaps at all five densities. - 1024 master lockup, Play Store 512 icon and the rounded-square mark in brand/, next to the generator and design notes. - brand/generate_icons.py rebuilds every asset deterministically (SVG via cairosvg for the gradients and glow, Montserrat for the wordmark); brand/README.md documents the palette, what each element means, and how to regenerate. The old flat vector drawable/ic_launcher.xml is replaced; the manifest now points at @mipmap/ic_launcher with a roundIcon.
3
.gitignore
vendored
@@ -18,3 +18,6 @@ keystore.properties
|
|||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Fonts fetched for icon generation (see brand/README.md)
|
||||||
|
brand/fonts/
|
||||||
|
|||||||
16
README.md
@@ -108,6 +108,22 @@ The queue is crash-safe: `PageScribeApp.onCreate` reclaims photos orphaned in
|
|||||||
`work/` older than two minutes and re-arms the worker, and failed photos are
|
`work/` older than two minutes and re-arms the worker, and failed photos are
|
||||||
retried up to `MAX_JOB_ATTEMPTS` before being parked in `failed/`.
|
retried up to `MAX_JOB_ATTEMPTS` before being parked in `failed/`.
|
||||||
|
|
||||||
|
## Icon and branding
|
||||||
|
|
||||||
|
PageScribe belongs to the **UmbraTools** suite, so its launcher icon follows the
|
||||||
|
UmbraTools UX brand: a dark, luminous "umbra" identity built from interwoven,
|
||||||
|
glowing forms. The mark is an open **book** bound along its gutter by a glowing
|
||||||
|
**weave**, held inside a broken **reticle** ring — a midnight field
|
||||||
|
(`#0A0F1F`), Electric Cyan (`#00BFFF`) glow, a bronze (`#A67C52`) book, and Ice
|
||||||
|
Blue (`#CFE9FF`) ruled lines.
|
||||||
|
|
||||||
|
The icon ships as a full adaptive icon (background + foreground + themed
|
||||||
|
`monochrome` layer) sized to the 66 dp safe circle, so circle, squircle and
|
||||||
|
rounded-square launcher masks all crop it cleanly, plus legacy bitmaps for older
|
||||||
|
launchers. The 1024 master lockup and Play Store icon live in `brand/`, together
|
||||||
|
with the deterministic generator and the design notes — see
|
||||||
|
[`brand/README.md`](brand/README.md).
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".PageScribeApp"
|
android:name=".PageScribeApp"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:icon="@drawable/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.PageScribe">
|
android:theme="@style/Theme.PageScribe">
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#3E5C9A"
|
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
|
||||||
<!-- Open book -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FBF7EF"
|
|
||||||
android:pathData="M18,30 h30 c6,0 10,4 10,10 v40 c0,-6 -4,-10 -10,-10 h-30 z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#FBF7EF"
|
|
||||||
android:pathData="M90,30 h-30 c-6,0 -10,4 -10,10 v40 c0,-6 4,-10 10,-10 h30 z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#5B8DEF"
|
|
||||||
android:pathData="M52,42 h4 v38 h-4 z" />
|
|
||||||
</vector>
|
|
||||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@mipmap/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@mipmap/ic_launcher_monochrome" />
|
||||||
|
</adaptive-icon>
|
||||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@mipmap/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@mipmap/ic_launcher_monochrome" />
|
||||||
|
</adaptive-icon>
|
||||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
66
brand/README.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# PageScribe icon & brand
|
||||||
|
|
||||||
|
PageScribe is part of the **UmbraTools** suite, so its icon follows the
|
||||||
|
UmbraTools UX brand: a dark, luminous "umbra" identity built from interwoven,
|
||||||
|
glowing forms.
|
||||||
|
|
||||||
|
## The mark
|
||||||
|
|
||||||
|
An open **book** whose gutter is bound by an interwoven, glowing **weave**,
|
||||||
|
held inside a broken **reticle** ring.
|
||||||
|
|
||||||
|
| Element | Meaning | Colour |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Midnight field | the "umbra" depth the suite is named for | Midnight Blue `#0A0F1F` |
|
||||||
|
| Reticle ring (4 arcs) | capture / framing — PageScribe photographs spreads | Electric Cyan `#00BFFF` → Deep Teal `#007A8C` |
|
||||||
|
| Open book | the object being read | Bronze `#A67C52` |
|
||||||
|
| Ruled lines | the text a scribe transcribes (OCR) | Ice Blue `#CFE9FF` |
|
||||||
|
| Weave through the gutter | the suite's interwoven-knot motif; the energy that turns pages into text | Electric Cyan `#00BFFF` with a near-white filament core |
|
||||||
|
|
||||||
|
The ring is drawn as four arcs with cut-outs on the diagonals, so the mark reads
|
||||||
|
as a scanner target rather than a plain medallion — that is PageScribe's
|
||||||
|
contextual twist on the shared UmbraTools emblem.
|
||||||
|
|
||||||
|
Everything is **vertically symmetric**, and every gradient is purely vertical,
|
||||||
|
so the left and right halves of the book and weave are pixel-identical. The
|
||||||
|
emblem is sized to fit the **66 dp adaptive-icon safe circle**, so no launcher
|
||||||
|
mask (circle, squircle, rounded square) ever clips it.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
```
|
||||||
|
brand/
|
||||||
|
pagescribe-icon-1024.png master lockup: mark + "PageScribe" wordmark
|
||||||
|
pagescribe-playstore-512.png Play Store listing icon (512)
|
||||||
|
pagescribe-mark-1024.png the mark on its rounded-square tile, no text
|
||||||
|
generate_icons.py produces every asset below, deterministically
|
||||||
|
```
|
||||||
|
|
||||||
|
The Android resources it writes:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/src/main/res/
|
||||||
|
mipmap-anydpi-v26/ic_launcher.xml adaptive icon (bg + fg + monochrome)
|
||||||
|
mipmap-anydpi-v26/ic_launcher_round.xml same, for round masks
|
||||||
|
mipmap-{m,h,xh,xxh,xxxh}dpi/
|
||||||
|
ic_launcher.png legacy rounded-square bitmap
|
||||||
|
ic_launcher_round.png legacy circular bitmap
|
||||||
|
ic_launcher_foreground.png adaptive foreground layer
|
||||||
|
ic_launcher_background.png adaptive background layer
|
||||||
|
ic_launcher_monochrome.png themed-icon layer (weave cut as a groove)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Regenerating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install cairosvg pillow
|
||||||
|
python3 brand/generate_icons.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Montserrat (SIL Open Font License) is needed for the wordmark. Put
|
||||||
|
`Montserrat-SemiBold.ttf` and `Montserrat-Light.ttf` in `brand/fonts/`, or point
|
||||||
|
`FONT_DIR` at a folder that has them, or install Montserrat system-wide.
|
||||||
|
|
||||||
|
The generator is deterministic: re-running it reproduces every PNG byte for
|
||||||
|
byte, so a regeneration only shows up in `git diff` when the art actually
|
||||||
|
changes.
|
||||||
348
brand/generate_icons.py
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate the PageScribe icon set in the UmbraTools brand style.
|
||||||
|
|
||||||
|
Brand rules taken from the UmbraTools UX Brand Document:
|
||||||
|
* midnight blue #0A0F1F ground, electric cyan #00BFFF accents,
|
||||||
|
deep teal #007A8C gradients, bronze #A67C52 for the book,
|
||||||
|
ice blue #CFE9FF for text
|
||||||
|
* core motif: an interwoven knot/weave
|
||||||
|
* composition: a central emblem with a contextual element (here the book,
|
||||||
|
plus the ruled lines a scribe reads)
|
||||||
|
* rounded corners, symmetrical geometry, soft glow, gradients into darkness
|
||||||
|
* legible from 1024px down to launcher sizes
|
||||||
|
|
||||||
|
Every gradient is **purely vertical** so the mirrored left and right halves of
|
||||||
|
the book and the weave come out identical.
|
||||||
|
|
||||||
|
The art is authored as SVG (real gradients, gaussian bloom) and rasterised with
|
||||||
|
cairosvg; the wordmark is drawn with PIL so it always uses Montserrat.
|
||||||
|
|
||||||
|
Requirements
|
||||||
|
------------
|
||||||
|
pip install cairosvg pillow
|
||||||
|
Montserrat SemiBold + Light TTFs (SIL Open Font License), found via
|
||||||
|
$FONT_DIR, ./fonts, or a system install.
|
||||||
|
|
||||||
|
Regenerate everything with:
|
||||||
|
|
||||||
|
python3 brand/generate_icons.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cairosvg
|
||||||
|
except ImportError: # pragma: no cover - developer convenience
|
||||||
|
sys.exit("cairosvg is required: pip install cairosvg")
|
||||||
|
|
||||||
|
from PIL import Image, ImageChops, ImageDraw, ImageFont, ImageFilter
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- brand ----
|
||||||
|
BG = "#0A0F1F"
|
||||||
|
CYAN = "#00BFFF"
|
||||||
|
TEAL = "#007A8C"
|
||||||
|
BRONZE = "#A67C52"
|
||||||
|
ICE = "#CFE9FF"
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def find_font(name: str) -> Path:
|
||||||
|
"""Locate a Montserrat TTF: explicit env, vendored, or system install."""
|
||||||
|
candidates = [HERE / "fonts" / name, Path("/tmp/opencode/fonts") / name]
|
||||||
|
env = os.environ.get("FONT_DIR")
|
||||||
|
if env:
|
||||||
|
candidates.insert(0, Path(env) / name)
|
||||||
|
for c in candidates:
|
||||||
|
if c.is_file():
|
||||||
|
return c
|
||||||
|
for root in ("/usr/share/fonts", "/usr/local/share/fonts", Path.home() / ".fonts"):
|
||||||
|
for hit in Path(root).rglob(name):
|
||||||
|
return hit
|
||||||
|
sys.exit(f"cannot find {name}; set FONT_DIR to the folder holding Montserrat TTFs")
|
||||||
|
|
||||||
|
|
||||||
|
FONT_SEMIBOLD = find_font("Montserrat-SemiBold.ttf")
|
||||||
|
FONT_LIGHT = find_font("Montserrat-Light.ttf")
|
||||||
|
|
||||||
|
D = 1024.0 # design space is always 1024x1024
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- geometry ----
|
||||||
|
RING_R = 340.0 # ring centreline radius
|
||||||
|
RING_W = 17.0 # ring stroke width
|
||||||
|
RING_GAP = 22.0 # degrees cut away at each diagonal (reticle)
|
||||||
|
|
||||||
|
BRAID_AMP = 76.0 # weave amplitude
|
||||||
|
BRAID_W = 64.0 # weave stroke width
|
||||||
|
BRAID_CORE = 20.0 # bright filament inside the weave
|
||||||
|
BRAID_Y0 = -318.0 # tips land on the ring's top/bottom, inside the
|
||||||
|
BRAID_Y1 = 318.0 # adaptive safe circle even with the round caps
|
||||||
|
BRAID_PERIOD = 318.0 # three crossings, symmetric about the centre
|
||||||
|
|
||||||
|
SAFE_DP = 64.0 # keep the mark inside the 66dp adaptive safe circle
|
||||||
|
CANVAS_DP = 108.0
|
||||||
|
|
||||||
|
|
||||||
|
def ring_scale(ring_px: float) -> float:
|
||||||
|
return (ring_px / 2.0) / RING_R
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- shapes ----
|
||||||
|
def leaf(sign: int) -> str:
|
||||||
|
"""One page of the open book; sign = -1 (left) or +1 (right)."""
|
||||||
|
s = sign
|
||||||
|
return (
|
||||||
|
f"M 0,-150 "
|
||||||
|
f"C {s * 60},-181 {s * 162},-183 {s * 233},-159 "
|
||||||
|
f"L {s * 241},-153 C {s * 249},-148 {s * 252},-140 {s * 252},-131 "
|
||||||
|
f"L {s * 252},110 C {s * 252},120 {s * 246},130 {s * 235},133 "
|
||||||
|
f"C {s * 166},153 {s * 62},151 0,116 Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# (y, inner x, outer x) for the ruled lines on each page.
|
||||||
|
RULES = [(-96, 122, 226), (-34, 122, 226), (28, 122, 226), (86, 122, 182)]
|
||||||
|
|
||||||
|
|
||||||
|
def braid_points(sign: int, n: int = 360) -> list[tuple[float, float]]:
|
||||||
|
pts = []
|
||||||
|
for i in range(n + 1):
|
||||||
|
y = BRAID_Y0 + (BRAID_Y1 - BRAID_Y0) * i / n
|
||||||
|
x = sign * BRAID_AMP * math.sin(2 * math.pi * (y - BRAID_Y0) / BRAID_PERIOD)
|
||||||
|
pts.append((x, y))
|
||||||
|
return pts
|
||||||
|
|
||||||
|
|
||||||
|
def polyline(pts: list[tuple[float, float]]) -> str:
|
||||||
|
return (f"M {pts[0][0]:.2f},{pts[0][1]:.2f}"
|
||||||
|
+ "".join(f" L {x:.2f},{y:.2f}" for x, y in pts[1:]))
|
||||||
|
|
||||||
|
|
||||||
|
def ring_arcs(r: float, gap_deg: float, n: int = 72) -> list[str]:
|
||||||
|
"""Four arcs of a circle, the cut-outs centred on the diagonals."""
|
||||||
|
arcs = []
|
||||||
|
for centre in (0.0, 90.0, 180.0, 270.0):
|
||||||
|
a0 = math.radians(centre - (90.0 - gap_deg) / 2.0)
|
||||||
|
a1 = math.radians(centre + (90.0 - gap_deg) / 2.0)
|
||||||
|
pts = [(r * math.cos(a0 + (a1 - a0) * i / n),
|
||||||
|
r * math.sin(a0 + (a1 - a0) * i / n)) for i in range(n + 1)]
|
||||||
|
arcs.append(polyline(pts))
|
||||||
|
return arcs
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- emblem ------
|
||||||
|
def emblem_defs(mono: bool = False) -> str:
|
||||||
|
if mono:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return """
|
||||||
|
<linearGradient id="weave" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#7FE8FF"/>
|
||||||
|
<stop offset="34%" stop-color="#00BFFF"/>
|
||||||
|
<stop offset="100%" stop-color="#0089A6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paper" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#E2C79C"/>
|
||||||
|
<stop offset="28%" stop-color="#C49B6C"/>
|
||||||
|
<stop offset="66%" stop-color="#A67C52"/>
|
||||||
|
<stop offset="100%" stop-color="#7B5836"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="inner" cx="50%" cy="46%" r="62%">
|
||||||
|
<stop offset="0%" stop-color="#00BFFF" stop-opacity="0.42"/>
|
||||||
|
<stop offset="55%" stop-color="#007A8C" stop-opacity="0.16"/>
|
||||||
|
<stop offset="100%" stop-color="#007A8C" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="bloom" x="-60%" y="-60%" width="220%" height="220%">
|
||||||
|
<feGaussianBlur stdDeviation="24"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="bloom2" x="-60%" y="-60%" width="220%" height="220%">
|
||||||
|
<feGaussianBlur stdDeviation="9"/>
|
||||||
|
</filter>
|
||||||
|
<clipPath id="braidband"><rect x="-260" y="-44" width="520" height="88"/></clipPath>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def braid_pass(stroke: str, width: float, extra: str = "") -> str:
|
||||||
|
"""Both strands with the middle crossing on top, so the weave reads over/under."""
|
||||||
|
a = polyline(braid_points(1))
|
||||||
|
b = polyline(braid_points(-1))
|
||||||
|
common = f'fill="none" stroke="{stroke}" stroke-width="{width}" stroke-linecap="round" {extra}'
|
||||||
|
return (f'<path d="{b}" {common}/>'
|
||||||
|
f'<path d="{a}" {common}/>'
|
||||||
|
f'<g clip-path="url(#braidband)"><path d="{b}" {common}/></g>')
|
||||||
|
|
||||||
|
|
||||||
|
def emblem(cx: float, cy: float, ring_px: float) -> str:
|
||||||
|
"""The mark: reticle, open book, and the weave running through its gutter."""
|
||||||
|
s = ring_scale(ring_px)
|
||||||
|
arcs = "".join(f'<path d="{d}"/>' for d in ring_arcs(RING_R, RING_GAP))
|
||||||
|
rules = []
|
||||||
|
for y, xi, xo in RULES:
|
||||||
|
for sign in (-1, 1):
|
||||||
|
x = sign * xo if sign < 0 else xi
|
||||||
|
rules.append(f'<rect x="{x:.1f}" y="{y - 9:.1f}" width="{xo - xi}" '
|
||||||
|
f'height="18" rx="9" fill="{ICE}" opacity="0.82"/>')
|
||||||
|
|
||||||
|
return f"""<g transform="translate({cx:.1f} {cy:.1f}) scale({s:.5f})">
|
||||||
|
<circle cx="0" cy="0" r="{RING_R:.0f}" fill="url(#inner)"/>
|
||||||
|
<!-- bloom pass: the luminous shapes, blurred beneath everything -->
|
||||||
|
<g filter="url(#bloom)" opacity="0.8">
|
||||||
|
<g fill="none" stroke="#00BFFF" stroke-width="{RING_W}" stroke-linecap="round">{arcs}</g>
|
||||||
|
{braid_pass('#00BFFF', BRAID_W)}
|
||||||
|
<path d="{leaf(-1)}" fill="#007A8C"/>
|
||||||
|
<path d="{leaf(1)}" fill="#007A8C"/>
|
||||||
|
</g>
|
||||||
|
<!-- the book: warm bronze, lit from above -->
|
||||||
|
<path d="{leaf(-1)}" fill="url(#paper)"/>
|
||||||
|
<path d="{leaf(1)}" fill="url(#paper)"/>
|
||||||
|
<g fill="none" stroke="url(#weave)" stroke-width="5" opacity="0.4">
|
||||||
|
<path d="{leaf(-1)}"/><path d="{leaf(1)}"/>
|
||||||
|
</g>
|
||||||
|
<g>{''.join(rules)}</g>
|
||||||
|
<!-- the weave: cyan ribbon with a bright filament core -->
|
||||||
|
<g filter="url(#bloom2)" opacity="0.55">{braid_pass('#00BFFF', BRAID_W)}</g>
|
||||||
|
{braid_pass('url(#weave)', BRAID_W)}
|
||||||
|
{braid_pass('#EAFDFF', BRAID_CORE, 'opacity="0.9"')}
|
||||||
|
<!-- the reticle sits on top, so the weave passes behind it -->
|
||||||
|
<g fill="none" stroke="url(#weave)" stroke-width="{RING_W}" stroke-linecap="round">{arcs}</g>
|
||||||
|
</g>"""
|
||||||
|
|
||||||
|
|
||||||
|
def background(rounded: float = 0.0, halo: bool = True) -> str:
|
||||||
|
r = f' rx="{rounded:.0f}"' if rounded else ""
|
||||||
|
glow = ""
|
||||||
|
if halo:
|
||||||
|
glow = (
|
||||||
|
'<defs>'
|
||||||
|
'<radialGradient id="halo" cx="50%" cy="44%" r="66%">'
|
||||||
|
'<stop offset="0%" stop-color="#007A8C" stop-opacity="0.55"/>'
|
||||||
|
'<stop offset="42%" stop-color="#007A8C" stop-opacity="0.20"/>'
|
||||||
|
'<stop offset="100%" stop-color="#0A0F1F" stop-opacity="0"/></radialGradient>'
|
||||||
|
'<radialGradient id="vign" cx="50%" cy="50%" r="74%">'
|
||||||
|
'<stop offset="52%" stop-color="#05070F" stop-opacity="0"/>'
|
||||||
|
'<stop offset="100%" stop-color="#05070F" stop-opacity="0.92"/></radialGradient>'
|
||||||
|
'</defs>'
|
||||||
|
f'<rect x="0" y="0" width="{D}" height="{D}"{r} fill="url(#halo)"/>'
|
||||||
|
f'<rect x="0" y="0" width="{D}" height="{D}"{r} fill="url(#vign)"/>'
|
||||||
|
)
|
||||||
|
return f'<rect x="0" y="0" width="{D}" height="{D}"{r} fill="{BG}"/>{glow}'
|
||||||
|
|
||||||
|
|
||||||
|
def svg(body: str, mono: bool = False) -> str:
|
||||||
|
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {D:.0f} {D:.0f}" '
|
||||||
|
f'width="{D:.0f}" height="{D:.0f}"><defs>{emblem_defs(mono)}</defs>{body}</svg>')
|
||||||
|
|
||||||
|
|
||||||
|
def rasterise(markup: str, px: int, supersample: int = 4) -> Image.Image:
|
||||||
|
import io
|
||||||
|
big = px * supersample
|
||||||
|
png = cairosvg.svg2png(bytestring=markup.encode(), output_width=big, output_height=big)
|
||||||
|
return Image.open(io.BytesIO(png)).convert("RGBA").resize((px, px), Image.LANCZOS)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- compose ----
|
||||||
|
def fit_font(path: Path, text: str, target_w: float) -> ImageFont.FreeTypeFont:
|
||||||
|
"""Largest size at which `text` measures `target_w`."""
|
||||||
|
probe = ImageFont.truetype(str(path), 100)
|
||||||
|
box = probe.getbbox(text)
|
||||||
|
return ImageFont.truetype(str(path), int(100 * target_w / (box[2] - box[0])))
|
||||||
|
|
||||||
|
|
||||||
|
def draw_wordmark(img: Image.Image, scale: float) -> Image.Image:
|
||||||
|
"""'PageScribe' in Montserrat SemiBold, 'UmbraTools.com' in Light below."""
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
mid = img.width / 2
|
||||||
|
title = fit_font(FONT_SEMIBOLD, "PageScribe", 660 * scale)
|
||||||
|
sub = fit_font(FONT_LIGHT, "UmbraTools.com", 330 * scale)
|
||||||
|
|
||||||
|
glow = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
||||||
|
ImageDraw.Draw(glow).text((mid, 872 * scale), "PageScribe", font=title,
|
||||||
|
fill=(0, 191, 255, 150), anchor="ms")
|
||||||
|
img.alpha_composite(glow.filter(ImageFilter.GaussianBlur(13 * scale)))
|
||||||
|
|
||||||
|
draw.text((mid, 872 * scale), "PageScribe", font=title, fill=ICE, anchor="ms")
|
||||||
|
draw.text((mid, 948 * scale), "UmbraTools.com", font=sub,
|
||||||
|
fill=(0, 191, 255, 205), anchor="ms")
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def icon_png(px: int, rounded_frac: float = 0.0, ring_px: float = 690.0,
|
||||||
|
wordmark: bool = False) -> Image.Image:
|
||||||
|
radius = rounded_frac * D
|
||||||
|
body = background(radius)
|
||||||
|
body += emblem(D / 2, 420 if wordmark else D / 2, ring_px)
|
||||||
|
img = rasterise(svg(body), px)
|
||||||
|
if wordmark and px >= 512:
|
||||||
|
img = draw_wordmark(img, scale=px / 1024.0)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def adaptive_layer(px: int, layer: str) -> Image.Image:
|
||||||
|
ring = SAFE_DP / CANVAS_DP * D
|
||||||
|
if layer == "background":
|
||||||
|
return rasterise(svg(background()), px)
|
||||||
|
if layer == "monochrome":
|
||||||
|
return monochrome_layer(px)
|
||||||
|
return rasterise(svg(emblem(D / 2, D / 2, ring)), px)
|
||||||
|
|
||||||
|
|
||||||
|
def monochrome_layer(px: int) -> Image.Image:
|
||||||
|
"""Single-colour themed icon: the mark with the weave cut out as a groove.
|
||||||
|
|
||||||
|
cairosvg's handling of SVG <mask> is unreliable, so the groove is cut with
|
||||||
|
PIL alpha arithmetic instead: draw the solid shapes, draw the braid, and
|
||||||
|
subtract the braid's coverage.
|
||||||
|
"""
|
||||||
|
s = ring_scale(SAFE_DP / CANVAS_DP * D)
|
||||||
|
arcs = "".join(f'<path d="{d}"/>' for d in ring_arcs(RING_R, RING_GAP))
|
||||||
|
wrap = (f'<g transform="translate({D / 2:.1f} {D / 2:.1f}) scale({s:.5f})">{{}}</g>')
|
||||||
|
|
||||||
|
shapes = wrap.format(
|
||||||
|
f'<g fill="#FFFFFF"><path d="{leaf(-1)}"/><path d="{leaf(1)}"/></g>'
|
||||||
|
f'<g fill="none" stroke="#FFFFFF" stroke-width="{RING_W}" '
|
||||||
|
f'stroke-linecap="round">{arcs}</g>'
|
||||||
|
)
|
||||||
|
braid = "".join(
|
||||||
|
f'<path d="{polyline(braid_points(sign))}" fill="none" stroke="#FFFFFF" '
|
||||||
|
f'stroke-width="{BRAID_W}" stroke-linecap="round"/>' for sign in (1, -1)
|
||||||
|
)
|
||||||
|
shape_img = rasterise(svg(shapes), px)
|
||||||
|
groove_img = rasterise(svg(wrap.format(braid)), px)
|
||||||
|
|
||||||
|
r, g, b, alpha = shape_img.split()
|
||||||
|
alpha = ImageChops.subtract(alpha, groove_img.split()[3])
|
||||||
|
return Image.merge("RGBA", (r, g, b, alpha))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ main ----
|
||||||
|
RES = HERE.parent / "app/src/main/res"
|
||||||
|
BRANDBOOK = HERE
|
||||||
|
DENSITIES = {"mdpi": 1.0, "hdpi": 1.5, "xhdpi": 2.0, "xxhdpi": 3.0, "xxxhdpi": 4.0}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
BRANDBOOK.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
icon_png(1024, ring_px=580, wordmark=True).save(BRANDBOOK / "pagescribe-icon-1024.png")
|
||||||
|
icon_png(512, ring_px=580, wordmark=True).save(BRANDBOOK / "pagescribe-playstore-512.png")
|
||||||
|
icon_png(1024, rounded_frac=0.229, ring_px=690).save(BRANDBOOK / "pagescribe-mark-1024.png")
|
||||||
|
|
||||||
|
for name, frac in (("ic_launcher", 0.229), ("ic_launcher_round", 0.5)):
|
||||||
|
for dens, f in DENSITIES.items():
|
||||||
|
out = RES / f"mipmap-{dens}"
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
icon_png(int(round(48 * f)), rounded_frac=frac, ring_px=820).save(out / f"{name}.png")
|
||||||
|
|
||||||
|
for dens, f in DENSITIES.items():
|
||||||
|
out = RES / f"mipmap-{dens}"
|
||||||
|
for layer in ("background", "foreground", "monochrome"):
|
||||||
|
adaptive_layer(int(round(108 * f)), layer).save(out / f"ic_launcher_{layer}.png")
|
||||||
|
print("wrote icon assets")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
BIN
brand/pagescribe-icon-1024.png
Normal file
|
After Width: | Height: | Size: 328 KiB |
BIN
brand/pagescribe-mark-1024.png
Normal file
|
After Width: | Height: | Size: 310 KiB |
BIN
brand/pagescribe-playstore-512.png
Normal file
|
After Width: | Height: | Size: 139 KiB |