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
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:
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user