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
|
||||
.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
|
||||
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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
<application
|
||||
android:name=".PageScribeApp"
|
||||
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:supportsRtl="true"
|
||||
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 |