24 February 2010

Call JavaScript in a TWebBrowser and get a result back

Calling a JavaScript function in a TWebBrowser is easy, but getting a return value from it is hard. I've been struggling for ages to find an answer to this, and lots of users have asked. My article "How to call JavaScript functions in a TWebBrowser from Delphi" goes into details.

Christian Sciberras suggested a solution that depended on modifying the HTML source to include a hidden input field and modifying the function stores its result in the field. I've wanted a tidier solution that didn't involve changing either the HTML code or the JavaScript function, because we can't always do that.

I've now got a solution based on Christian's:

  1. Create a hidden input field in the current document with a unique id.
  2. Wrap the required function call in JavaScript that calls the function and stores its return value in the input field.
  3. Read the value from the input field and return it.

It's a bit of a dirty hack, and it only works if the function's return value can be represented as a string. For what it's worth, here it is:

We'll create a little class called TJSExec:

  TJSExec = class(TObject)
    fWB: TWebBrowser;
    function GetDocWindow: IHTMLWindow2;
    function GetElementById(const ID: string): IHTMLElement;
    function GetRetValContainer: IHTMLElement;
    function CreateRetValContainer: IHTMLElement;
    constructor Create(const WB: TWebBrowser);
    procedure RunJSProc(const Fn: string);
    function RunJSFn(const Fn: string): string;

GetDocWindow and GetElementById are just helpers that get the IHTMLWindow2 interface to the current document and find an element with a given ID:

function TJSExec.GetDocWindow: IHTMLWindow2;
  Doc: IHTMLDocument2;
  if not Supports(fWB.Document, IHTMLDocument2, Doc) then
    raise Exception.Create('Invalid document');
  Result := Doc.parentWindow;
  if not Assigned(Result) then
    raise Exception.Create('No document window');

function TJSExec.GetElementById(const ID: string): IHTMLElement;
  Doc: IHTMLDocument3;
  if not Supports(fWB.Document, IHTMLDocument3, Doc) then
    raise Exception.Create('Invalid document');
  Result := Doc.getElementById(ID);

CreateRetValContainer and GetRetValContainer create and find the hidden input field:

function TJSExec.CreateRetValContainer: IHTMLElement;
  Doc: IHTMLDocument2;
  if not Supports(fWB.Document, IHTMLDocument2, Doc) then
    raise Exception.Create('Invalid document');
  Result := Doc.createElement('input');
  Result.id := cRetValElemId;
  Result.setAttribute('name', cRetValElemId, 0);
  Result.setAttribute('type', 'hidden', 0);
  Result.setAttribute('value', '', 0);

function TJSExec.GetRetValContainer: IHTMLElement;
  NewNode: IHTMLDOMNode;
  BodyNode: IHTMLDOMNode;
  Doc: IHTMLDocument2;
  Result := GetElementById(cRetValElemId);
  if not Assigned(Result) then
    if not Supports(fWB.Document, IHTMLDocument2, Doc) then
      raise Exception.Create('Invalid document');
    if not Supports(Doc.body, IHTMLDOMNode, BodyNode) then
      raise Exception.Create('Invalid body node');
    Result := CreateRetValContainer;
    if not Supports(Result, IHTMLDOMNode, NewNode) then
      raise Exception.Create('Invalid new node');

GetRetValContainer tries to find the hidden input field and, if it doesn't exist, calls CreateRetValContainer. This method manipulates the DOM to append a hidden input field to the current document. In this way repeated calls to JavaScript function re-use the hidden field once it has been created.

RunJSProc just calls a JavaScript function without getting its return value. It is useful when no return value is needed or available.

procedure TJSExec.RunJSProc(const Fn: string);
  Wdw: IHTMLWindow2;
    Wdw := GetDocWindow;
    Wdw.execScript(Fn, 'JavaScript'); // execute function
    // swallow exception to prevent JS error dialog

RunJSFn is where the action is. The key is the use of the JavaScript eval function to store the function result in the hidden input. It finds the field from its id and stores the function result its value attribute. RunJSFn then gets its return value from the field.

function TJSExec.RunJSFn(const Fn: string): string;
  EmbedFn: string;
  WrapperFn: string;
  HiddenElem: IHTMLElement;
  WrapperFnTplt = 'eval("'
    + 'id = document.getElementById(''' + cRetValElemId + '''); '
    + 'id.value = %s;'
    + '")';
  EmbedFn := StringReplace(Fn, '"', '\"', [rfReplaceAll]);
  EmbedFn := StringReplace(EmbedFn, '''', '\''', [rfReplaceAll]);
  HiddenElem := GetRetValContainer;
  WrapperFn := Format(WrapperFnTplt, [EmbedFn]);
  Result := HiddenElem.getAttribute('value', 0);

The constructor just records the browser control that contains the relevant document.

constructor TJSExec.Create(const WB: TWebBrowser);
  inherited Create;
  fWB := WB;

Finally, we use a GUID as the id of the hidden input field to try to ensure it is unique in the document:

  cRetValElemId = 'id58A3A2A46539468A943D00FDD6A4FF08';

So there we have it - at last a way to get a value from a JavaScript function. But, what about functions that don't return values that make sense when cast to strings: what if the function returns an object such as Date? Leave a comment if you have ideas please.

This source code, along with a demo project for Delphi 2010 is available in my Delphi Doodlings repository. View the code.


Harry Pitfall said...

I create my own descendent of TWebBrowser, that have the possibility to publish a "external" com object.
So, you can just create a Delphi COM object, with a method "ReceiveResult" and do a external.ReceiveResult(xxx); script command to send values back to Delphi host :)
Isn't hard to achieve!

DelphiDabbler said...

Thx Harry

Don't know why I didn't make this connection myself - I've even got an article about the external object here!

I'll give this a try sometime and post an example if I can make it work.

Nelson said...

thanks for sharing this .. its must helpful for developers know about this u java and Delphi its a great combination .