Files
Jeffie 1aba69c72d Localization: pictures of text per language, with shadows you can set
Some of the game's words are textures, not messages: the area name cards on
the loading screen, the dungeon floor banners and the bosses' names. The disc
has one picture of each per language, and the TrueType font never touched
them.

The port now takes a language's pictures from
lang/textures/<language>/<name>.png (the save folder's before the
executable's), so they follow game.language like the messages do. A picture
is scaled to its texture and left unused if its shape is wrong. The hook sits
at the two texture decode sites and at the loading screen, whose area card is
one texture called "maptitle", so it is named by its file (mt01...).

The pictures are not shipped, since they are made from the disc's art.
tools/textpack/build.py draws them on the player's machine from the words in
tools/textpack/strings/*.json: it erases the old lettering from the disc's
own textures and letters the language's words in the message font (the area
cards and floor labels are that face, squeezed or embossed). The bosses'
names are a serif, for which Cinzel (SIL OFL) stands in.

The pictures carry no shadow; the port casts it, so it follows the Options
sliders: Area Name Shadow, Floor Label Shadow and Boss Name Shadow, next to
Text Shadow and Symbol Shadow. A change shows from the next area that loads
the picture.
2026-10-08 04:29:58 -05:00

221 lines
8.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Text pack renderer: draws the Dark Cloud Compendium font (the localization font) into texture-sized images.
The font's accented letters and the marks acute/diaeresis/cedilla are blank in the file, so they are built here:
base letter + a mark drawn from the font's own grave/circumflex/tilde (acute = mirrored grave) or simple strokes.
Everything is in pixels of the 4x upscaled textures; `scale` converts to other sizes.
"""
import os, unicodedata
import numpy as np
import cv2
from PIL import Image, ImageDraw, ImageFont
HERE = os.path.dirname(os.path.abspath(__file__))
VARIANTS = ('REGULAR', 'BOLD', 'ITALIC', 'THIN') # the four files of the Compendium Community Font
FONT_PATH = os.path.join(HERE, '..', 'font', 'DarkCloudCompendium.ttf')
_fonts = {}
def use_font_file(path):
"""Draw with any TTF instead of the Compendium (used for the boss captions' serif, Cinzel, SIL OFL)."""
global FONT_PATH
FONT_PATH = path
_fonts.clear()
def use_variant(name):
"""Select REGULAR / BOLD / ITALIC / THIN for everything drawn after this call."""
global FONT_PATH
assert name in VARIANTS, name
FONT_PATH = os.path.join(HERE, '..', 'font', 'DarkCloudCompendium.ttf')
_fonts.clear()
def font(px):
if px not in _fonts:
_fonts[px] = ImageFont.truetype(FONT_PATH, px)
return _fonts[px]
def _glyph(ch, px):
"""Return (L image, advance) of one character drawn on a canvas with the baseline at y = 1.2*px."""
f = font(px)
W = int(px * 2.2)
H = int(px * 2.0)
im = Image.new('L', (W, H), 0)
ImageDraw.Draw(im).text((px * 0.4, px * 0.2), ch, font=f, fill=255)
return im, f.getlength(ch)
MARKS = {'̀': '`', '̂': '^', '̃': '~'}
def _compose(ch, px, cap_top_y, cap_h):
"""Draw a letter with accent. Returns (L image canvas, advance)."""
base_ch = unicodedata.normalize('NFD', ch)
base, marks = base_ch[0], base_ch[1:]
im, adv = _glyph(base, px)
bbox = im.getbbox()
if bbox is None:
return im, adv
d = ImageDraw.Draw(im)
cx = (bbox[0] + bbox[2]) / 2
stroke = max(2, int(cap_h * 0.085))
top = bbox[1]
gap = int(cap_h * 0.05)
for m in marks:
if m in MARKS:
mark_im, _ = _glyph(MARKS[m], px)
mb = mark_im.getbbox()
if mb is None:
continue
mark = mark_im.crop(mb)
mark = mark.resize((int(mark.width * 0.8), int(mark.height * 0.8)))
im.paste(255, (int(cx - mark.width / 2), top - gap - mark.height), mark)
elif m == '́': # acute: mirrored grave
mark_im, _ = _glyph('`', px)
mb = mark_im.getbbox()
mark = mark_im.crop(mb).transpose(Image.FLIP_LEFT_RIGHT)
mark = mark.resize((int(mark.width * 0.8), int(mark.height * 0.8)))
im.paste(255, (int(cx - mark.width / 2), top - gap - mark.height), mark)
elif m == '̈': # diaeresis: two short strokes
h = int(cap_h * 0.16)
for dx in (-cap_h * 0.12, cap_h * 0.12):
x = cx + dx
d.line([(x, top - gap - h), (x - 1, top - gap)], fill=255, width=stroke)
elif m == '̧': # cedilla
bot = bbox[3]
d.line([(cx, bot - 2), (cx - 2, bot + int(cap_h * 0.1))], fill=255, width=stroke)
d.line([(cx - 2, bot + int(cap_h * 0.1)), (cx - cap_h * 0.1, bot + int(cap_h * 0.16))], fill=255, width=stroke)
return im, adv
def text_mask(text, cap_h, xsq, tracking=0.0, max_w=None, advance=False):
"""Render `text` (already the final string; caps recommended) -> uint8 mask cropped to ink, plus ink height info.
cap_h: height of capital letters in px. xsq: horizontal squeeze (1 = font's own width).
When max_w is set and the text is wider, the squeeze is reduced to fit.
"""
# find font px so that cap letter height == cap_h
px = 200
probe, _ = _glyph('H', px)
bb = probe.getbbox()
px = int(px * cap_h / (bb[3] - bb[1]))
probe, _ = _glyph('H', px)
hb = probe.getbbox()
cap_top = hb[1]
glyphs = []
x = 0.0
gap = cap_h * (0.075 + tracking)
SMALL = set(",.'’-:;!")
for ch in text:
if ch == ' ':
x += cap_h * (0.26 if advance else 0.32)
continue
gim, adv = _glyph(ch, px)
if gim.getbbox() is None and unicodedata.normalize('NFD', ch) != ch:
gim, adv = _compose(ch, px, cap_top, cap_h) # the font has no glyph for this accented letter
gb = gim.getbbox()
if gb is None:
x += cap_h * 0.2
continue
if advance: # serif faces: use the font's own advance (kerned pairs would need more; Q tails break ink spacing)
glyphs.append((x - px * 0.4, gim))
x += adv + cap_h * tracking
else:
glyphs.append((x - gb[0], gim))
x += (gb[2] - gb[0]) + (gap * 0.5 if ch in SMALL else gap)
total = int(x + px * 3)
canvas = Image.new('L', (total, int(px * 2.0)), 0)
for gx, gim in glyphs:
canvas.paste(255, (int(gx), 0), gim)
arr = np.asarray(canvas)
ys = np.where(arr.max(1) > 0)[0]
xs = np.where(arr.max(0) > 0)[0]
if len(xs) == 0:
return np.zeros((1, 1), np.uint8), 0
# vertical: keep cap top aligned: crop from cap_top-0.45*cap_h to baseline+0.3*cap_h
y0 = max(0, cap_top - int(cap_h * 0.45))
y1 = min(arr.shape[0], cap_top + int(cap_h * 1.35))
crop = arr[y0:y1, xs.min():xs.max() + 1]
w = crop.shape[1]
new_w = max(1, int(w * xsq))
if max_w and new_w > max_w:
new_w = max_w
crop = cv2.resize(crop, (new_w, crop.shape[0]), interpolation=cv2.INTER_AREA)
return crop, cap_top - y0 # cap top row inside the crop
def place(canvas_shape, mask, cap_top_in_mask, cx, cap_top_y):
"""Paste mask onto a blank canvas so its cap top is at cap_top_y and it is centered on cx."""
H, W = canvas_shape
out = np.zeros((H, W), np.uint8)
x0 = int(cx - mask.shape[1] / 2)
y0 = int(cap_top_y - cap_top_in_mask)
sx0, sy0 = max(0, -x0), max(0, -y0)
dx0, dy0 = max(0, x0), max(0, y0)
w = min(mask.shape[1] - sx0, W - dx0)
h = min(mask.shape[0] - sy0, H - dy0)
if w > 0 and h > 0:
out[dy0:dy0 + h, dx0:dx0 + w] = mask[sy0:sy0 + h, sx0:sx0 + w]
return out
def underline_mask(shape, x0, x1, yc, thick):
"""Brush underline: slim tapered stroke with a slightly heavier left end."""
H, W = shape
m = np.zeros((H, W), np.uint8)
n = max(2, int(x1 - x0))
xs = np.linspace(x0, x1, n)
t = np.linspace(0, 1, n)
th = thick * (0.55 + 0.45 * np.sin(np.pi * np.clip(t * 0.85 + 0.05, 0, 1)))
yy = yc + 4 * np.sin(t * np.pi)
top = np.stack([xs, yy - th / 2], 1)
bot = np.stack([xs[::-1], (yy + th / 2)[::-1]], 1)
cv2.fillPoly(m, [np.round(np.concatenate([top, bot])).astype(np.int32)], 255)
return m
def tint(rgb, mask01, base):
"""Composite color rgb over base using mask01 (H,W float)."""
return base * (1 - mask01[..., None]) + np.array(rgb, float)[None, None, :] * mask01[..., None]
def drips(shape, x0, x1, y, n, length):
"""Hanging drops under the underline (Dark Heaven Castle)."""
H, W = shape
m = np.zeros((H, W), np.uint8)
for k in range(n):
x = x0 + (k + 0.5) * (x1 - x0) / n
cv2.fillPoly(m, [np.array([[x - 9, y], [x + 9, y], [x, y + length * (0.7 + 0.3 * ((k * 7) % 3) / 2)]], np.int32)], 255)
return m
def render_card(plate, text, style, cap_h=215, xsq=0.56, cap_top_y=140, ul_y=392, ul_thick=28, max_w=1440, tracking=0.0,
underline=True, return_mask=False):
"""plate: HxWx3 uint8 clean backdrop. style: dict(fill, glow, edge, glow_amount, outline, bold, drips)."""
H, W = plate.shape[:2]
mask, ct = text_mask(text, cap_h, xsq, tracking, max_w=max_w)
k = np.ones((3, 3), np.uint8)
bold = style.get('bold', 2)
if bold:
mask = cv2.dilate(mask, k, iterations=bold)
M = place((H, W), mask, ct, W / 2, cap_top_y)
ys, xs = np.where(M > 0)
if underline and len(xs):
U = underline_mask((H, W), xs.min() - 18, xs.max() + 26, ul_y, ul_thick)
M = np.maximum(M, U)
if style.get('drips'):
M = np.maximum(M, drips((H, W), xs.min(), xs.max(), ul_y + 10, 7, 46))
m = M.astype(np.float32) / 255
out = plate.astype(np.float32)
ga = style.get('glow_amount', 0.85)
if ga > 0:
wide = cv2.GaussianBlur(cv2.dilate(M, k, iterations=9).astype(np.float32) / 255, (0, 0), 11)
out = out + (np.array(style['glow'], np.float32)[None, None] - out) * (np.clip(wide * 1.3, 0, 1) * ga)[..., None]
edge = cv2.GaussianBlur(cv2.dilate(M, k, iterations=style.get('outline', 4)).astype(np.float32) / 255, (0, 0), 1.6)
out = out + (np.array(style['edge'], np.float32)[None, None] - out) * np.clip(edge * 1.4, 0, 1)[..., None]
mm = cv2.GaussianBlur(m, (0, 0), 0.9)
out = tint(style['fill'], mm, out)
out = np.clip(out, 0, 255).astype(np.uint8)
return (out, M) if return_mask else out