2
0

fix(socialcard): improve typography and avatar rendering
All checks were successful
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 2m42s
Build and Release / Create Release (push) Successful in 0s
Build and Release / Lint (push) Successful in 5m31s
Build and Release / Unit Tests (push) Successful in 5m54s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 3m13s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 8m3s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 9m4s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 8h16m37s
Build and Release / Build Binary (linux/arm64) (push) Successful in 8m54s

Adjust font sizes and line heights for better text layout in preset cards (64px title, 28px desc). Create separate portrait font faces with 56px title for portrait formats. Fix avatar rendering to use proper alpha compositing for transparent images. Change delete image endpoint from DELETE to POST method and add author attribution overlay to Unsplash photo thumbnails.
This commit is contained in:
2026-01-30 23:52:32 -05:00
parent 7c1595859e
commit 3c98948218
3 changed files with 81 additions and 31 deletions

View File

@@ -213,12 +213,15 @@ func (r *Renderer) renderPresetCard(data CardData, theme Theme) ([]byte, error)
}
// Calculate total text block height for vertical centering
titleLineH := 76 // line height for 64px title
descLineH := 38 // line height for 28px desc
titleLines := wrapText(data.Title, faces.title, contentWidth)
if len(titleLines) > 2 {
titleLines = titleLines[:2]
titleLines[1] = truncateWithEllipsis(titleLines[1], faces.title, contentWidth)
}
titleBlockH := len(titleLines) * 60
titleBlockH := len(titleLines) * titleLineH
var descLines []string
descBlockH := 0
@@ -228,25 +231,25 @@ func (r *Renderer) renderPresetCard(data CardData, theme Theme) ([]byte, error)
descLines = descLines[:3]
descLines[2] = truncateWithEllipsis(descLines[2], faces.desc, contentWidth)
}
descBlockH = len(descLines)*34 + 16 // 16 = gap between title and desc
descBlockH = len(descLines)*descLineH + 16
}
metaH := 28 // height of bottom metadata line
metaH := 30
gapBeforeMeta := 24
totalH := titleBlockH + descBlockH + gapBeforeMeta + metaH
// Vertically center the text block
startY := (CardHeight-totalH)/2 + 48 // baseline of first title line
startY := (CardHeight-totalH)/2 + 64 // baseline of first title line
// Draw title
for i, line := range titleLines {
drawText(img, leftPad, startY+i*60, line, faces.title, theme.TitleColor)
drawText(img, leftPad, startY+i*titleLineH, line, faces.title, theme.TitleColor)
}
// Draw description
descY := startY + titleBlockH + 16
for i, line := range descLines {
drawText(img, leftPad, descY+i*34, line, faces.desc, theme.DescColor)
drawText(img, leftPad, descY+i*descLineH, line, faces.desc, theme.DescColor)
}
// Bottom metadata: language + repo name
@@ -294,7 +297,7 @@ func (r *Renderer) renderSolidCard(data CardData) ([]byte, error) {
cropped, cropped.Bounds().Min, draw.Src)
}
faces, err := r.createFaces()
faces, err := r.createPortraitFaces()
if err != nil {
return nil, err
}
@@ -384,7 +387,7 @@ func (r *Renderer) renderImageCard(data CardData) ([]byte, error) {
descColor := color.RGBA{R: 230, G: 230, B: 230, A: 255}
subtextColor := color.RGBA{R: 170, G: 170, B: 170, A: 255}
faces, err := r.createFaces()
faces, err := r.createPortraitFaces()
if err != nil {
return nil, err
}
@@ -467,21 +470,29 @@ func (f *fontFaces) close() {
}
func (r *Renderer) createFaces() (*fontFaces, error) {
return r.createFacesWithSizes(64, 28, 22)
}
func (r *Renderer) createPortraitFaces() (*fontFaces, error) {
return r.createFacesWithSizes(56, 28, 22)
}
func (r *Renderer) createFacesWithSizes(titleSize, descSize, metaSize float64) (*fontFaces, error) {
titleFace, err := opentype.NewFace(r.boldFont, &opentype.FaceOptions{
Size: 48, DPI: 72, Hinting: font.HintingFull,
Size: titleSize, DPI: 72, Hinting: font.HintingFull,
})
if err != nil {
return nil, fmt.Errorf("create title face: %w", err)
}
descFace, err := opentype.NewFace(r.regularFont, &opentype.FaceOptions{
Size: 24, DPI: 72, Hinting: font.HintingFull,
Size: descSize, DPI: 72, Hinting: font.HintingFull,
})
if err != nil {
titleFace.Close()
return nil, fmt.Errorf("create desc face: %w", err)
}
metaFace, err := opentype.NewFace(r.regularFont, &opentype.FaceOptions{
Size: 20, DPI: 72, Hinting: font.HintingFull,
Size: metaSize, DPI: 72, Hinting: font.HintingFull,
})
if err != nil {
titleFace.Close()
@@ -568,7 +579,8 @@ func drawCircle(img *image.RGBA, cx, cy, radius int, c color.RGBA) {
}
}
// drawRoundedAvatar draws an avatar image clipped to a rounded rectangle.
// drawRoundedAvatar draws an avatar image clipped to a rounded rectangle
// using proper alpha compositing so transparent areas show the card background.
func drawRoundedAvatar(dst *image.RGBA, x, y, size, radius int, avatar image.Image) {
scaled := scaleImage(avatar, size, size)
@@ -577,13 +589,31 @@ func drawRoundedAvatar(dst *image.RGBA, x, y, size, radius int, avatar image.Ima
if !insideRoundedRect(px, py, size, size, radius) {
continue
}
r, g, b, a := scaled.At(px, py).RGBA()
dst.SetRGBA(x+px, y+py, color.RGBA{
R: uint8(r >> 8),
G: uint8(g >> 8),
B: uint8(b >> 8),
A: uint8(a >> 8),
})
sr, sg, sb, sa := scaled.At(px, py).RGBA()
if sa == 0 {
continue // fully transparent — skip
}
dx, dy := x+px, y+py
if sa == 0xFFFF {
// Fully opaque — direct write
dst.SetRGBA(dx, dy, color.RGBA{
R: uint8(sr >> 8), G: uint8(sg >> 8),
B: uint8(sb >> 8), A: 255,
})
} else {
// Alpha composite: dst = src over dst
dr, dg, db, da := dst.At(dx, dy).RGBA()
a := sa
invA := 0xFFFF - a
outR := (sr*a + dr*invA) / 0xFFFF
outG := (sg*a + dg*invA) / 0xFFFF
outB := (sb*a + db*invA) / 0xFFFF
outA := a + (da*invA)/0xFFFF
dst.SetRGBA(dx, dy, color.RGBA{
R: uint8(outR >> 8), G: uint8(outG >> 8),
B: uint8(outB >> 8), A: uint8(outA >> 8),
})
}
}
}
}

View File

@@ -1198,7 +1198,7 @@ func registerWebRoutes(m *web.Router) {
m.Group("/media_kit", func() {
m.Combo("").Get(repo_setting.MediaKit).Post(repo_setting.MediaKitPost)
m.Post("/upload", repo_setting.MediaKitUploadImage)
m.Delete("/image", repo_setting.MediaKitDeleteImage)
m.Post("/delete_image", repo_setting.MediaKitDeleteImage)
m.Get("/unsplash/search", repo_setting.MediaKitUnsplashSearch)
m.Post("/unsplash/select", repo_setting.MediaKitUnsplashSelect)
})

View File

@@ -74,7 +74,7 @@
{{svg "octicon-image" 14}} Custom uploaded image
{{end}}
</span>
<button class="ui mini red button link-action" data-url="{{.Link}}/image" data-method="DELETE">
<button class="ui mini red button link-action" data-url="{{.Link}}/delete_image">
{{svg "octicon-trash" 14}} {{ctx.Locale.Tr "repo.settings.media_kit.delete_image"}}
</button>
</div>
@@ -191,18 +191,38 @@
loadingDiv.classList.add('tw-hidden');
photos.forEach(function(photo) {
const el = document.createElement('div');
el.style.cssText = 'cursor:pointer;border-radius:6px;overflow:hidden;border:2px solid transparent;';
el.innerHTML = '<img src="' + photo.thumb + '" style="width:100%;height:100px;object-fit:cover;display:block;" title="Photo by ' + photo.author + '">';
el.style.cssText = 'cursor:pointer;border-radius:6px;overflow:hidden;border:2px solid transparent;position:relative;';
el.innerHTML = '<img src="' + photo.thumb + '" style="width:100%;height:100px;object-fit:cover;display:block;" title="Photo by ' + photo.author + '">'
+ '<div style="position:absolute;bottom:0;left:0;right:0;padding:2px 6px;background:rgba(0,0,0,0.6);color:#fff;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + photo.author + '</div>';
el.addEventListener('click', async function() {
// Deselect all others
resultsDiv.querySelectorAll('div').forEach(function(d) { d.style.borderColor = 'transparent'; });
el.style.borderColor = 'var(--color-primary)';
const csrf = document.querySelector('input[name="_csrf"]').value;
const fd = new FormData();
fd.append('_csrf', csrf);
fd.append('id', photo.id);
fd.append('author', photo.author);
fd.append('url', photo.regular);
await fetch('{{.Link}}/unsplash/select', {method: 'POST', body: fd});
updatePreview();
el.style.opacity = '0.7';
el.style.pointerEvents = 'none';
try {
const csrf = document.querySelector('input[name="_csrf"]').value;
const fd = new FormData();
fd.append('_csrf', csrf);
fd.append('id', photo.id);
fd.append('author', photo.author);
fd.append('url', photo.regular);
const resp = await fetch('{{.Link}}/unsplash/select', {method: 'POST', body: fd});
if (resp.ok) {
// Switch radio to "image" and update UI
const imageRadio = document.querySelector('.style-radio[value="image"]');
if (imageRadio) {
imageRadio.checked = true;
updateButtonStyles();
solidOpts.style.display = 'none';
imageOpts.style.display = '';
}
updatePreview();
}
} finally {
el.style.opacity = '1';
el.style.pointerEvents = '';
}
});
resultsDiv.appendChild(el);
});