-44
appview/pages/templates/fragments/dolly/silhouette.svg
-44
appview/pages/templates/fragments/dolly/silhouette.svg
···
1
-
<svg
2
-
version="1.1"
3
-
id="svg1"
4
-
width="32"
5
-
height="32"
6
-
viewBox="0 0 25 25"
7
-
sodipodi:docname="tangled_dolly_silhouette.png"
8
-
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
9
-
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
10
-
xmlns="http://www.w3.org/2000/svg"
11
-
xmlns:svg="http://www.w3.org/2000/svg">
12
-
<title>Dolly</title>
13
-
<defs
14
-
id="defs1" />
15
-
<sodipodi:namedview
16
-
id="namedview1"
17
-
pagecolor="#ffffff"
18
-
bordercolor="#000000"
19
-
borderopacity="0.25"
20
-
inkscape:showpageshadow="2"
21
-
inkscape:pageopacity="0.0"
22
-
inkscape:pagecheckerboard="true"
23
-
inkscape:deskcolor="#d1d1d1">
24
-
<inkscape:page
25
-
x="0"
26
-
y="0"
27
-
width="25"
28
-
height="25"
29
-
id="page2"
30
-
margin="0"
31
-
bleed="0" />
32
-
</sodipodi:namedview>
33
-
<g
34
-
inkscape:groupmode="layer"
35
-
inkscape:label="Image"
36
-
id="g1">
37
-
<path
38
-
class="dolly"
39
-
fill="currentColor"
40
-
style="stroke-width:1.12248"
41
-
d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z"
42
-
id="path1" />
43
-
</g>
44
-
</svg>
-5
appview/repo/index.go
-5
appview/repo/index.go
-500
appview/repo/ogcard/card.go
-500
appview/repo/ogcard/card.go
···
1
-
// Copyright 2024 The Forgejo Authors. All rights reserved.
2
-
// Copyright 2025 The Tangled Authors -- repurposed for Tangled use.
3
-
// SPDX-License-Identifier: MIT
4
-
5
-
package ogcard
6
-
7
-
import (
8
-
"bytes"
9
-
"fmt"
10
-
"image"
11
-
"image/color"
12
-
"io"
13
-
"log"
14
-
"math"
15
-
"net/http"
16
-
"strings"
17
-
"sync"
18
-
"time"
19
-
20
-
"github.com/goki/freetype"
21
-
"github.com/goki/freetype/truetype"
22
-
"github.com/srwiley/oksvg"
23
-
"github.com/srwiley/rasterx"
24
-
"golang.org/x/image/draw"
25
-
"golang.org/x/image/font"
26
-
"tangled.org/core/appview/pages"
27
-
28
-
_ "golang.org/x/image/webp" // for processing webp images
29
-
)
30
-
31
-
type Card struct {
32
-
Img *image.RGBA
33
-
Font *truetype.Font
34
-
Margin int
35
-
Width int
36
-
Height int
37
-
}
38
-
39
-
var fontCache = sync.OnceValues(func() (*truetype.Font, error) {
40
-
interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf")
41
-
if err != nil {
42
-
return nil, err
43
-
}
44
-
return truetype.Parse(interVar)
45
-
})
46
-
47
-
// DefaultSize returns the default size for a card
48
-
func DefaultSize() (int, int) {
49
-
return 1200, 630
50
-
}
51
-
52
-
// NewCard creates a new card with the given dimensions in pixels
53
-
func NewCard(width, height int) (*Card, error) {
54
-
img := image.NewRGBA(image.Rect(0, 0, width, height))
55
-
draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
56
-
57
-
font, err := fontCache()
58
-
if err != nil {
59
-
return nil, err
60
-
}
61
-
62
-
return &Card{
63
-
Img: img,
64
-
Font: font,
65
-
Margin: 0,
66
-
Width: width,
67
-
Height: height,
68
-
}, nil
69
-
}
70
-
71
-
// Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage
72
-
// size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer.
73
-
func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) {
74
-
bounds := c.Img.Bounds()
75
-
bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
76
-
if vertical {
77
-
mid := (bounds.Dx() * percentage / 100) + bounds.Min.X
78
-
subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA)
79
-
subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
80
-
return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()},
81
-
&Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()}
82
-
}
83
-
mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y
84
-
subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA)
85
-
subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA)
86
-
return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()},
87
-
&Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()}
88
-
}
89
-
90
-
// SetMargin sets the margins for the card
91
-
func (c *Card) SetMargin(margin int) {
92
-
c.Margin = margin
93
-
}
94
-
95
-
type (
96
-
VAlign int64
97
-
HAlign int64
98
-
)
99
-
100
-
const (
101
-
Top VAlign = iota
102
-
Middle
103
-
Bottom
104
-
)
105
-
106
-
const (
107
-
Left HAlign = iota
108
-
Center
109
-
Right
110
-
)
111
-
112
-
// DrawText draws text within the card, respecting margins and alignment
113
-
func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) {
114
-
ft := freetype.NewContext()
115
-
ft.SetDPI(72)
116
-
ft.SetFont(c.Font)
117
-
ft.SetFontSize(sizePt)
118
-
ft.SetClip(c.Img.Bounds())
119
-
ft.SetDst(c.Img)
120
-
ft.SetSrc(image.NewUniform(textColor))
121
-
122
-
face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
123
-
fontHeight := ft.PointToFixed(sizePt).Ceil()
124
-
125
-
bounds := c.Img.Bounds()
126
-
bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
127
-
boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y
128
-
// draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box
129
-
130
-
// Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move
131
-
// on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires
132
-
// knowing the total height, which is related to how many lines we'll have.
133
-
lines := make([]string, 0)
134
-
textWords := strings.Split(text, " ")
135
-
currentLine := ""
136
-
heightTotal := 0
137
-
138
-
for {
139
-
if len(textWords) == 0 {
140
-
// Ran out of words.
141
-
if currentLine != "" {
142
-
heightTotal += fontHeight
143
-
lines = append(lines, currentLine)
144
-
}
145
-
break
146
-
}
147
-
148
-
nextWord := textWords[0]
149
-
proposedLine := currentLine
150
-
if proposedLine != "" {
151
-
proposedLine += " "
152
-
}
153
-
proposedLine += nextWord
154
-
155
-
proposedLineWidth := font.MeasureString(face, proposedLine)
156
-
if proposedLineWidth.Ceil() > boxWidth {
157
-
// no, proposed line is too big; we'll use the last "currentLine"
158
-
heightTotal += fontHeight
159
-
if currentLine != "" {
160
-
lines = append(lines, currentLine)
161
-
currentLine = ""
162
-
// leave nextWord in textWords and keep going
163
-
} else {
164
-
// just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it
165
-
// regardless as a line by itself. It will be clipped by the drawing routine.
166
-
lines = append(lines, nextWord)
167
-
textWords = textWords[1:]
168
-
}
169
-
} else {
170
-
// yes, it will fit
171
-
currentLine = proposedLine
172
-
textWords = textWords[1:]
173
-
}
174
-
}
175
-
176
-
textY := 0
177
-
switch valign {
178
-
case Top:
179
-
textY = fontHeight
180
-
case Bottom:
181
-
textY = boxHeight - heightTotal + fontHeight
182
-
case Middle:
183
-
textY = ((boxHeight - heightTotal) / 2) + fontHeight
184
-
}
185
-
186
-
for _, line := range lines {
187
-
lineWidth := font.MeasureString(face, line)
188
-
189
-
textX := 0
190
-
switch halign {
191
-
case Left:
192
-
textX = 0
193
-
case Right:
194
-
textX = boxWidth - lineWidth.Ceil()
195
-
case Center:
196
-
textX = (boxWidth - lineWidth.Ceil()) / 2
197
-
}
198
-
199
-
pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY)
200
-
_, err := ft.DrawString(line, pt)
201
-
if err != nil {
202
-
return nil, err
203
-
}
204
-
205
-
textY += fontHeight
206
-
}
207
-
208
-
return lines, nil
209
-
}
210
-
211
-
// DrawTextAt draws text at a specific position with the given alignment
212
-
func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error {
213
-
_, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign)
214
-
return err
215
-
}
216
-
217
-
// DrawTextAtWithWidth draws text at a specific position and returns the text width
218
-
func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
219
-
ft := freetype.NewContext()
220
-
ft.SetDPI(72)
221
-
ft.SetFont(c.Font)
222
-
ft.SetFontSize(sizePt)
223
-
ft.SetClip(c.Img.Bounds())
224
-
ft.SetDst(c.Img)
225
-
ft.SetSrc(image.NewUniform(textColor))
226
-
227
-
face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72})
228
-
fontHeight := ft.PointToFixed(sizePt).Ceil()
229
-
lineWidth := font.MeasureString(face, text)
230
-
textWidth := lineWidth.Ceil()
231
-
232
-
// Adjust position based on alignment
233
-
adjustedX := x
234
-
adjustedY := y
235
-
236
-
switch halign {
237
-
case Left:
238
-
// x is already at the left position
239
-
case Right:
240
-
adjustedX = x - textWidth
241
-
case Center:
242
-
adjustedX = x - textWidth/2
243
-
}
244
-
245
-
switch valign {
246
-
case Top:
247
-
adjustedY = y + fontHeight
248
-
case Bottom:
249
-
adjustedY = y
250
-
case Middle:
251
-
adjustedY = y + fontHeight/2
252
-
}
253
-
254
-
pt := freetype.Pt(adjustedX, adjustedY)
255
-
_, err := ft.DrawString(text, pt)
256
-
return textWidth, err
257
-
}
258
-
259
-
// DrawBoldText draws bold text by rendering multiple times with slight offsets
260
-
func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) {
261
-
// Draw the text multiple times with slight offsets to create bold effect
262
-
offsets := []struct{ dx, dy int }{
263
-
{0, 0}, // original
264
-
{1, 0}, // right
265
-
{0, 1}, // down
266
-
{1, 1}, // diagonal
267
-
}
268
-
269
-
var width int
270
-
for _, offset := range offsets {
271
-
w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign)
272
-
if err != nil {
273
-
return 0, err
274
-
}
275
-
if width == 0 {
276
-
width = w
277
-
}
278
-
}
279
-
return width, nil
280
-
}
281
-
282
-
// DrawSVGIcon draws an SVG icon from the embedded files at the specified position
283
-
func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error {
284
-
svgData, err := pages.Files.ReadFile(svgPath)
285
-
if err != nil {
286
-
return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err)
287
-
}
288
-
289
-
// Convert color to hex string for SVG
290
-
rgba, isRGBA := iconColor.(color.RGBA)
291
-
if !isRGBA {
292
-
r, g, b, a := iconColor.RGBA()
293
-
rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)}
294
-
}
295
-
colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
296
-
297
-
// Replace currentColor with our desired color in the SVG
298
-
svgString := string(svgData)
299
-
svgString = strings.ReplaceAll(svgString, "currentColor", colorHex)
300
-
301
-
// Make the stroke thicker
302
-
svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`)
303
-
304
-
// Parse SVG
305
-
icon, err := oksvg.ReadIconStream(strings.NewReader(svgString))
306
-
if err != nil {
307
-
return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err)
308
-
}
309
-
310
-
// Set the icon size
311
-
w, h := float64(size), float64(size)
312
-
icon.SetTarget(0, 0, w, h)
313
-
314
-
// Create a temporary RGBA image for the icon
315
-
iconImg := image.NewRGBA(image.Rect(0, 0, size, size))
316
-
317
-
// Create scanner and rasterizer
318
-
scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds())
319
-
raster := rasterx.NewDasher(size, size, scanner)
320
-
321
-
// Draw the icon
322
-
icon.Draw(raster, 1.0)
323
-
324
-
// Draw the icon onto the card at the specified position
325
-
bounds := c.Img.Bounds()
326
-
destRect := image.Rect(x, y, x+size, y+size)
327
-
328
-
// Make sure we don't draw outside the card bounds
329
-
if destRect.Max.X > bounds.Max.X {
330
-
destRect.Max.X = bounds.Max.X
331
-
}
332
-
if destRect.Max.Y > bounds.Max.Y {
333
-
destRect.Max.Y = bounds.Max.Y
334
-
}
335
-
336
-
draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over)
337
-
338
-
return nil
339
-
}
340
-
341
-
// DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension
342
-
func (c *Card) DrawImage(img image.Image) {
343
-
bounds := c.Img.Bounds()
344
-
targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin)
345
-
srcBounds := img.Bounds()
346
-
srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy())
347
-
targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy())
348
-
349
-
var scale float64
350
-
if srcAspect > targetAspect {
351
-
// Image is wider than target, scale by width
352
-
scale = float64(targetRect.Dx()) / float64(srcBounds.Dx())
353
-
} else {
354
-
// Image is taller or equal, scale by height
355
-
scale = float64(targetRect.Dy()) / float64(srcBounds.Dy())
356
-
}
357
-
358
-
newWidth := int(math.Round(float64(srcBounds.Dx()) * scale))
359
-
newHeight := int(math.Round(float64(srcBounds.Dy()) * scale))
360
-
361
-
// Center the image within the target rectangle
362
-
offsetX := (targetRect.Dx() - newWidth) / 2
363
-
offsetY := (targetRect.Dy() - newHeight) / 2
364
-
365
-
scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight)
366
-
draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil)
367
-
}
368
-
369
-
func fallbackImage() image.Image {
370
-
// can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage
371
-
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
372
-
img.Set(0, 0, color.White)
373
-
return img
374
-
}
375
-
376
-
// As defensively as possible, attempt to load an image from a presumed external and untrusted URL
377
-
func (c *Card) fetchExternalImage(url string) (image.Image, bool) {
378
-
// Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want
379
-
// this rendering process to be slowed down
380
-
client := &http.Client{
381
-
Timeout: 1 * time.Second, // 1 second timeout
382
-
}
383
-
384
-
resp, err := client.Get(url)
385
-
if err != nil {
386
-
log.Printf("error when fetching external image from %s: %v", url, err)
387
-
return nil, false
388
-
}
389
-
defer resp.Body.Close()
390
-
391
-
if resp.StatusCode != http.StatusOK {
392
-
log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status)
393
-
return nil, false
394
-
}
395
-
396
-
contentType := resp.Header.Get("Content-Type")
397
-
// Support content types are in-sync with the allowed custom avatar file types
398
-
if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" {
399
-
log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType)
400
-
return nil, false
401
-
}
402
-
403
-
body := resp.Body
404
-
bodyBytes, err := io.ReadAll(body)
405
-
if err != nil {
406
-
log.Printf("error when fetching external image from %s: %v", url, err)
407
-
return nil, false
408
-
}
409
-
410
-
bodyBuffer := bytes.NewReader(bodyBytes)
411
-
_, imgType, err := image.DecodeConfig(bodyBuffer)
412
-
if err != nil {
413
-
log.Printf("error when decoding external image from %s: %v", url, err)
414
-
return nil, false
415
-
}
416
-
417
-
// Verify that we have a match between actual data understood in the image body and the reported Content-Type
418
-
if (contentType == "image/png" && imgType != "png") ||
419
-
(contentType == "image/jpeg" && imgType != "jpeg") ||
420
-
(contentType == "image/gif" && imgType != "gif") ||
421
-
(contentType == "image/webp" && imgType != "webp") {
422
-
log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType)
423
-
return nil, false
424
-
}
425
-
426
-
_, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode
427
-
if err != nil {
428
-
log.Printf("error w/ bodyBuffer.Seek")
429
-
return nil, false
430
-
}
431
-
img, _, err := image.Decode(bodyBuffer)
432
-
if err != nil {
433
-
log.Printf("error when decoding external image from %s: %v", url, err)
434
-
return nil, false
435
-
}
436
-
437
-
return img, true
438
-
}
439
-
440
-
func (c *Card) DrawExternalImage(url string) {
441
-
image, ok := c.fetchExternalImage(url)
442
-
if !ok {
443
-
image = fallbackImage()
444
-
}
445
-
c.DrawImage(image)
446
-
}
447
-
448
-
// DrawCircularExternalImage draws an external image as a circle at the specified position
449
-
func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error {
450
-
img, ok := c.fetchExternalImage(url)
451
-
if !ok {
452
-
img = fallbackImage()
453
-
}
454
-
455
-
// Create a circular mask
456
-
circle := image.NewRGBA(image.Rect(0, 0, size, size))
457
-
center := size / 2
458
-
radius := float64(size / 2)
459
-
460
-
// Scale the source image to fit the circle
461
-
srcBounds := img.Bounds()
462
-
scaledImg := image.NewRGBA(image.Rect(0, 0, size, size))
463
-
draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil)
464
-
465
-
// Draw the image with circular clipping
466
-
for cy := 0; cy < size; cy++ {
467
-
for cx := 0; cx < size; cx++ {
468
-
// Calculate distance from center
469
-
dx := float64(cx - center)
470
-
dy := float64(cy - center)
471
-
distance := math.Sqrt(dx*dx + dy*dy)
472
-
473
-
// Only draw pixels within the circle
474
-
if distance <= radius {
475
-
circle.Set(cx, cy, scaledImg.At(cx, cy))
476
-
}
477
-
}
478
-
}
479
-
480
-
// Draw the circle onto the card
481
-
bounds := c.Img.Bounds()
482
-
destRect := image.Rect(x, y, x+size, y+size)
483
-
484
-
// Make sure we don't draw outside the card bounds
485
-
if destRect.Max.X > bounds.Max.X {
486
-
destRect.Max.X = bounds.Max.X
487
-
}
488
-
if destRect.Max.Y > bounds.Max.Y {
489
-
destRect.Max.Y = bounds.Max.Y
490
-
}
491
-
492
-
draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over)
493
-
494
-
return nil
495
-
}
496
-
497
-
// DrawRect draws a rect with the given color
498
-
func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) {
499
-
draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src)
500
-
}
-1
appview/state/router.go
-1
appview/state/router.go
+15
-110
appview/state/state.go
+15
-110
appview/state/state.go
···
1
1
package state
2
2
3
3
import (
4
-
"bytes"
5
4
"context"
6
5
"database/sql"
7
6
"errors"
8
7
"fmt"
9
-
"image"
10
-
"image/color"
11
-
"image/draw"
12
-
"image/png"
13
8
"log/slog"
14
9
"net/http"
15
-
"strconv"
16
10
"strings"
17
11
"time"
18
12
···
26
20
dbnotify "tangled.org/core/appview/notify/db"
27
21
phnotify "tangled.org/core/appview/notify/posthog"
28
22
"tangled.org/core/appview/oauth"
29
-
"tangled.org/core/appview/ogcard"
30
23
"tangled.org/core/appview/pages"
31
24
"tangled.org/core/appview/reporesolver"
32
25
"tangled.org/core/appview/validator"
···
46
39
securejoin "github.com/cyphar/filepath-securejoin"
47
40
"github.com/go-chi/chi/v5"
48
41
"github.com/posthog/posthog-go"
49
-
"github.com/srwiley/rasterx"
50
42
)
51
43
52
44
type State struct {
···
229
221
230
222
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
231
223
const manifestJson = `{
232
-
"name": "tangled",
233
-
"description": "tightly-knit social coding.",
234
-
"start_url": "/",
235
-
"id": "org.tangled",
236
-
"display": "standalone",
237
-
"background_color": "#111827",
238
-
"theme_color": "#111827",
239
-
"icons": [
240
-
{
241
-
"src": "/pwa-icon.png?res=512&transparent=true",
242
-
"type": "image/png",
243
-
"sizes": "512x512",
244
-
"purpose": "any monchrome"
245
-
},
246
-
{
247
-
"src": "/pwa-icon.png?res=512",
248
-
"type": "image/png",
249
-
"sizes": "512x512",
250
-
"purpose": "maskable"
251
-
},
252
-
{
253
-
"src": "/pwa-icon.png?res=192",
254
-
"type": "image/png",
255
-
"sizes": "192x192",
256
-
"purpose": "maskable"
257
-
},
258
-
{
259
-
"src": "/pwa-icon.png?res=144",
260
-
"type": "image/png",
261
-
"sizes": "144x144",
262
-
"purpose": "maskable"
263
-
},
264
-
{
265
-
"src": "/pwa-icon.png?res=96",
266
-
"type": "image/png",
267
-
"sizes": "96x96",
268
-
"purpose": "maskable"
269
-
},
270
-
{
271
-
"src": "/pwa-icon.png?res=72",
272
-
"type": "image/png",
273
-
"sizes": "72x72",
274
-
"purpose": "maskable"
275
-
},
276
-
{
277
-
"src": "/pwa-icon.png?res=48",
278
-
"type": "image/png",
279
-
"sizes": "48x48",
280
-
"purpose": "maskable"
281
-
}
282
-
]
224
+
"name": "tangled",
225
+
"description": "tightly-knit social coding.",
226
+
"icons": [
227
+
{
228
+
"src": "/favicon.svg",
229
+
"sizes": "144x144"
230
+
}
231
+
],
232
+
"start_url": "/",
233
+
"id": "org.tangled",
234
+
235
+
"display": "standalone",
236
+
"background_color": "#111827",
237
+
"theme_color": "#111827"
283
238
}`
284
239
285
-
func (s *State) PWAManifest(w http.ResponseWriter, r *http.Request) {
240
+
func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) {
286
241
w.Header().Set("Content-Type", "application/json")
287
242
w.Write([]byte(manifestJson))
288
-
}
289
-
290
-
291
-
func (s *State) PWAIcon(w http.ResponseWriter, r *http.Request) {
292
-
tangledBgColour := color.RGBA{0x11, 0x18, 0x27, 0xff}
293
-
etag := "W/\"dolly-logo-v1 " + r.URL.Query().Get("res") + r.URL.Query().Get("transparent") + "\""
294
-
295
-
w.Header().Set("Content-Type", "image/png")
296
-
w.Header().Set("Cache-Control", "public, max-age=604800, stale-while-revalidate=86400, stale-if-error=86400")
297
-
w.Header().Set("Etag", etag)
298
-
// if the client already has the same logo dont bother recreating it
299
-
if len(r.Header["If-None-Match"]) == 1 && r.Header["If-None-Match"][0] == etag {
300
-
w.WriteHeader(304)
301
-
return
302
-
}
303
-
304
-
icon, err := ogcard.BuildSVGIconFromPath("templates/fragments/dolly/logo.svg", tangledBgColour)
305
-
// ignore error as icon is default icon on error
306
-
// also this should not fail
307
-
308
-
transparent := r.URL.Query().Get("transparent") == "true";
309
-
size, err := strconv.Atoi(r.URL.Query().Get("res"))
310
-
if err != nil {
311
-
size = 512
312
-
}
313
-
314
-
// maskable safe area is centered circle with radius 40%
315
-
// area outside may be cropped so make sure the icon fills it
316
-
// bc trig, safe square is just over 70% of screen
317
-
icon.SetTarget(float64(size) * 0.15, float64(size) * 0.15, float64(size) * 0.7, float64(size) * 0.7)
318
-
rgba := image.NewRGBA(image.Rect(0, 0, size, size))
319
-
320
-
// set image bg
321
-
var bgColour color.Color = tangledBgColour
322
-
if transparent {
323
-
bgColour = color.Transparent
324
-
}
325
-
draw.Draw(rgba, rgba.Bounds(), &image.Uniform{ bgColour }, image.Point{}, draw.Src)
326
-
327
-
// Create a scanner and rasterize the SVG
328
-
scanner := rasterx.NewScannerGV(size, size, rgba, rgba.Bounds())
329
-
raster := rasterx.NewDasher(size, size, scanner)
330
-
331
-
icon.Draw(raster, 1.0)
332
-
333
-
var img_buff bytes.Buffer
334
-
err = png.Encode(&img_buff, rgba)
335
-
// ignore error as encoding shouldnt fail
336
-
// if it fails itll return no bytes which is fine
337
-
w.Write(img_buff.Bytes())
338
243
}
339
244
340
245
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {