captcha.go (17526B)
1 package captcha 2 3 import ( 4 "bytes" 5 "encoding/base64" 6 "errors" 7 "fmt" 8 "image" 9 "image/color" 10 "image/draw" 11 "image/gif" 12 "math" 13 "os" 14 "sort" 15 "strings" 16 ) 17 18 const ( 19 b64Prefix = "R0lGODlhCAAOAIAAAAQCBPz-_CwAAAAACAAOAAAC" 20 alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 21 alphabet1 = "abdcefgh1ijkImnpoqrstyQuvwxzABCDEGJKMNHLORPFSTlUVWXYZ023456789" 22 ) 23 24 var ( 25 onColor = color.RGBA{R: 252, G: 254, B: 252, A: 255} 26 redColor = color.RGBA{R: 204, G: 2, B: 4, A: 255} 27 offColor = color.RGBA{R: 4, G: 2, B: 4, A: 255} 28 ) 29 30 var b64Map = map[string]string{ 31 "D_AxdTP8lWthViTno4yvAgA7": "z", 32 "EPAxdTXcDYyvhbokkC8znwoAOw==": "c", 33 "EfAxFG3ZU2AAntrky1Hf-cECADs=": "l", 34 "EfAxdTP8gUMvLCoVnfVW9pcCADs=": "s", 35 "EfAxdTU8jjNSxnfvqqBi9pkCADs=": "e", 36 "EvAxdTECnDNxPplue3hr9kGjAAA7": "r", 37 "E_ARIlK523poxXlqyvRKCcEQKAAAOw==": "i", 38 "E_ARInL53EMrKQmvoS3bGbtKrAoAOw==": "1", 39 "E_ARYiO103IyQkQvtFTiZ51QPAoAOw==": "J", 40 "E_AtAlV5HYiyIWrvm9FN6j1MFAsAOw==": "X", 41 "E_AtAlW5nzuN1rgmzDLpjsBQNAoAOw==": "H", 42 "E_AtAnNZGYDPoWnruTo39aNQRAoAOw==": "L", 43 "E_AtdAMUGVySzYdcw1fi7MFQLAAAOw==": "P", 44 "E_AtdAOznYrwSGQrfQ7gzDROHAsAOw==": "Z", 45 "E_AtdBUzHGhonvrclZDyHsFQLAAAOw==": "7", 46 "E_AtdBkzHFCRTQmdRfrwF8FQLAAAOw==": "F", 47 "E_AtdPUawUPSBRgtpCnPXq9QvAoAOw==": "T", 48 "E_AvVI07UILIvRkrvbK6laNQRAoAOw==": "3", 49 "E_AvdFHR3IstPDBTlZrSxa1QBAoAOw==": "I", 50 "E_AxL0IZWWxrwkrdNQ_wHpVQRAoAOw==": "t", 51 "E_AxdTEC3IPOUHqirLFx7DdGXAoAOw==": "n", 52 "E_AxdTEgHHyRSgvpvDrj7phQKQAAOw==": "m", 53 "E_AxdTGi3TsO0CqtvXrL6ZlwKQAAOw==": "v", 54 "E_AxdTGi3TtRGgodBHnz2JlwKQAAOw==": "u", 55 "E_AxdTGi3TvxOUBvjTlTbDlGXAoAOw==": "w", 56 "E_AxdTGinzNyugeTvYBKWplwKQAAOw==": "x", 57 "E_AxdTU8jjNSxocetKBWfpmQKQAAOw==": "o", 58 "E_AxdTXcDYyh0XflzA_cTplQKQAAOw==": "a", 59 "E_DxIhK24WLp0Ykqvorl5ycpgwoAOw==": "j", 60 "FPAtAlV5HYiyIWqvnWenpT9MHJECADs=": "Y", 61 "FPAtAlW5nzttOQoszJRL_pBQHI0CADs=": "W", 62 "FPAtAlW5nzttTWhluhrITJFQHJECADs=": "U", 63 "FPAtdAMUU4NvMkkRPjpCZyVPHI0CADs=": "E", 64 "FPAvdEFxAzJONhgrfXhra6FKHJECADs=": "S", 65 "FPAxNE7NXWMIREntmzdtvRZKHMUCADs=": "2", 66 "FfARInL3nmLROAYdsjtjKr8vGUmjAAA7": "A", 67 "FfARInL3nmLROAYdsnpeurlEHEmgAAA7": "0", 68 "FfARIxK2n3uTyVXNxXbTHC-pGcmjAAA7": "d", 69 "FfARYuPWnmoSThQrszRht6tEHEmjAAA7": "f", 70 "FfAtAlW5H3By0VTnpHrXwy9EHEmxAAA7": "V", 71 "FfAtAnNZGYDK0SZdg4dmvDUPjMiyAAA7": "h", 72 "FfAtdDNOAXysxSWl1TPbDK8OGcmyAAA7": "B", 73 "FfAvAk0ZHItzRahWwshZ2rLrGcmjAAA7": "k", 74 "FfAxNE7NXVtpyvgqvgAy7r8NGcmyAAA7": "O", 75 "FfAxNE7NXVuJxQlmpHhb9i5EHEmjAAA7": "9", 76 "FfAxVI170YvApXBWpRDf6rJrGsmjAAA7": "G", 77 "FfAxVI17EYDRpRDlpfrc6pYKG0mgAAA7": "C", 78 "FfAxdTEC3IPOUHqirLHlPSfp6hilAAA7": "p", 79 "FfAxdTGi3TtRGgodBHnz2CvrobilAAA7": "y", 80 "FfAxdTPcwTsPGlhjsHQverVJ-8KlAAA7": "g", 81 "FfAxdTUc3oNOGhrQzVhSG7GkOkakAAA7": "q", 82 "FfDxInJZHQTL0MnsvS6iLJsnGcmkAAA7": "4", 83 "FvAtAlXPHYhwpcbuW1HWWbtQQ8jSNAoAOw==": "K", 84 "FvAtAnNZGYDK0SZdg4dmbLEQjMjyKAAAOw==": "b", 85 "FvAtArWvHYAqOQrlwnlzWqMKGcnSKAAAOw==": "N", 86 "FvAtArWzYQRqOgprpQdz57cHGcnSKAAAOw==": "M", 87 "FvAtdAMUGVySzXqeaxq3nKYOGcnSKAAAOw==": "R", 88 "FvAtdAMUU4NvRfmgk4jxhjEtzMjSKAAAOw==": "5", 89 "FvAtdDNOAXxsymYjva7ynBcPGckSKAAAOw==": "D", 90 "FvAxNE7NXQMxsfUiZO3yuallQ8jSNAoAOw==": "6", 91 "FvAxNE7NXVtpyvgmrIvFywEGGckSKAAAOw==": "Q", 92 "FvAxNE7NXWMxgsUehjhXOzXuQsjSRAoAOw==": "8", 93 } 94 95 // SolveBase64 solve a base64 encoded gif 96 func SolveBase64(b64Str string) (string, error) { 97 b64Str = strings.TrimPrefix(b64Str, "data:image/gif;base64,") 98 b64, err := base64.StdEncoding.DecodeString(b64Str) 99 if err != nil { 100 return "", err 101 } 102 captchaImg, err := gif.Decode(bytes.NewReader(b64)) 103 if err != nil { 104 return "", err 105 } 106 if captchaImg.Bounds().Max.X > 60 { 107 return SolveDifficulty3(captchaImg) 108 } 109 return SolveDifficulty2(captchaImg) 110 } 111 112 // Solve a captcha difficulty 1 113 // Slice the captcha into 5, and directly compare the slices with our base64 hashmap 114 func Solve(img image.Image) (answer string, err error) { 115 gifImg, ok := img.(*image.Paletted) 116 if !ok { 117 return "", errors.New("invalid gif image") 118 } 119 letterSize := image.Point{X: 8, Y: 14} 120 cornerPt := image.Point{X: 5, Y: 7} 121 for i := 0; i < 5; i++ { 122 rect := image.Rectangle{Min: cornerPt, Max: cornerPt.Add(letterSize)} 123 letterImg := gifImg.SubImage(rect) 124 buf := bytes.Buffer{} 125 _ = gif.Encode(&buf, letterImg, nil) 126 letterB64 := base64.URLEncoding.EncodeToString(buf.Bytes()) 127 character, err := getCharByB64(letterB64) 128 if err != nil { 129 return "", fmt.Errorf("failed to get character %d", i+1) 130 } 131 answer += character 132 cornerPt.X += letterSize.X + 1 133 } 134 return 135 } 136 137 // SolveDifficulty2 solve all captcha up to difficulty 2 138 // For difficulty 2, we slice the captcha into 5 slices, and compare 139 // which of our base64 images has the best match for the slice. 140 func SolveDifficulty2(img image.Image) (answer string, err error) { 141 gifImg, ok := img.(*image.Paletted) 142 if !ok { 143 return "", errors.New("invalid gif image") 144 } 145 letterSize := image.Point{X: 8, Y: 14} 146 cornerPt := image.Point{X: 5, Y: 7} 147 for i := 0; i < 5; i++ { 148 rect := image.Rectangle{Min: cornerPt, Max: cornerPt.Add(letterSize)} 149 letterImg := gifImg.SubImage(rect).(*image.Paletted) 150 character := "" 151 alphabetLoop: 152 for _, c := range alphabet1 { 153 goodLetterImg, _ := getLetterImg(string(c)) 154 for y := 0; y < 14; y++ { 155 for x := 0; x < 8; x++ { 156 if goodLetterImg.At(x, y) == onColor && letterImg.At(cornerPt.X+x, cornerPt.Y+y) != onColor { 157 continue alphabetLoop 158 } 159 } 160 } 161 character = string(c) 162 break 163 } 164 // This should never happen 165 if character == "" { 166 return "", errors.New("failed to solve captcha") 167 } 168 answer += character 169 cornerPt.X += letterSize.X + 1 170 } 171 return 172 } 173 174 // Count pixels that are On (either white or red) 175 func countPxOn(img *image.Paletted) (countOn int) { 176 for y := 0; y < 14; y++ { 177 for x := 0; x < 8; x++ { 178 c := img.At(img.Bounds().Min.X+x, img.Bounds().Min.Y+y) 179 if c == onColor || c == redColor { 180 countOn += 1 181 } 182 } 183 } 184 return 185 } 186 187 // Count pixels that are red 188 func countRedPx(img *image.Paletted, offset image.Point) (countOn int) { 189 for y := 0; y < img.Rect.Bounds().Dy(); y++ { 190 for x := 0; x < img.Rect.Bounds().Dx(); x++ { 191 c := img.At(offset.X+x, offset.Y+y) 192 if c == redColor { 193 countOn += 1 194 } 195 } 196 } 197 return 198 } 199 200 type Letter struct { 201 Char string 202 Rect image.Rectangle 203 angles []float64 204 neighbors []Letter 205 } 206 207 func (l Letter) String() string { 208 return fmt.Sprintf(`["%s",X:%d,Y:%d]`, l.Char, l.Center().X, l.Center().Y) 209 } 210 211 func (l Letter) Center() image.Point { 212 return image.Point{X: l.Rect.Min.X + 4, Y: l.Rect.Min.Y + 6} 213 } 214 215 func (l Letter) Key() string { 216 return fmt.Sprintf("%s_%d_%d", l.Char, l.Rect.Min.X, l.Rect.Min.Y) 217 } 218 219 func hasRedInCenterArea(letterImg *image.Paletted) bool { 220 for y := 5; y <= 7; y++ { 221 for x := 3; x <= 5; x++ { 222 letterImgColor := letterImg.At(letterImg.Bounds().Min.X+x, letterImg.Bounds().Min.Y+y) 223 if letterImgColor == redColor { 224 return true 225 } 226 } 227 } 228 return false 229 } 230 231 // give an image and a valid letter image, return either or not the letter is in that image. 232 func imgContainsLetter(img, letterImg1 *image.Paletted) bool { 233 for y := 0; y < letterImg1.Bounds().Size().Y; y++ { 234 for x := 0; x < letterImg1.Bounds().Size().X; x++ { 235 goodLetterColor := letterImg1.At(x, y) 236 letterImgColor := img.At(img.Bounds().Min.X+x, img.Bounds().Min.Y+y) 237 // If we find an Off pixel where it's supposed to be On, skip that letter 238 if (goodLetterColor == onColor || goodLetterColor == redColor) && 239 (letterImgColor != onColor && letterImgColor != redColor) { 240 return false 241 } 242 } 243 } 244 return true 245 } 246 247 func getContourRedPixels(img *image.Paletted) (out []image.Point) { 248 topLeftPt := img.Bounds().Min 249 bottomRightPt := img.Bounds().Max 250 for i := 0; i < img.Bounds().Dx(); i++ { 251 pxColor := img.At(topLeftPt.X+i, topLeftPt.Y) 252 if pxColor == redColor { 253 out = append(out, image.Point{X: topLeftPt.X + i, Y: topLeftPt.Y}) 254 } 255 pxColor = img.At(topLeftPt.X+i, bottomRightPt.Y-1) 256 if pxColor == redColor { 257 out = append(out, image.Point{X: topLeftPt.X + i, Y: bottomRightPt.Y - 1}) 258 } 259 } 260 for i := 1; i < img.Bounds().Dy()-1; i++ { 261 pxColor := img.At(topLeftPt.X, topLeftPt.Y+i) 262 if pxColor == redColor { 263 out = append(out, image.Point{X: topLeftPt.X, Y: topLeftPt.Y + i}) 264 } 265 if img.Bounds().Dx() < img.Bounds().Dy() { 266 if img.Bounds().Max.X == 150 { 267 continue 268 } 269 } 270 pxColor = img.At(bottomRightPt.X-1, topLeftPt.Y+i) 271 if pxColor == redColor { 272 out = append(out, image.Point{X: bottomRightPt.X - 1, Y: topLeftPt.Y + i}) 273 } 274 } 275 return 276 } 277 278 func getAngle(p1, p2 image.Point) float64 { 279 return math.Atan2(float64(p1.Y-p2.Y), float64(p1.X-p2.X)) 280 } 281 282 func getLetterInDirection(letter *Letter, angle float64, lettersMap LettersCache) (out *Letter) { 283 minAngle := math.MaxFloat64 284 // Visit every other letters 285 for _, otherLetter := range lettersMap.toSlice() { 286 if otherLetter.Key() == letter.Key() { 287 continue 288 } 289 // Find the angle between the two letters 290 t := getAngle(otherLetter.Center(), letter.Center()) 291 if t < 0 { 292 t += 2 * math.Pi 293 } 294 if angle < 0 { 295 angle += 2 * math.Pi 296 } 297 angleDiff := math.Abs(angle - t) 298 if angleDiff < minAngle { 299 // Keep track of the letter with the smaller angle difference 300 minAngle = angleDiff 301 out = otherLetter 302 } 303 } 304 return 305 } 306 307 type LettersCache map[string]*Letter 308 309 func (c LettersCache) toSlice() (out []*Letter) { 310 for _, v := range c { 311 out = append(out, v) 312 } 313 sort.Slice(out, func(i, j int) bool { return out[i].String() < out[j].String() }) 314 return 315 } 316 317 // To find orientation of ordered triplet (p, q, r). 318 // The function returns following values 319 // 0 --> p, q and r are collinear 320 // 1 --> Clockwise 321 // 2 --> Counterclockwise 322 func orientation(p, q, r image.Point) int { 323 // See http://www.geeksforgeeks.org/orientation-3-ordered-points/ 324 // for details of below formula. 325 collinear := 0 326 clockwise := 1 327 counterclockwise := 2 328 329 val := (q.Y-p.Y)*(r.X-q.X) - (q.X-p.X)*(r.Y-q.Y) 330 fmt.Println("TEST", val) 331 if val == 0 { // collinear 332 return collinear 333 } 334 // clock or counterclockwise 335 if val > 0 { 336 return clockwise 337 } 338 return counterclockwise 339 } 340 341 // SolveDifficulty3 solve captcha for difficulty 3 342 // For each pixel, verify if a match is found. If we do have a match, 343 // verify that we have some "red" in it. 344 // TODO: figure out how to get the right order 345 // 346 // Red circle is 17x17 (initial point) 347 func SolveDifficulty3(img image.Image) (answer string, err error) { 348 gifImg, ok := img.(*image.Paletted) 349 if !ok { 350 return "", errors.New("invalid gif image") 351 } 352 353 imageWidth := 150 354 imageHeight := 200 355 letterSize := image.Point{X: 8, Y: 14} 356 minPxForLetter := 21 357 minStartingPtRedPx := 50 358 359 var starting *Letter // Hold the starting letter 360 lettersMap := make(LettersCache) // Build a hashmap to quickly access letters 361 362 // Step1: Find all letters with red on the center 363 for y := 0; y < imageHeight; y++ { 364 for x := 0; x < imageWidth; x++ { 365 topLeftPt := image.Point{X: x, Y: y} 366 rect := image.Rectangle{Min: topLeftPt, Max: topLeftPt.Add(letterSize)} 367 letterImg := gifImg.SubImage(rect).(*image.Paletted) 368 369 // We know that minimum amount of pixels on to form a letter is 21 370 // We can skip squares that do not have this prerequisite 371 if countPxOn(letterImg) < minPxForLetter { 372 continue 373 } 374 // Check middle pixels for red, if no red pixels, we can ignore that square 375 if !hasRedInCenterArea(letterImg) { 376 continue 377 } 378 379 for _, c := range alphabet1 { 380 goodLetterImg, _ := getLetterImg(string(c)) 381 if !imgContainsLetter(letterImg, goodLetterImg) { 382 continue 383 } 384 385 // "w" fits in "W". So if we find "W" 1 px bellow, discard "w" 386 if c == 'w' { 387 capitalWImg, _ := getLetterImg("W") 388 newPt := topLeftPt.Add(image.Point{X: 0, Y: 1}) 389 rect := image.Rectangle{Min: newPt, Max: newPt.Add(letterSize)} 390 onePxDownImg := gifImg.SubImage(rect).(*image.Paletted) 391 if imgContainsLetter(onePxDownImg, capitalWImg) { 392 continue 393 } 394 } else if c == 'k' { 395 capitalKImg, _ := getLetterImg("K") 396 newPt := topLeftPt.Add(image.Point{X: 1, Y: 1}) 397 rect := image.Rectangle{Min: newPt, Max: newPt.Add(letterSize)} 398 onePxUpImg := gifImg.SubImage(rect).(*image.Paletted) 399 if imgContainsLetter(onePxUpImg, capitalKImg) { 400 continue 401 } 402 } 403 404 letter := &Letter{Char: string(c), Rect: rect} 405 lettersMap[letter.Key()] = letter // Keep letters in hashmap for easy access 406 break 407 } 408 } 409 } 410 411 if len(lettersMap) != 5 { 412 return "", fmt.Errorf("did not find exactly 5 letters (%d)", len(lettersMap)) 413 } 414 415 // Step2: Find the starting letter 416 for _, letter := range lettersMap.toSlice() { 417 p1 := letter.Rect.Min.Add(image.Point{X: -5, Y: -3}) 418 p2 := letter.Rect.Max.Add(image.Point{X: 6, Y: 2}) 419 rect := image.Rectangle{Min: p1, Max: p2} 420 square := gifImg.SubImage(rect).(*image.Paletted) 421 422 // Find starting point 423 redCount := countRedPx(square, p1) 424 if redCount > minStartingPtRedPx { 425 starting = letter 426 break 427 } 428 } 429 430 if starting == nil { 431 return "", errors.New("could not find starting letter") 432 } 433 434 code := "" 435 letter := starting 436 visited := make(map[string]bool) 437 for i := 0; i < 5; i++ { 438 if _, found := visited[letter.Key()]; found { 439 return "", errors.New("already visited node") 440 } 441 code += letter.Char 442 visited[letter.Key()] = true 443 if i == 4 { 444 break 445 } 446 447 p1 := letter.Rect.Min.Add(image.Point{X: -5, Y: -3}) 448 p2 := letter.Rect.Max.Add(image.Point{X: 6, Y: 2}) 449 rect := image.Rectangle{Min: p1, Max: p2} 450 451 retry := 0 452 Loop: 453 for { 454 retry++ 455 square := gifImg.SubImage(rect).(*image.Paletted) 456 //SaveGif("char_"+letter.Key()+".gif", square) 457 redPxPts := getContourRedPixels(square) 458 459 if i == 0 { 460 if len(redPxPts) == 0 { 461 if retry < 10 { 462 rect.Min = rect.Min.Add(image.Point{X: -1, Y: -1}) 463 rect.Max = rect.Max.Add(image.Point{X: 1, Y: 1}) 464 continue 465 } 466 return "", fmt.Errorf("root %s has no line detected", letter) 467 } 468 if len(redPxPts) > 1 { 469 return "", fmt.Errorf("root %s has more than one line detected", letter) 470 } 471 redPt := redPxPts[0] 472 angle := getAngle(redPt, letter.Center()) 473 neighbor := getLetterInDirection(letter, angle, lettersMap) 474 letter = neighbor 475 break 476 } 477 478 if len(redPxPts) == 0 { 479 if retry < 10 { 480 rect.Min = rect.Min.Add(image.Point{X: -1, Y: -1}) 481 rect.Max = rect.Max.Add(image.Point{X: 1, Y: 1}) 482 continue 483 } 484 return "", fmt.Errorf("letter #%d %s has no line detected", i+1, letter) 485 } 486 if len(redPxPts) == 1 { 487 if retry < 10 { 488 rect.Min = rect.Min.Add(image.Point{X: -1, Y: -1}) 489 rect.Max = rect.Max.Add(image.Point{X: 1, Y: 1}) 490 continue 491 } 492 return "", fmt.Errorf("letter #%d %s has only 1 line detected", i+1, letter) 493 } 494 if len(redPxPts) > 2 { 495 return "", fmt.Errorf("letter #%d %s has more than 2 lines detected", i+1, letter) 496 } 497 fstRedPt := redPxPts[0] 498 angle := getAngle(fstRedPt, letter.Center()) 499 neighbor := getLetterInDirection(letter, angle, lettersMap) 500 if _, found := visited[neighbor.Key()]; found { 501 fstRedPt := redPxPts[1] 502 angle := getAngle(fstRedPt, letter.Center()) 503 neighbor = getLetterInDirection(letter, angle, lettersMap) 504 if _, found := visited[neighbor.Key()]; found { 505 if i == 3 { 506 for _, l := range lettersMap.toSlice() { 507 if _, found := visited[l.Key()]; !found { 508 letter = l 509 break Loop 510 } 511 } 512 } 513 return "", fmt.Errorf("letter #%d %s all neighbors already visited", i+1, letter) 514 } 515 } 516 letter = neighbor 517 break 518 } 519 } 520 521 answer = code 522 return 523 } 524 525 // Given a base64 string, return the letter that match the gif 526 func getCharByB64(b64 string) (string, error) { 527 b64 = strings.TrimPrefix(b64, b64Prefix) 528 if v, found := b64Map[b64]; found { 529 return v, nil 530 } 531 return "", errors.New("character not found") 532 } 533 534 // Given a letter (eg: "a") return the gif image for that letter 535 func getLetterImg(letter string) (*image.Paletted, error) { 536 for k, v := range b64Map { 537 if v == letter { 538 goodLetter, _ := base64.URLEncoding.DecodeString(b64Prefix + k) 539 img, _ := gif.Decode(bytes.NewReader(goodLetter)) 540 if palettedImg, ok := img.(*image.Paletted); ok { 541 return palettedImg, nil 542 } 543 break 544 } 545 } 546 return nil, errors.New("letter not found") 547 } 548 549 // SaveGif save an image on disk 550 func SaveGif(filename string, img image.Image) { 551 f, _ := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, 0644) 552 _ = gif.Encode(f, img, nil) 553 _ = f.Close() 554 } 555 556 // GetAlphabetImg generate an image of all the characters 557 func GetAlphabetImg() { 558 rect := image.Rectangle{Min: image.Point{X: 0, Y: 0}, Max: image.Point{X: 5 + (62 * 9) + 5, Y: 8 + 14 + 8}} 559 newImg := image.NewPaletted(rect, color.Palette{onColor, offColor}) 560 draw.Draw(newImg, newImg.Bounds(), &image.Uniform{C: color.Black}, image.Point{}, draw.Src) 561 letterSize := image.Point{X: 8, Y: 14} 562 cornerPt := image.Point{X: 5, Y: 8} 563 for _, c := range alphabet { 564 b64 := "" 565 for k, v := range b64Map { 566 if v == string(c) { 567 b64 = k 568 break 569 } 570 } 571 letterB64, _ := base64.URLEncoding.DecodeString(b64Prefix + b64) 572 letterImg, _ := gif.Decode(bytes.NewReader(letterB64)) 573 r := image.Rectangle{Min: cornerPt, Max: cornerPt.Add(letterSize)} 574 draw.Draw(newImg, r, letterImg, image.Point{X: 0, Y: 0}, draw.Src) 575 cornerPt.X += letterSize.X + 1 576 } 577 SaveGif("alphabet.gif", newImg) 578 }