20a73ea957
frame_panorama_codex.py / _ipad.py: panorámica premium oscura (Impact, marco real, +22.7% verde, línea continua) con copy localizado nativo en 7 idiomas. Usado para las screenshots publicadas de la 1.5.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
523 lines
18 KiB
Python
523 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
"""Render the high-impact Portfolio Journal App Store panorama.
|
||
|
||
The output is one continuous 5 x 1320 scene sliced into App Store panels.
|
||
Run with Scripts/aso/.venv/bin/python.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from pathlib import Path
|
||
|
||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
RAW = ROOT / "build/screenshots/src_raw"
|
||
|
||
PANEL_W = 1320
|
||
HEIGHT = 2868
|
||
PANELS = 5
|
||
WIDTH = PANEL_W * PANELS
|
||
|
||
INK = (244, 248, 255)
|
||
MUTED = (155, 169, 191)
|
||
GREEN = (16, 185, 129)
|
||
BLUE = (48, 132, 255)
|
||
NAVY = (3, 9, 22)
|
||
|
||
DISPLAY_FONT = "/System/Library/Fonts/Supplemental/Impact.ttf"
|
||
TEXT_FONT = "/System/Library/Fonts/Avenir Next.ttc"
|
||
CONDENSED_FONT = "/System/Library/Fonts/Avenir Next Condensed.ttc"
|
||
JAPANESE_FONT_CANDIDATES = (
|
||
"/System/Library/Fonts/ヒラギノ角ゴシック W8.ttc",
|
||
"/System/Library/Fonts/ヒラギノ角ゴシック W9.ttc",
|
||
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
||
)
|
||
|
||
LOCALIZED_SPECS = {
|
||
"es-ES": [
|
||
{
|
||
"eyebrow": "TU PATRIMONIO",
|
||
"headline": ("CONOCE TU", "PATRIMONIO."),
|
||
"body": "Todo lo que tienes, de un vistazo.",
|
||
},
|
||
{
|
||
"eyebrow": "CRECIMIENTO REAL",
|
||
"headline": ("MÍRALO", "AUMENTAR."),
|
||
"stat": "+22.7%",
|
||
"body": "Desde el inicio. Y sigue subiendo.",
|
||
},
|
||
{
|
||
"eyebrow": "TODA TU CARTERA",
|
||
"headline": ("TODO LO", "QUE TIENES."),
|
||
"body": "Acciones, inmuebles, cripto y efectivo.",
|
||
},
|
||
{
|
||
"eyebrow": "DECIDE CON DATOS",
|
||
"headline": ("DESCUBRE TUS", "GANADORES."),
|
||
"body": "Rentabilidad real. Sin suposiciones.",
|
||
},
|
||
{
|
||
"eyebrow": "PRIVADO DE SERIE",
|
||
"headline": ("SIN CLAVES", "BANCARIAS."),
|
||
"body": "Tus datos nunca salen de tu iPhone.",
|
||
},
|
||
],
|
||
"de": [
|
||
{
|
||
"eyebrow": "DEIN VERMÖGEN",
|
||
"headline": ("KENN DEIN", "VERMÖGEN."),
|
||
"body": "Alles, was dir gehört — auf einen Blick.",
|
||
},
|
||
{
|
||
"eyebrow": "ECHTES VERMÖGEN. ECHTER SCHWUNG.",
|
||
"headline": ("SIEH ES", "WACHSEN."),
|
||
"stat": "+22.7%",
|
||
"body": "Seit Beginn — und weiter aufwärts.",
|
||
},
|
||
{
|
||
"eyebrow": "DAS GANZE PORTFOLIO",
|
||
"headline": ("ALLES, WAS", "DIR GEHÖRT."),
|
||
"body": "Aktien · Immobilien · Krypto · Cash",
|
||
},
|
||
{
|
||
"eyebrow": "KLARHEIT SCHAFFT ÜBERZEUGUNG",
|
||
"headline": ("FINDE DEINE", "GEWINNER."),
|
||
"body": "Echter CAGR. Kein Rätselraten.",
|
||
},
|
||
{
|
||
"eyebrow": "PRIVAT. VON GRUND AUF.",
|
||
"headline": ("KEINE BANK-", "LOGINS. NIE."),
|
||
"body": "Deine Zahlen bleiben auf deinem iPhone.",
|
||
},
|
||
],
|
||
"fr": [
|
||
{
|
||
"eyebrow": "VOTRE PATRIMOINE",
|
||
"headline": ("CONNAIS TON", "PATRIMOINE."),
|
||
"body": "Tout ce que tu possèdes, d’un coup d’œil.",
|
||
},
|
||
{
|
||
"eyebrow": "VRAIE RICHESSE. VRAI ÉLAN.",
|
||
"headline": ("REGARDE-LE", "FRUCTIFIER."),
|
||
"stat": "+22.7%",
|
||
"body": "Depuis le début — et ça continue.",
|
||
},
|
||
{
|
||
"eyebrow": "TOUT LE PORTEFEUILLE",
|
||
"headline": ("TOUT CE QUE", "TU POSSÈDES."),
|
||
"body": "Actions · immobilier · crypto · liquidités",
|
||
},
|
||
{
|
||
"eyebrow": "LA CLARTÉ CRÉE LA CONVICTION",
|
||
"headline": ("REPÈRE TES", "GAGNANTS."),
|
||
"body": "CAGR réel. Zéro approximation.",
|
||
},
|
||
{
|
||
"eyebrow": "CONFIDENTIEL PAR CONCEPTION",
|
||
"headline": ("AUCUN ACCÈS", "BANCAIRE. JAMAIS."),
|
||
"body": "Tes chiffres ne quittent jamais ton iPhone.",
|
||
},
|
||
],
|
||
"it": [
|
||
{
|
||
"eyebrow": "IL TUO PATRIMONIO",
|
||
"headline": ("CONOSCI IL TUO", "PATRIMONIO."),
|
||
"body": "Tutto ciò che hai, in un colpo d’occhio.",
|
||
},
|
||
{
|
||
"eyebrow": "PATRIMONIO REALE. SLANCIO REALE.",
|
||
"headline": ("GUARDALO", "CRESCERE."),
|
||
"stat": "+22.7%",
|
||
"body": "Dall’inizio — e continua a salire.",
|
||
},
|
||
{
|
||
"eyebrow": "TUTTO IL PORTAFOGLIO",
|
||
"headline": ("TUTTO CIÒ", "CHE POSSIEDI."),
|
||
"body": "Azioni · immobili · crypto · liquidità",
|
||
},
|
||
{
|
||
"eyebrow": "LA CHIAREZZA CREA CONVINZIONE",
|
||
"headline": ("SCOPRI I TUOI", "VINCENTI."),
|
||
"body": "CAGR reale. Zero ipotesi.",
|
||
},
|
||
{
|
||
"eyebrow": "PRIVATO PER NATURA",
|
||
"headline": ("NESSUN LOGIN", "BANCARIO. MAI."),
|
||
"body": "I tuoi numeri restano sul tuo iPhone.",
|
||
},
|
||
],
|
||
"ja": [
|
||
{
|
||
"eyebrow": "資産管理",
|
||
"headline": ("純資産を", "ひと目で。"),
|
||
"body": "持っている資産のすべてを。",
|
||
},
|
||
{
|
||
"eyebrow": "着実な成長",
|
||
"headline": ("資産が", "増えていく。"),
|
||
"stat": "+22.7%",
|
||
"body": "運用開始から、今も上昇中。",
|
||
},
|
||
{
|
||
"eyebrow": "ポートフォリオのすべて",
|
||
"headline": ("所有資産を", "すべて。"),
|
||
"body": "株式・不動産・暗号資産・現金",
|
||
},
|
||
{
|
||
"eyebrow": "明確さが確信を生む",
|
||
"headline": ("勝ち筋を", "見極める。"),
|
||
"body": "本当のCAGR。勘に頼らない。",
|
||
},
|
||
{
|
||
"eyebrow": "プライバシーを最優先",
|
||
"headline": ("銀行ログインは", "一切不要。"),
|
||
"body": "あなたの数字はiPhoneの外に出ません。",
|
||
},
|
||
],
|
||
"pt-BR": [
|
||
{
|
||
"eyebrow": "SEU PATRIMÔNIO",
|
||
"headline": ("CONHEÇA SEU", "PATRIMÔNIO."),
|
||
"body": "Tudo o que é seu, num relance.",
|
||
},
|
||
{
|
||
"eyebrow": "PATRIMÔNIO REAL. IMPULSO REAL.",
|
||
"headline": ("VEJA-O", "CRESCER."),
|
||
"stat": "+22.7%",
|
||
"body": "Desde o início — e ainda subindo.",
|
||
},
|
||
{
|
||
"eyebrow": "A CARTEIRA COMPLETA",
|
||
"headline": ("TUDO QUE", "É SEU."),
|
||
"body": "Ações · imóveis · cripto · dinheiro",
|
||
},
|
||
{
|
||
"eyebrow": "CLAREZA GERA CONVICÇÃO",
|
||
"headline": ("ACHE SEUS", "VENCEDORES."),
|
||
"body": "CAGR real. Zero achismo.",
|
||
},
|
||
{
|
||
"eyebrow": "PRIVADO POR NATUREZA",
|
||
"headline": ("SEM LOGIN", "BANCÁRIO. NUNCA."),
|
||
"body": "Seus números nunca saem do seu iPhone.",
|
||
},
|
||
],
|
||
}
|
||
|
||
|
||
def font(path: str, size: int, index: int = 0) -> ImageFont.FreeTypeFont:
|
||
return ImageFont.truetype(path, size=size, index=index)
|
||
|
||
|
||
def contains_cjk(text: str) -> bool:
|
||
return any(
|
||
"\u3040" <= char <= "\u30ff" or "\u3400" <= char <= "\u9fff"
|
||
for char in text
|
||
)
|
||
|
||
|
||
def japanese_font_path() -> str:
|
||
for path in JAPANESE_FONT_CANDIDATES:
|
||
try:
|
||
font(path, 20)
|
||
return path
|
||
except OSError:
|
||
continue
|
||
raise RuntimeError("No supported heavy Japanese font could be loaded")
|
||
|
||
|
||
def face_for(text: str, latin_path: str, size: int, index: int = 0) -> ImageFont.FreeTypeFont:
|
||
if contains_cjk(text):
|
||
return font(japanese_font_path(), size)
|
||
return font(latin_path, size, index)
|
||
|
||
|
||
def gradient_background() -> Image.Image:
|
||
"""Near-black navy base with a continuous blue/green atmospheric wash."""
|
||
column = Image.new("RGB", (1, HEIGHT))
|
||
px = column.load()
|
||
for y in range(HEIGHT):
|
||
t = y / (HEIGHT - 1)
|
||
px[0, y] = (
|
||
int(10 - 7 * t),
|
||
int(21 - 12 * t),
|
||
int(42 - 20 * t),
|
||
)
|
||
bg = column.resize((WIDTH, HEIGHT)).convert("RGBA")
|
||
|
||
glows = [
|
||
(460, 610, 920, BLUE, 58),
|
||
(1670, 1150, 870, GREEN, 42),
|
||
(2890, 520, 760, BLUE, 40),
|
||
(4130, 1240, 950, GREEN, 36),
|
||
(5720, 600, 940, BLUE, 44),
|
||
(6280, 2350, 980, GREEN, 28),
|
||
]
|
||
for cx, cy, radius, color, alpha in glows:
|
||
mask = Image.new("L", (WIDTH, HEIGHT), 0)
|
||
md = ImageDraw.Draw(mask)
|
||
md.ellipse((cx - radius, cy - radius, cx + radius, cy + radius), fill=alpha)
|
||
mask = mask.filter(ImageFilter.GaussianBlur(radius // 2))
|
||
wash = Image.new("RGBA", bg.size, color + (255,))
|
||
bg = Image.composite(wash, bg, mask)
|
||
|
||
# Subtle technical grid keeps the background dimensional without looking busy.
|
||
grid = Image.new("RGBA", bg.size, (0, 0, 0, 0))
|
||
gd = ImageDraw.Draw(grid)
|
||
for x in range(0, WIDTH, 132):
|
||
gd.line((x, 0, x, HEIGHT), fill=(93, 132, 181, 11), width=1)
|
||
for y in range(0, HEIGHT, 132):
|
||
gd.line((0, y, WIDTH, y), fill=(93, 132, 181, 9), width=1)
|
||
bg.alpha_composite(grid)
|
||
return bg
|
||
|
||
|
||
def rounded_mask(size: tuple[int, int], radius: int) -> Image.Image:
|
||
mask = Image.new("L", size, 0)
|
||
ImageDraw.Draw(mask).rounded_rectangle(
|
||
(0, 0, size[0] - 1, size[1] - 1), radius=radius, fill=255
|
||
)
|
||
return mask
|
||
|
||
|
||
def build_iphone(shot: Image.Image, target_w: int) -> Image.Image:
|
||
"""Realistic titanium iPhone frame with bezel, screen and Dynamic Island."""
|
||
outer = max(18, int(target_w * 0.032))
|
||
inner = max(6, int(target_w * 0.009))
|
||
screen_w = target_w - 2 * (outer + inner)
|
||
screen_h = round(screen_w * shot.height / shot.width)
|
||
total_h = screen_h + 2 * (outer + inner)
|
||
corner = int(target_w * 0.126)
|
||
|
||
phone = Image.new("RGBA", (target_w, total_h), (0, 0, 0, 0))
|
||
d = ImageDraw.Draw(phone)
|
||
d.rounded_rectangle(
|
||
(0, 0, target_w - 1, total_h - 1),
|
||
radius=corner + outer,
|
||
fill=(26, 29, 35, 255),
|
||
outline=(147, 154, 166, 255),
|
||
width=max(4, target_w // 180),
|
||
)
|
||
d.rounded_rectangle(
|
||
(outer, outer, target_w - outer - 1, total_h - outer - 1),
|
||
radius=corner,
|
||
fill=(2, 3, 5, 255),
|
||
outline=(79, 84, 94, 255),
|
||
width=max(3, target_w // 240),
|
||
)
|
||
|
||
screen = shot.convert("RGB").resize((screen_w, screen_h), Image.Resampling.LANCZOS)
|
||
screen_radius = int(screen_w * 0.105)
|
||
inset = outer + inner
|
||
phone.paste(screen, (inset, inset), rounded_mask(screen.size, screen_radius))
|
||
|
||
# The source shots already contain a Dynamic Island when captured on-device.
|
||
# Repainting it ensures the one older settings capture matches the same hardware.
|
||
island_w = int(screen_w * 0.315)
|
||
island_h = int(screen_w * 0.084)
|
||
island_x = inset + (screen_w - island_w) // 2
|
||
island_y = inset + int(screen_w * 0.022)
|
||
d.rounded_rectangle(
|
||
(island_x, island_y, island_x + island_w, island_y + island_h),
|
||
radius=island_h // 2,
|
||
fill=(0, 0, 0, 255),
|
||
)
|
||
|
||
# Hardware controls sell the silhouette as a physical iPhone.
|
||
button = (70, 74, 82, 255)
|
||
d.rounded_rectangle((-4, total_h * 0.22, 7, total_h * 0.31), radius=5, fill=button)
|
||
d.rounded_rectangle((-4, total_h * 0.34, 7, total_h * 0.43), radius=5, fill=button)
|
||
d.rounded_rectangle(
|
||
(target_w - 7, total_h * 0.29, target_w + 4, total_h * 0.43),
|
||
radius=5,
|
||
fill=button,
|
||
)
|
||
return phone
|
||
|
||
|
||
def elevated(phone: Image.Image, angle: float, glow: tuple[int, int, int]) -> Image.Image:
|
||
rotated = phone.rotate(
|
||
angle, expand=True, resample=Image.Resampling.BICUBIC
|
||
)
|
||
pad = 160
|
||
stage = Image.new(
|
||
"RGBA", (rotated.width + pad * 2, rotated.height + pad * 2), (0, 0, 0, 0)
|
||
)
|
||
alpha = rotated.getchannel("A")
|
||
|
||
shadow = Image.new("RGBA", rotated.size, (0, 0, 0, 0))
|
||
shadow.putalpha(alpha.point(lambda a: int(a * 0.68)))
|
||
black = Image.new("RGBA", rotated.size, (0, 0, 0, 255))
|
||
black.putalpha(shadow.getchannel("A"))
|
||
stage.alpha_composite(black, (pad + 18, pad + 74))
|
||
stage = stage.filter(ImageFilter.GaussianBlur(70))
|
||
|
||
halo = Image.new("RGBA", rotated.size, glow + (255,))
|
||
halo.putalpha(alpha.point(lambda a: int(a * 0.24)))
|
||
stage.alpha_composite(halo, (pad - 8, pad + 16))
|
||
stage.alpha_composite(rotated, (pad, pad))
|
||
return stage
|
||
|
||
|
||
def text_width(draw: ImageDraw.ImageDraw, text: str, face: ImageFont.FreeTypeFont) -> float:
|
||
return draw.textlength(text, font=face)
|
||
|
||
|
||
def draw_tracking_text(
|
||
draw: ImageDraw.ImageDraw,
|
||
xy: tuple[int, int],
|
||
text: str,
|
||
face: ImageFont.FreeTypeFont,
|
||
fill: tuple[int, int, int],
|
||
tracking: int,
|
||
) -> None:
|
||
x, y = xy
|
||
for char in text:
|
||
draw.text((x, y), char, font=face, fill=fill)
|
||
x += text_width(draw, char, face) + tracking
|
||
|
||
|
||
def fit_display(draw: ImageDraw.ImageDraw, lines: tuple[str, ...], max_width: int) -> ImageFont.FreeTypeFont:
|
||
size = 206
|
||
display_path = japanese_font_path() if any(contains_cjk(line) for line in lines) else DISPLAY_FONT
|
||
while size > 110:
|
||
face = font(display_path, size)
|
||
if max(text_width(draw, line, face) for line in lines) <= max_width:
|
||
return face
|
||
size -= 4
|
||
return font(display_path, size)
|
||
|
||
|
||
def fit_body(draw: ImageDraw.ImageDraw, text: str, max_width: int) -> ImageFont.FreeTypeFont:
|
||
size = 39
|
||
while size > 29:
|
||
face = face_for(text, TEXT_FONT, size)
|
||
if text_width(draw, text, face) <= max_width:
|
||
return face
|
||
size -= 1
|
||
return face_for(text, TEXT_FONT, size)
|
||
|
||
|
||
def draw_campaign_copy(canvas: Image.Image, panel: int, spec: dict) -> None:
|
||
d = ImageDraw.Draw(canvas)
|
||
left = panel * PANEL_W + 92
|
||
right = (panel + 1) * PANEL_W - 92
|
||
top = 78
|
||
|
||
eyebrow = face_for(spec["eyebrow"], CONDENSED_FONT, 33, 0)
|
||
draw_tracking_text(d, (left, top), spec["eyebrow"], eyebrow, GREEN, 5)
|
||
|
||
index = font(CONDENSED_FONT, 31, 0)
|
||
index_text = f"0{panel + 1} / 05"
|
||
d.text(
|
||
(right - text_width(d, index_text, index), top),
|
||
index_text,
|
||
font=index,
|
||
fill=(100, 118, 145),
|
||
)
|
||
d.rounded_rectangle((left, 139, right, 147), radius=4, fill=(38, 57, 82))
|
||
d.rounded_rectangle((left, 139, left + 120, 147), radius=4, fill=GREEN)
|
||
|
||
lines = tuple(spec["headline"])
|
||
display = fit_display(d, lines, PANEL_W - 184)
|
||
line_h = int(display.size * (1.02 if any(contains_cjk(line) for line in lines) else 0.88))
|
||
y = 190
|
||
for line in lines:
|
||
# A restrained blue offset gives the letterforms extra physical punch.
|
||
d.text((left + 6, y + 9), line, font=display, fill=(23, 68, 127))
|
||
d.text((left, y), line, font=display, fill=INK)
|
||
y += line_h
|
||
y += 34
|
||
|
||
if spec.get("stat"):
|
||
stat = font(DISPLAY_FONT, 110)
|
||
d.text((left, y + 8), spec["stat"], font=stat, fill=GREEN)
|
||
y += 139
|
||
|
||
body = fit_body(d, spec["body"], PANEL_W - 184)
|
||
d.text((left, y + 18), spec["body"], font=body, fill=MUTED)
|
||
|
||
|
||
def draw_growth_thread(canvas: Image.Image) -> None:
|
||
"""Continuous electric growth curve tying all five gallery panels together."""
|
||
overlay = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
||
points = [
|
||
(0, 1370),
|
||
(600, 1330),
|
||
(1210, 1380),
|
||
(1770, 1160),
|
||
(2380, 1215),
|
||
(2970, 1030),
|
||
(3590, 1080),
|
||
(4210, 890),
|
||
(4820, 960),
|
||
(5420, 750),
|
||
(6040, 820),
|
||
(6600, 650),
|
||
]
|
||
glow = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
||
gd = ImageDraw.Draw(glow)
|
||
gd.line(points, fill=BLUE + (80,), width=38, joint="curve")
|
||
glow = glow.filter(ImageFilter.GaussianBlur(30))
|
||
overlay.alpha_composite(glow)
|
||
od = ImageDraw.Draw(overlay)
|
||
od.line(points, fill=BLUE + (135,), width=5, joint="curve")
|
||
for x, y in points[1:-1]:
|
||
od.ellipse((x - 9, y - 9, x + 9, y + 9), fill=GREEN + (190,))
|
||
canvas.alpha_composite(overlay)
|
||
|
||
|
||
def render(specs: list[dict], output_dir: Path) -> list[Path]:
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
canvas = gradient_background()
|
||
draw_growth_thread(canvas)
|
||
|
||
for i, spec in enumerate(specs):
|
||
draw_campaign_copy(canvas, i, spec)
|
||
|
||
# One FULL iPhone per panel — sized + centered so nothing is clipped at the
|
||
# panel borders even after the tilt + halo padding. Panel centers are at
|
||
# 660, 1980, 3300, 4620, 5940 (PANEL_W = 1320). Widths kept small enough that
|
||
# rotated_width + halo stays inside the panel; top_y leaves the device fully
|
||
# visible (only a small, tasteful bottom bleed).
|
||
placements = [
|
||
# source, width, angle, center x, top y, halo color
|
||
("01_home.png", 860, -4.5, 660, 900, BLUE),
|
||
("02_evolution.png", 860, 4.5, 1980, 960, GREEN),
|
||
("03_allocation.png", 860, -3.6, 3300, 910, BLUE),
|
||
("04_performance.png", 860, 4.0, 4620, 950, GREEN),
|
||
("05_settings.png", 860, -4.0, 5940, 895, BLUE),
|
||
]
|
||
for source, width, angle, center_x, top_y, halo in placements:
|
||
shot = Image.open(RAW / source)
|
||
phone = elevated(build_iphone(shot, width), angle, halo)
|
||
canvas.alpha_composite(phone, (center_x - phone.width // 2, top_y))
|
||
|
||
rgb = canvas.convert("RGB")
|
||
outputs: list[Path] = []
|
||
for i in range(PANELS):
|
||
path = output_dir / f"portfolio_journal_{i + 1:02d}.png"
|
||
panel = rgb.crop((i * PANEL_W, 0, (i + 1) * PANEL_W, HEIGHT))
|
||
panel.save(path, "PNG", optimize=True)
|
||
outputs.append(path)
|
||
|
||
preview = output_dir / "_preview.png"
|
||
preview_w = 1980
|
||
preview_h = round(HEIGHT * preview_w / WIDTH)
|
||
rgb.resize((preview_w, preview_h), Image.Resampling.LANCZOS).save(
|
||
preview, "PNG", optimize=True
|
||
)
|
||
outputs.append(preview)
|
||
return outputs
|
||
|
||
|
||
if __name__ == "__main__":
|
||
for locale, localized_specs in LOCALIZED_SPECS.items():
|
||
output_dir = ROOT / "build/screenshots" / f"pano_codex_{locale}"
|
||
rendered = render(localized_specs, output_dir)
|
||
for output in rendered:
|
||
with Image.open(output) as image:
|
||
print(f"{output} {image.width}x{image.height}")
|