{@@ ----------------------------------------------------------------------------
  This unit implements a CAPTCHA component for Lazarus.

  AUTHOR:  Werner Pamler

  LICENSE: LGPL with linking exception (like Lazarus LCL)
           See the file COPYING.modifiedLGPL.txt, included in the Lazarus
           distribution, for details about the license.
-------------------------------------------------------------------------------}

unit CaptchaCtrl;

{$mode OBJFPC}{$H+}
{$WARN 6058 off : Call to subroutine "$1" marked as inline is not inlined}
interface

uses
  Classes, SysUtils, Graphics, Controls;

type
  TCaptchaChar = record
    Character: String;    // Character (must be a string for UTF8)
    Angle: Integer;       // Rotation angle of character, in degrees
    Position: TPoint;     // Position of character within buffer bitmap (for TextOut)
    FontIndex: Integer;   // Index of font to be used
    Color: TColor;        // Random color of the character
  end;
  TCaptchaCharArray = array of TCaptchaChar;

  TCaptchaLine = record
    StartPt: TPoint;      // Random start point of the line
    EndPt: TPoint;        // Random end point of the line
    Color: TColor;        // Random line color
  end;
  TCaptchaLineArray = array of TCaptchaLine;

  TCaptchaOption = (
    coAlphaUpper, coAlphaLower, coNumeric, coCustom,
    coRotated, coFont1, coFont2, coLines
    );
  TCaptchaOptions = set of TCaptchaOption;
  TCaptchaCharsOption = coAlphaUpper..coCustom;

  TNewCaptchaEvent = (nceNone, nceClick, nceDblClick);

const
  DEFAULT_CAPTCHA_OPTIONS = [
    coAlphaUpper, coAlphaLower, coNumeric, coCustom,
    coRotated, coFont1, coFont2, coLines
  ];
  DEFAULT_CAPTCHA_NUMCHARS = 10;
  DEFAULT_CAPTCHA_NUMLINES = 30;

type
  TCaptchaLabel = class(TGraphicControl)
  private
    FBuffer: TBitmap;
    FCaptchaChars: TCaptchaCharArray;
    FCaptchaLines: TCaptchaLineArray;
    FValidChars: array[TCaptchaCharsOption] of string;
    FFonts: array[0..1] of TFont;
    FInitialized: Boolean;
    FMaxAngle: Integer;
    FNewCaptchaEvent: TNewCaptchaEvent;
    FNumChars: Integer;
    FNumLines: Integer;
    FOptions: TCaptchaOptions;
    function GetCaptchaText: String;
    function GetFont(AIndex: Integer): TFont;
    function GetValidChars(AIndex: Integer): String;
    procedure SetFont(AIndex: Integer; const AValue: TFont);
    procedure SetMaxAngle(const AValue: Integer);
    procedure SetNumChars(const AValue: Integer);
    procedure SetNumLines(const AValue: Integer);
    procedure SetOptions(const AValue: TCaptchaOptions);
    procedure SetValidChars(AIndex: Integer; const AValue: String);
  protected
    function AlmostBackgroundColor(AColor: TColor): Boolean;
    procedure CreateNewCaptcha(ANumChars, ANumLines: Integer; KeepText,KeepLines: Boolean);
    procedure DrawBuffer;
    procedure InitAngles;
    procedure InitCharPos(KeepVertPos: boolean);
    procedure InitFontIndex;
    procedure InitLineColors;
    procedure InitLines(ACount: Integer; KeepExisting: Boolean);
    procedure InitText(ACount: Integer; KeepExisting: Boolean);
    procedure InitTextColors;
  protected
    procedure CalculatePreferredSize(var PreferredWidth, PreferredHeight: integer;
      WithThemeSpace: Boolean); override;
    procedure Click; override;
    procedure DblClick; override;
    procedure Paint; override;
    procedure Resize; override;
    procedure SetColor(AValue: TColor); override;
  public
    constructor Create(AOwner: TComponent); override;
    destructor Destroy; override;
    procedure NewCaptcha;
    function Verify(const AText: String): Boolean;
    property Text: String read GetCaptchaText;
  published
    property CustomChars: String index ord(coCustom) read GetValidChars write SetValidChars;
    property Font1: TFont index 0 read GetFont write SetFont;
    property Font2: TFont index 1 read GetFont write SetFont;
    property Options: TCaptchaOptions read FOptions write SetOptions default DEFAULT_CAPTCHA_OPTIONS;
    property LowercaseChars: String index ord(coAlphaLower) read GetValidChars write SetValidChars;
    property MaxAngle: Integer read FMaxAngle write SetMaxAngle default 60;
    property NumericChars: String index ord(coNumeric) read GetValidChars write SetValidChars;
    property NewCaptchaEvent: TNewCaptchaEvent read FNewCaptchaEvent write FNewCaptchaEvent default nceNone;
    property NumChars: Integer read FNumChars write SetNumChars default DEFAULT_CAPTCHA_NUMCHARS;
    property NumLines: Integer read FNumLines write SetNumLines default DEFAULT_CAPTCHA_NUMLINES;
    property UppercaseChars: String index ord(coAlphaUpper) read GetValidChars write SetValidChars;

    property Align;
    property AutoSize default true;
    property BorderSpacing;
    property Color default clBlack;
    property OnClick;
    property OnDblClick;
    property OnMouseDown;
    property OnMouseEnter;
    property OnMouseLeave;
    property OnMouseUp;
  end;

procedure Register;

implementation

{$R captcha_images.res}

uses
  LCLIntf, Types, GraphUtil, Math, LazUTF8;

{ Component registration }

procedure Register;
begin
  RegisterComponents('Misc', [TCaptchaLabel]);
end;


{ Utility functions }

function RotatePoint(const APoint: TPoint; Angle: Double): TPoint;
var
  sinphi, cosphi: Double;
begin
  Angle := DegToRad(Angle);
  SinCos(angle, sinphi, cosphi);
  Result.X := Round( cosphi * APoint.X + sinphi * APoint.Y);
  Result.Y := Round(-sinphi * APoint.X + cosphi * APoint.Y);
end;

function RotateRect(const Width, Height: Integer; Angle: Double): TRect;
var
  P0, P1, P2, P3: TPoint;
begin
  P0 := Point(0, 0);
  P1 := RotatePoint(Point(0, Height), Angle);
  P2 := RotatePoint(Point(Width, 0), Angle);
  P3 := RotatePoint(Point(Width, Height), Angle);
  Result.Left := MinValue([P0.X, P1.X, P2.X, P3.X]);
  Result.Top := MinValue([P0.Y, P1.Y, P2.Y, P3.Y]);
  Result.Right := MaxValue([P0.X, P1.X, P2.X, P3.X]);
  Result.Bottom := MaxValue([P0.Y, P1.Y, P2.Y, P3.Y]);
end;


{ TCaptchaLabel }

constructor TCaptchaLabel.Create(AOwner: TComponent);
begin
  inherited;
  with GetControlClassDefaultSize do
    SetInitialBounds(0, 0, 300, 100);
  AutoSize := true;
  Color := clBlack;

  FBuffer := TBitmap.Create;
  FBuffer.PixelFormat := pf32bit;

  FFonts[0] := TFont.Create;
  FFonts[0].Size := 36;

  FFonts[1] := TFont.Create;
  {$IF DEFINED(MSWindows)}
  FFonts[1].Name := 'Courier New';
  {$ELSEIF DEFINED(Linux)}
  FFonts[1].Name := 'FreeMono';
  {$ELSEIF DEFINED(Darwin)}
  Fronts[1].Name := 'Courier';
  {$IFEND}
  FFonts[1].Size := 36;

  FOptions := DEFAULT_CAPTCHA_OPTIONS;
  FMaxAngle := 60;
  FNumChars := DEFAULT_CAPTCHA_NUMCHARS;
  FNumLines := DEFAULT_CAPTCHA_NUMLINES;
  FValidChars[coAlphaUpper] := 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
  FValidChars[coAlphaLower] := 'abcdefghijklmnopqrstuvwxyz';
  FValidChars[coNumeric] := '0123456789';
  FValidChars[coCustom] := '';
  FInitialized := false;

  // Do not call Randomize at runtime to facilitate debugging.
  if (csDesigning in ComponentState) then
    Randomize;
end;

destructor TCaptchaLabel.Destroy;
begin
  Finalize(FCaptchaChars);
  Finalize(FCaptchaLines);
  FreeAndNil(FFonts[0]);
  FreeAndNil(FFonts[1]);
  FreeAndNil(FBuffer);
  inherited;
end;

function TCaptchaLabel.AlmostBackgroundColor(AColor: TColor): Boolean;
const
  TOLERANCE = 64;
var
  colorH, colorL, colorS: Byte;
  bgColorH, bgColorL, bgColorS: Byte;
begin
  ColorToHLS(ColorToRGB(AColor), colorH, colorL, colorS);
  ColorToHLS(ColorToRGB(Self.Color), bgColorH, bgColorL, bgColorS);
  Result := abs(colorL - bgColorL) < TOLERANCE;
end;

procedure TCaptchaLabel.CalculatePreferredSize(
  var PreferredWidth, PreferredHeight: integer;
  WithThemeSpace: Boolean);
begin
  inherited;

  CreateNewCaptcha(FNumChars, FNumLines, true, true);

  PreferredWidth := FBuffer.Width;
  PreferredHeight := 0;
  if (coFont1 in FOptions) then
  begin
    FBuffer.Canvas.Font.Assign(FFonts[0]);
    PreferredHeight := FBuffer.Canvas.TextHeight('Tg');
  end;
  if (coFont2 in FOptions) then
  begin
    FBuffer.Canvas.Font.Assign(FFonts[1]);
    PreferredHeight := Max(PreferredHeight, FBuffer.Canvas.TextHeight('Tg'));
  end;
  PreferredHeight := 3*PreferredHeight div 2;
end;

procedure TCaptchaLabel.Click;
begin
  inherited;
  if FNewCaptchaEvent = nceClick then
    NewCaptcha;
end;

procedure TCaptchaLabel.CreateNewCaptcha(ANumChars, ANumLines: Integer;
  KeepText, KeepLines: Boolean);
begin
  if not KeepText then
    FCaptchaChars := nil;
  FCaptchaLines := nil;
  InitText(ANumChars, KeepText);
  InitTextColors;                   // after InitText
  InitAngles;
  InitCharPos(false);
  InitLines(ANumLines, KeepLines);  // after InitCharPos
  InitLineColors;                   // after InitLines
  DrawBuffer;
end;

procedure TCaptchaLabel.DblClick;
begin
  inherited;
  if FNewCaptchaEvent = nceDblClick then
    NewCaptcha;
end;

procedure TCaptchaLabel.DrawBuffer;
var
  i: Integer;
begin
  if not Assigned(FBuffer) then
    exit;

  // Fill the buffer background in the requested color.
  FBuffer.Canvas.Brush.Color := Self.Color;
  FBuffer.Canvas.Brush.Style := bsSolid;
  FBuffer.Canvas.FillRect(0, 0, FBuffer.Width, FBuffer.Height);

  // Draw the captcha characters to the buffer bitmap
  if (FOptions * [coAlphaUpper, coAlphaLower, coNumeric, coCustom] <> []) and
     (FOptions * [coFont1, coFont2] <> []) then
  begin
    FBuffer.Canvas.Brush.Style := bsClear;
    for i := 0 to High(FCaptchaChars) do
      with FCaptchaChars[i] do
      begin
        FBuffer.Canvas.Font.Assign(FFonts[FontIndex]);
        FBuffer.Canvas.Font.Color := Color;
        if coRotated in FOptions then
          FBuffer.Canvas.Font.Orientation := Angle * 10
        else
          FBuffer.Canvas.Font.Orientation := 0;
        FBuffer.Canvas.TextOut(Position.X, Position.Y, Character);
      end;
  end;

  // Draw the captcha lines
  if coLines in FOptions then
  begin
    for i := 0 to High(FCaptchaLines) do
      with FCaptchaLines[i] do
      begin
        FBuffer.Canvas.Pen.Color := Color;
        FBuffer.Canvas.Line(StartPt.X, StartPt.Y, EndPt.X, EndPt.Y);
      end;
  end;
end;

function TCaptchaLabel.GetFont(AIndex: Integer): TFont;
begin
  Result := FFonts[AIndex];
end;

function TCaptchaLabel.GetCaptchaText: string;
var
  i: Integer;
begin
  Result := '';
  for i := 0 to High(FCaptchaChars) do
    Result := Result + FCaptchaChars[i].Character;
end;

function TCaptchaLabel.GetValidChars(AIndex: Integer): String;
begin
  Result := FValidChars[TCaptchaCharsOption(AIndex)];
end;

procedure TCaptchaLabel.InitAngles;
var
  i: Integer;
begin
  for i := 0 to High(FCaptchaChars) do
    FCaptchaChars[i].Angle := Random(FMaxAngle*2) - FMaxAngle;
end;

{ Calculates the character positions and stores them in the ChaptchaChars array
  When KeepVertPos is false, the vertical position of the characters is selected
  randomly within the height of the control. Otherwise the already stored
  vertical positions are used. }
procedure TCaptchaLabel.InitCharPos(KeepVertPos: Boolean);
var
  x: Integer;
  i: Integer;
  R: TRect;
  ext: TSize;
  w, h: Integer;
  fnt: TFont;
  maxHeight: Integer;
begin
  maxHeight := 0;
  x := 0;
  for i := 0 to High(FCaptchaChars) do
  begin
    // Set character font
    fnt := FFonts[FCaptchaChars[i].FontIndex];
    FBuffer.Canvas.Font.Assign(fnt);

    // Get character size
    ext := FBuffer.Canvas.TextExtent(FCaptchaChars[i].Character);

    // Rotate the character and get the bounds of the enclosing rectangle.
    // The rotation occurs around the upper left corner of the character.
    if coRotated in FOptions then
      R := RotateRect(ext.CX, ext.CY, FCaptchaChars[i].Angle)
    else
      // unrotated: add some extra space for better legibility
      R := Rect(0, 0, ext.CX * 6 div 5, ext.CY);
    w := R.Right - R.Left;
    h := R.Bottom - R.Top;

    // Horizontal drawing coordinate
    FCaptchaChars[i].Position.X := x - R.Left;

    // Vertical drawing coordinate: randomly inside control
    if not KeepVertPos then
    begin
      if Self.Height > h then
        FCaptchaChars[i].Position.Y := Max(0, Random(Height - h) - R.Top)
      else
        FCaptchaChars[i].Position.Y := 0;
    end;

    // Find max y coordinate needed to enclose the entire text
    maxHeight := Max(maxHeight, FCaptchaChars[i].Position.Y + h);
    // Next drawing position
    x := x + w;
  end;

  // Set size of the bitmap buffer so that the entire captcha is enclosed.
  FBuffer.SetSize(x, maxHeight);
end;

procedure TCaptchaLabel.InitFontIndex;
var
  i: Integer;
begin
  if FOptions * [coFont1, coFont2] = [coFont1] then
    for i := 0 to High(FCaptchaChars) do
      FCaptchaChars[i].FontIndex := 0
  else
  if FOptions * [coFont1, coFont2] = [coFont2] then
    for i := 0 to High(FCaptchaChars) do
      FCaptchaChars[i].FontIndex := 1
  else
    for i := 0 to High(FCaptchaChars) do
      FCaptchaChars[i].FontIndex := Random(2);
end;

{ Pick random color for a line.
  Make sure that the color is not too close to the background color. }
procedure TCaptchaLabel.InitLineColors;
var
  i: Integer;
begin
  // Line colors
  if (FOptions * [coLines] <> []) then
    for i := 0 to High(FCaptchaLines) do
      repeat
        FCaptchaLines[i].Color := TColor(Random($FFFFFF));
      until not AlmostBackgroundColor(FCaptchaLines[i].Color);
end;

procedure TCaptchaLabel.InitLines(ACount: Integer; KeepExisting: Boolean);
var
  i, n: Integer;
begin
  if coLines in FOptions then
  begin
    if KeepExisting then
      n := Length(FCaptchaLines)
    else
      n := 0;
    SetLength(FCaptchaLines, ACount);
    for i := n to High(FCaptchaLines) do
    begin
      // Select random start and end points
      FCaptchaLines[i].StartPt := Point(
        Random(FBuffer.Width),
        Random(FBuffer.Height)
      );
      FCaptchaLines[i].EndPt := Point(
        Random(FBuffer.Width),
        Random(FBuffer.Height)
      );

      // Select random line color
      repeat
        FCaptchaLines[i].Color := TColor(Random($FFFFFF));
      until not AlmostBackgroundColor(FCaptchaLines[i].Color);
    end;
  end;
end;

procedure TCaptchaLabel.InitText(ACount: Integer; KeepExisting: Boolean);
var
  i, n: Integer;
  validChars: String;
  co: TCaptchaCharsOption;
begin
  if (FOptions * [coAlphaUpper, coAlphaLower, coNumeric, coCustom] <> []) and
     (FOptions * [coFont1, coFont2] <> []) then
  begin
    // Prepare character list for captcha
    validChars := '';
    for co in TCaptchaCharsOption do
      if co in FOptions then
        validChars := validChars + FValidChars[co];;
    // Remove characters which are hard to distinguish
    if FOptions * [coAlphaUpper, coAlphaLower] = [coAlphaUpper, coAlphaLower] then
    begin
      i := Pos('I', validChars);  // Remove upper-case I
      if i > 0 then Delete(validChars, i, 1);
      i := Pos('l', validChars);  // Remove lower-case L
      if i > 0 then Delete(validChars, i, 1);
    end;
    if FOptions * [coAlphaUpper, coNumeric] = [coAlphaUpper, coNumeric] then
    begin
      i := Pos('O', validChars);   // Remove upper-case O
      if i > 0 then Delete(validChars, i, 1);
      i := Pos('0', validChars);   // Remove number zero
      if i > 0 then Delete(validChars, i, 1);
    end;

    if KeepExisting then
      n := Length(FCaptchaChars)
    else
      n := 0;
    // Get random captcha characters, but keep previously assigned chars.
    SetLength(FCaptchaChars, ACount);
    for i := n to High(FCaptchaChars) do
    begin
      // Pick random character from the validChars. Take care of UTF8.
      FCaptchaChars[i].Character := UTF8Copy(validChars, random(UTF8Length(validChars)) + 1, 1);

      // Pick one of the fonts
      if FOptions * [coFont1, coFont2] = [coFont1] then
        FCaptchaChars[i].FontIndex := 1
      else
      if FOptions * [coFont1, coFont2] = [coFont2] then
        FCaptchaChars[i].FontIndex := 2
      else
        FCaptchaChars[i].FontIndex := Random(2);

      if KeepExisting then
      begin
        // Set random text color
        repeat
          FCaptchaChars[i].Color := TColor(Random($FFFFFF));
        until not AlmostbackgroundColor(FCaptchaChars[i].Color);

        // Set random rotation angle
        if (coRotated in FOptions) then
          FCaptchaChars[i].Angle := Random(FMaxAngle*2) - FMaxAngle;
      end;
    end;
  end else
    SetLength(FCaptchaChars, 0);
end;

{ Pick random color for a character.
  Make sure that the color is not too close to the background color. }
procedure TCaptchaLabel.InitTextColors;
var
  i: Integer;
begin
  // Character colors
  if (FOptions * [coAlphaUpper, coAlphaLower, coNumeric, coCustom] <> []) then
    for i := 0 to High(FCaptchaChars) do
      repeat
        FCaptchaChars[i].Color := TColor(Random($FFFFFF));
      until not AlmostbackgroundColor(FCaptchaChars[i].Color);
end;

procedure TCaptchaLabel.NewCaptcha;
begin
  CreateNewCaptcha(FNumChars, FNumLines, false, false);
  Invalidate;
end;

procedure TCaptchaLabel.Paint;
begin
  Canvas.Draw((Width - FBuffer.Width) div 2, (Height - FBuffer.Height) div 2, FBuffer);
end;

procedure TCaptchaLabel.Resize;
begin
  inherited;
  if Assigned(FBuffer) and not FInitialized then
  begin
    CreateNewCaptcha(FNumChars, FNumLines, false, false);
    FInitialized := true;
  end;
end;

procedure TCaptchaLabel.SetColor(AValue: TColor);
begin
  if AValue = Color then
    exit;
  inherited SetColor(AValue);
  InitTextColors;
  InitLineColors;
  DrawBuffer;
  Invalidate;
end;

procedure TCaptchaLabel.SetFont(AIndex: Integer; const AValue: TFont);
begin
  if FFonts[AIndex].IsEqual(AValue) then
    exit;
  FFonts[AIndex].Assign(AValue);
  InitFontIndex;
  InitCharPos(true);
  DrawBuffer;
  Invalidate;
end;

procedure TCaptchaLabel.SetMaxAngle(const AValue: Integer);
begin
  if AValue = FMaxAngle then
    exit;
  FMaxAngle := AValue;
  InitAngles;
  InitCharPos(true);
  DrawBuffer;
  Invalidate;
end;

procedure TCaptchaLabel.SetNumChars(const AValue: Integer);
begin
  if AValue = FNumChars then
    exit;
  FNumChars := AValue;
  InitText(FNumChars, true);
  InitAngles;
  InitCharPos(false);
  InitLines(FNumLines, false);
  DrawBuffer;
  Invalidate;
end;

procedure TCaptchaLabel.SetNumLines(const AValue: Integer);
begin
  if AValue = FNumLines then
    exit;
  FNumLines := AValue;
  InitLines(FNumLines, true);
  DrawBuffer;
  Invalidate;
end;

procedure TCaptchaLabel.SetOptions(const AValue: TCaptchaOptions);
var
  oldOptions: TCaptchaOptions;
begin
  if AValue = FOptions then
    exit;
  oldOptions := FOptions;
  FOptions := AValue;
  if (oldOptions * [coAlphaUpper, coAlphaLower, coNumeric, coCustom] <>
          AValue * [coAlphaUpper, coAlphaLower, coNumeric, coCustom])
  then
    InitText(FNumChars, false);
  if (oldOptions * [coFont1, coFont2] <> AValue * [coFont1, coFont2]) then
  begin
    InitFontIndex;
    InitCharPos(false);
  end;
  if (oldOptions * [coRotated] <> AValue * [coRotated]) then
  begin
    InitAngles;
    InitCharPos(true);
  end;
  if oldOptions * [coLines] <> AValue * [coLines] then
    InitLines(FNumLines, true);
  DrawBuffer;
  Invalidate;
end;

procedure TCaptchaLabel.SetValidChars(AIndex: Integer; const AValue: String);
begin
  if FValidChars[TCaptchaCharsOption(AIndex)] = AValue then
    exit;
  FValidChars[TCaptchaCharsOption(AIndex)] := AValue;
  NewCaptcha;
end;


function TCaptchaLabel.Verify(const AText: String): Boolean;
begin
  Result := (AText = GetCaptchaText);
end;

end.