Pascal Newsletter #23
The full source code examples of this issue are available for download.
![]() |
![]() |
Pascal Newsletter #23 - 23-JUN-2001 INDEX 1. A FEW WORDS FROM THE EDITOR 2. HASH TABLES - By Alirio A. Gavidia B. - How to take a decision - The alternatives - order - Closer to "case..of" - Pros and cons - "Hash" tables or lists - Facing the way to find the index - Multiplication method - Manipulating strings of characters - Conclusion 3. KYLIX DESKTOP DEVELOPER EDITION FOR JUST $199! 4. LOADING A JPEG IMAGE FILE INTO A BITMAP 5. PE EXPLORER 6. PESTPATROL 7. HYPHENATION 8. GREATIS TFORMDESIGNER 3.0 9. GETTING THE ICON OF AN APPLICATION OR DOCUMENT ________________________________________________________________________ 1. A FEW WORDS FROM THE EDITOR Before we start, I'd like to apologize for the delay in releasing this issue of the newsletter. For those who are interested in a comparison between Delphi and Visual Basic, please check the last issue of our Developers Newsletter: http://www.latiumsoftware.com/en/developers/0016.php At Latium Software we are celebrating our first anniversary! With its ups and downs, almost without noticing, a full year has elapsed... I recall that this newsletter started with no more than 100 subscribers, and after a year we now have more than 2,600! I'd like to thank all of you, specially those who have accompanied us from the very beginning, the webmasters who linked to our web site, those who have collaborated with the newsletter with their articles (particularly to Alirio A. Gavidia), those who contacted us to congratulate us for the newsletter and encouraged us to keep it going, those who recommended us to their colleagues, those who voted for us in the rankings, and finally I don't want to forget all the companies who trusted us to present their products. Thank you all very much. In the following year there will be many changes, hoping to be able to fulfil your expectations much better. Stay tuned! Ernesto De Spirito eds2004 @ latiumsoftware.com ________________________________________________________________________ JfControls Library. Multi-language. Multi-appearance. Skins. Privileges. More than 40 integrated and customizable components. Impressive GUI. Centralized resources administration. Multiple programming problems solved. For Delphi 3-7 and C++ Builder 3-6. http://www.jfactivesoft.com/ ________________________________________________________________________ 2. HASH TABLES - By Alirio A. Gavidia B. <alirio@gavidia.org> How to take a decision ====================== A common problem in programming is the taking of a course of action based on the value of a literal sequence of characters. This is simply to be able to execute an option if for example the variable "Payment" is 'cash', 'check', 'card' or 'transfer'. We could design a sequence of code like the following: Const STransfer = 'transfer'; SCheck = 'check'; SCard = 'card'; SCash = 'cash'; SPayMode = 'Pay mode'; SUnknow = 'unknown'; If pago=SCash then ... If pago=SCheck then ... If pago=SCard then ... If pago=STransfer then ... This would work, and -to be sincere- I wouldn't worry much about it. It's practical. Nevertheless, if we speak of more options (like for example 32 to give a number), it might become a bit uncomfortable and certainly little elegant. The option of a "case" is -in this level- unusable, simply because it requires ordinal types (integers, chars and enumerations). This allows the "case" statement to be really efficient (unlike the "Case" of xBase languages). Improving the previous example we can go to: If pago=SCash then ... else If pago= SCheck then ... else If pago= SCard then ... else If pago= STransfer then ... This change prevents that "n" checkings be done for an equal number of options, but, for the case of the last option -or even worse, the case in which the option be different from the expected ones-, "n" checkings are performed anyway. The alternatives - order ======================== The order seems to be the way. A sorted list is established and then a binary search is made (divide and conquer). Let us suppose that we have 32 elements: we divide in two sections of 16 elements and select, divide in two of 8 and select, divide in two of 4 and select, divide in two of 2 and select, and we choose the end. Total: 5 decisions (notice the fact that 2 to the 5th power is 32). Until now, the binary search guarantees us that for 32 options a maximum of 5 (log N) checkings (in fact, 50% of the searches would be solved in 5 checkings, think about it). Nested "if" statements would yield in an average of 16 checkings (n/2). Closer to "case..of" ==================== An idea that always appears, and that I have seen used, is to take the first letter. For the initial example, this would let to us use a "case" structure, but when arriving to the letter or 'c' of 'cash', 'check' or 'card' in English (or the 't' of 'tarjeta' and 'transferencia' in Spanish) we would need at least to place an "if" or another "case". Another approach would be to create a function that gives us a number for an identifier. For example, I propose the following function: Function IdentIndex(AIdent: String): integer; Begin Result := length(AIdent) + Ord(AIdent[3]) end sCash 119 // in English SCheck 106 // in English SCard 118 // in English STransfer 105 // in English sCash 109 // in Spanish SCheck 107 // in Spanish SCard 121 // in Spanish STransfer 110 // in Spanish Now it's possible to use a "Case": Var Ident : string; begin Ident := 'cash'; if InputQuery('Hash', SPayMode, Ident) then case IdentIndex(Ident) of 109: ShowMessage(SCash); 107: ShowMessage(SCheck); 121: ShowMessage(SCard); 110: ShowMessage(STransfer); else ShowMessage(SUnknown); end end; Pros and cons ============= The method is interesting due to the fact that reduces the number of necessary checkings to one. However now we have the overload of having to determine the suitable function that returns the indexes. An incorrect determination of this formula will introduce the following problems: * Index repetition * Scattered indexes * Slowness in the evaluation of the formula The last point is very obvious and thus I won't make comments about it. The repetition of indexes forces us to make additional steps within the "Case". If we have two identifiers generating the same index, then it's necessary to make at least one additional analysis to recognize which is the correct way. Scattered indexes are a drawback if we try to build a table. In this case we could design an array like the one I exemplify here: Begin : FillChar(JumpTable,SizeOf(JumpTable), 0); JumpTable[IdentIndex(SCash)] := PayModeCash; JumpTable[IdentIndex(SCheck)] := PayModeCheck; JumpTable[IdentIndex(SCard)] := PayModeCard; JumpTable[IdentIndex(sTransfer)] := PayModeTransfer; : End; Var JumpTable : array [100..130] of TRoutine; Index : Integer; Ident : string; Begin Ident := SCash; InputQuery('Hash', SPayMode, Ident); Index := IdentIndex(Ident); if (Index in [100..130]) and (Assigned(JumpTable[Index])) then JumpTable[Index] end; We have JumpTable as an array of routines that are executed given an index. But there's a waste since we used only four out of 30 possible values. The ideal would be that "IdentIndex" gives us four consecutive values. Sometimes we have to live with that, but sometimes no. In the case of the analyzer of a compiler, normally it's possible to dedicate effort to determine the adequate formula without waste. But when there are many words, or they aren't known beforehand, we must rethink the whole subject. "Hash" tables or lists ====================== The formula to determine the index generally must be limited since usually we can't create arrays with 14-digits indexes. The simplest solution is dividing and getting the rest. If we want to limit us to only 8 possibilities, we could divide into 8 and get the rest of the division. This way, the new values will be: sCash 7 // in English SCheck 2 // in English SCard 6 // in English STransfer 1 // in English sCash 5 // in Spanish SCheck 3 // in Spanish SCard 1 // in Spanish STransfer 6 // in Spanish Too good, but sometimes it doesn't end up being this way. For example, limiting it to four instead of eight, index repetition appears: sCash 3 // in English SCheck 2 // in English SCard 2 // in English STransfer 1 // in English sCash 1 // in Spanish SCheck 3 // in Spanish SCard 1 // in Spanish STransfer 2 // in Spanish The suggested limit for a table is usually a prime number that is not very near to a power of 2. For tables with repetition of indexes, normally the index is obtained, the table is checked, and if the cell is free, it is taken, otherwise the next free cell is searched in a linear way. Each failure in this search reduces the efficiency of the method. Hash lists are implemented as lists of lists. Each node of the main list points to the list of identifiers that match the given index. There are other solutions to this problem, but I won't analyze them here. Facing the way to find the index - Multiplication method ======================================================== I like this way of solving the problem when we have a number in a wide range and we want to reduce it to a smaller range. For a number of possible values 2^N (2 raised to the Nth power). "Key" is a value generated directly when evaluating the identifier. The formula is determined this way: Hash := ((K*Key) and M) mod S; Where: - K is 158 for 8-bits indexes, 40503 for 16-bits indexes and 2654435769 for 16-bits indexes. - S is 2 raised to the difference between the number of bits and N. - M is the size of table minus 1. (2^N-1). For N=10 we have K=40503 (16 bits) S=2^(16-10) = 2^6 = 64 M=1023 Hash := ((40503*Key) and 1023) mod 64; The result would be a value from 0 to 63 (from an original range of 0 to 1023). Manipulating strings of characters ================================== Usually we can take each character of the string, add them and take the module with 256. However this generates problems with anagrams ('abcd' and 'dcba' for example) and similar words ('cba' and 'cab' for example). A solution is using xor (exclusive or) instead of the addition and to add an intermediate table of pseudo-random values that gives us a better distribution, apart from considering in the algorithm the position of each letter and not only its intrinsic value. Conclusion ========== Using "hash tables" can give terrific results in situations where we have knowledge of the identifiers to search and/or where we can waste memory in exchange for speed. In particular, compilers and interpreters are -in my opinion- the best cases. It's also useful in database systems where the problem is not space but the need of a minimum amount of readings. However, a problem to consider is the elimination of records in a table, something usual in databases. ------------------ The source code that accompanies this article can be downloaded from: http://www.latiumsoftware.com/download/p0023.zip ------------------ To learn more: * D. E. Knuth, 1973, The Art of Computer Programming, Vol. 3: Sorting and Searching, Reading, MS: Addison-Wesley. ------------------------------------ Copyright © 2001 by Alirio A. Gavidia B. All rights reserved. The publication of this material is allowed by any means from anyone as long as this material is not modified and the original source is mentioned. ________________________________________________________________________ 3. KYLIX DESKTOP DEVELOPER EDITION FOR JUST $199! Borland is trying to sell as many licenses of Kylix as possible and for a limited time has significantly lowered the price of Kylix DDE from US $999 to only just US $199. Yes, a license of Kylix DDE costs only just US $199! If you are interested in application development under Linux, this is definitely the right time to buy: http://shop.borland.com/Product/0,1057,3-15-CQ100479,00.html The offer expires on August 23, 2001. The order form allows for international billing addresses, but a United States shipping address must be provided for shipment. If you don't have an US address -either yours or provided by a third party- you can check your local Borland representative to see at how much they are selling it. It's usually more expensive, but you don't have to worry about transport, customs, etc. ________________________________________________________________________ 4. LOADING A JPEG IMAGE FILE INTO A BITMAP This is a recurrent question in forums and newsgroups. To load a JPEG image we have to use the "jpeg" unit that comes with Delphi (if you don't have it installed, I believe you can find it in the "Extras" directory in the Delphi CD ROM). You can find alternatives in the web, but I'm going to use this one because it comes with Delphi. To load a JPEG image in an Image component you can do something like the following: uses jpeg; procedure TForm1.Button1Click(Sender: TObject); var jpg: TJpegImage; begin jpg := TJpegImage.Create; try jpg.LoadFromFile('d:\path\file.jpg'); Image1.Picture.Assign(jpg); // Loads the image as a JPEG finally jpg.Free; end; end; The image is loaded as JPEG, which is fine, unless we intend to have access to the Pixels and ScanLine properties of the Bitmap. For example, the following won't work: Image1.Canvas.Pixels[0,0] := clWhite; // Top-leftmost pixel in white If we intend to perform some image processing we should load the JPEG image as a bitmap. To do that we have to use the Assing method of a Bitmap, not a Picture, so we just have to make a little change in the procedure we presented above: Image1.Picture.Bitmap.Assign(jpg); // Loads the image as a BMP ________________________________________________________________________ 5. PE EXPLORER PE Explorer is a source code analyzer, resource tool and disassembler. Allows you to view, edit and repair the internal structure and resources of PE (portable executable) files (such as EXE, DLL, DRV, BPL, DPL, SYS, CPL, OCX, SCR and other win32 executables). Visual editing features let you quickly modify the resources without writing any scripts. Application : PE Explorer v1.20 Creator : Heaven Tools http://www.heaventools.com License : Shareware (30-day evaluation) Download : http://www.heaventools.com/download/pexsetup.zip (~1Mb) ________________________________________________________________________ 6. PESTPATROL PestPatrol detects and removes over 16,000 non-virus pests and is designed to be used with anti-virus software to provide complete protection. Runs fast, detecting and removing malicious or unwanted programs and documents including ANSI Bombs, Anarchy-related, annoyances, AOL Pests, Back Doors, Carding, Denial of Service, Exploits, Explosives, Hostile Java and ActiveX, Keyloggers, Mail Bombers, Cracking Tools, Password Crackers and Capture Tools, Phreaking Tools, Port Scanners, Remote Control/Admin, Sniffers, Spoofers, Spyware, Trojans and Trojan Creation Tools, War Dialers, Worms, etc. Application: PestPatrol Creator : SaferSite.com, Inc. http://www.safersite.com/ License : Tryware (detects but doesn't remove) Download : http://safersite.com/Downloads/Eval/SetupPestPatrolEval.EXE (~1.35Mb) ________________________________________________________________________ 7. HYPHENATION Sometimes we need to display or print a text, and we'd like to hyphenate long words that don't fit at the end of a line, to prevent them from falling entirely into the next line leaving too much space unused. The main problem that arises is how to divide a word in syllables. Well, I really don't know how to syllabicate in English, so I leave that part to you, but I hope you find the example on Spanish syllabication useful: procedure Syllabify(Syllables: TStringList; s: string); const Consonants = ['b','B','c','C','d','D','f','F','g','G', 'h','H','j','J','k','K','l','L','m','M','n','N', 'ñ','Ñ','p','P','q','Q','r','R','s','S','t','T', 'v','V','w','W','x','X','y','Y','z','Z']; StrongVowels = ['a','A','á','Á','e','E','é','É', 'í','Í','o','ó','O','Ó','ú','Ú']; WeakVowels = ['i','I','u','U','ü','Ü']; Vowels = StrongVowels + WeakVowels; Letters = Vowels + Consonants; var i, j, n, m, hyphen: integer; begin j := 2; s := #0 + s + #0; n := Length(s) - 1; i := 2; Syllables.Clear; while i <= n do begin hyphen := 0; // Do not hyphenate if s[i] in Consonants then begin if s[i+1] in Vowels then begin if s[i-1] in Vowels then hyphen := 1; end else if (s[i] in ['s', 'S']) and (s[i-1] in ['n', 'N']) and (s[i+1] in Consonants) then begin hyphen := 2; end else if (s[i+1] in Consonants) and (s[i-1] in Vowels) then begin if s[i+1] in ['r','R'] then begin if s[i] in ['b','B','c','C','d','D','f','F','g', 'G','k','K','p','P','r','R','t','T','v','V'] then hyphen := 1 else hyphen := 2; end else if s[i+1] in ['l','L'] then begin if s[i] in ['b','B','c','C','d','D','f','F','g', 'G','k','K','l','L','p','P','t','T','v','V'] then hyphen := 1 else hyphen := 2; end else if s[i+1] in ['h', 'H'] then begin if s[i] in ['c', 'C', 's', 'S', 'p', 'P'] then hyphen := 1 else hyphen := 2; end else hyphen := 2; end; end else if s[i] in StrongVowels then begin if (s[i-1] in StrongVowels) then hyphen := 1 end else if s[i] = '-' then begin Syllables.Add(Copy(s, j, i - j)); Syllables.Add('-'); inc(i); j := i; end; if hyphen = 1 then begin // Hyphenate here Syllables.Add(Copy(s, j, i - j)); j := i; end else if hyphen = 2 then begin // Hyphenate after inc(i); Syllables.Add(Copy(s, j, i - j)); j := i; end; inc(i); end; m := Syllables.Count - 1; if (j = n) and (m >= 0) and (s[n] in Consonants) then Syllables[m] := Syllables[m] + s[n] // Last letter else Syllables.Add(Copy(s, j, n - j + 1)); // Last syllable end; To test the procedure yon can drop a Textbox and a Label on a form and in the Change event of the Textbox write: procedure TForm1.Edit1Change(Sender: TObject); var Syllables: TStringList; begin Syllables := TStringList.Create; try Syllabify(Syllables, Edit1.Text); Label1.Caption := StringReplace(Trim(Syllables.Text), #13#10, '-', [rfReplaceAll]); finally Syllables.Free; end; end; Now that we have a syllabication procedure, we have to note that we can't hyphenate a word in any syllable break. It is usually correct and/or desirable to join small syllables at the left and/or right sides of a word to guarantee for example that there are at least two syllables on either side of the word when it gets hyphenated, or -like in the following example- to make sure that at least we have four characters in either side: procedure ApplyRules(Syllables: TStringList); // Guarantee there are at least four letters in the left // and right parts of the word begin with Syllables do begin if Count = 1 then exit; while Count > 1 do begin if Length(Strings[0]) >= 4 then break; Strings[0] := Strings[0] + Strings[1]; Delete(1); end; while Syllables.Count > 1 do begin if Length(Strings[Count-1]) >= 4 then break; Strings[Count-2] := Strings[Count-2] + Strings[Count-1]; Delete(Count-1); end; end; end; Finally, it comes the time to parse the text separating the lines of a paragraph determining which words should be hyphenated. The following example does that with a text to be displayed in a Memo: procedure Hyphenate(Memo: TMemo; OriginalText: TStrings); var paragraph, i, j, k, m, n, MaxLineWidth: integer; s, line: string; Bitmap: TBitmap; Canvas: TCanvas; Syllables: TStringList; begin Syllables := TStringList.Create; try // We need a canvas to use its TextWidth method to get the width // of the text to see if it fits in the client area or not. The // TMemo class doesn't have a Canvas property, so we have to // create one of our own. Bitmap := TBitmap.Create; Canvas := Bitmap.Canvas; try Canvas.Font := Memo.Font; MaxLineWidth := Memo.ClientWidth - 6; // Maximum width Memo.Lines.Clear; for paragraph := 0 to OriginalText.Count - 1 do begin // For each paragraph s := OriginalText[paragraph]; // Get the original paragraph // Get the lines in which we have to break the paragraph while Canvas.TextWidth(s) > MaxLineWidth do begin // First we find (in "j") the index of the start of the // first word that doesn't fit (the one to hyphenate) j := 1; n := Length(s); i := 2; while i <= n do begin if (s[i-1] = ' ') and (s[i] <> ' ') then j := i; // last beginning of a word if Canvas.TextWidth(Copy(s, 1, i)) > MaxLineWidth then break; // reached a width that doesn't fit inc(i); end; // Where does the break occurs? if s[i] = ' ' then begin // Great! We break on a space Memo.Lines.Add(Copy(s, 1, i - 1)); // Add the line s := Copy(s, i + 1, n - i); // Remove the line end else begin // We break somewhere in a word. Now, we find (in "k") // the first space after the word (k) k := j + 1; while (k <= n) and (s[k] <> ' ') do inc(k); // Divide the word in Syllables Syllabify(Syllables, Copy(s, j, k - j)); ApplyRules(Syllables); // Check (in "m") how many syllables fit m := 0; Line := Copy(s, 1, j-1); while Canvas.TextWidth(Line + Syllables[m] + '-') <= MaxLineWidth do begin Line := Line + Syllables[m]; inc(m); end; if (m <> 0) and (Syllables[m-1] <> '-') then begin // Hyphenate Line := Line + '-'; j := Length(Line); if Syllables[m] = '-' then inc(j); end; Memo.Lines.Add(Line); // Add the line s := Copy(s, j, n - j + 1); // Remove the line end; end; Memo.Lines.Add(s); // Add the last line (it fits) end; finally Bitmap.Free; end; finally Syllables.Free; end; end; To test the procedure, drop a Memo component on a form, align it for example to the top of the form (Align = alTop) and write the following code in the Resize event of the form: procedure TForm1.FormResize(Sender: TObject); var OriginalText: TStringList; begin OriginalText := TStringList.Create; try OriginalText.Add('Si se ha preguntado cómo hacen los ' + 'programas procesamiento de textos para dividir palabras ' + 'con de guiones al final de una línea, he aquí un ' + 'ejemplo sencillo (en comparación con los que usan las ' + 'aplicaciones de procesamiento de textos).'); OriginalText.Add('Este es un segundo párrafo que se provee ' + 'con fines de ejemplo.'); Hyphenate(Memo1, OriginalText); finally OriginalText.Free; end; end; The full source code of this article can be found attached to this newsletter. ________________________________________________________________________ 8. GREATIS TFORMDESIGNER 3.0 What is Greatis TFormDesigner? ------------------------------ TFormDesigner is a component for Delphi 3-5 and C++ Builder 3-5 that allows you to move and resize any control on your form. Just place a TFormDesigner component into your form, set Active property to True, and enjoy - you don't need to prepare your form to use TFormDesigner. The beauty of TFormDesigner is that it works in RUN-TIME, whereas the standard form designer works only in design time. To facilitate ease of use, TFormDesigner replicates the face of the standard Delphi and C++ Builder form designer. After TFormDesigner has been activated, you can select any control in your form by Tab key or mouse click and move/resize it. Features -------- - Multi-selection - ActiveX forms compatibility - MDI forms compatibility - Locking and protecting any controls - Align dialog - Size dialog - Alignment palette - Customizable grab handles - Customizable design grid - Size/coordinates hints - Printable User Manual - Free trial version for Delphi 3-5 and C++ Builder 3-5 - Fully-functional form editor as free demo Download -------- Compiled EXE-demo, printable documentation in PDF-format and trial version for Delphi 3-5 and C++ Builder 3-5 are included in the demo kit, availabled from: http://www.greatis.com/formdesdemo.zip (~756K) License ------- TFormDesigner costs US$ 29.95 for a single-user license. More information ---------------- You can find more information at TFormDesigner's home page http://www.greatis.com/formdes.htm For more information, contact Greatis Software <b-team@greatis.com> ________________________________________________________________________ 9. GETTING THE ICON OF AN APPLICATION OR DOCUMENT Marian Hubinsky sent me this code in response to my article "Getting the icon of an application or document", telling me that GetAssociatedIcon (the function I wrote) "doesn't extract correctly some small icons, this one is smaller and works little bit better": // get icon from Shell uses ShellApi; function SHSmallIcon(xpath : string; getopen : boolean) : hicon; var fili : TSHFileInfo; aka : Integer; begin aka := SHGFI_ICON or SHGFI_SMALLICON; if getopen then aka := aka or SHGFI_OPENICON; SHGetFileInfo(Pchar(xpath), 0, fili, sizeof(TSHFileInfo), aka); Result := fili.hIcon; end; // example of use: procedure TForm1.Button3Click(Sender: TObject); var smi : hicon; begin smi := SHSmallIcon('c:\windows', true); DrawIconex(Form1.Canvas.Handle, 10, 10, smi, 0, 0, 0, 0, DI_normal); DestroyIcon(smi); end; // Marian Hubinsky <treeumph@tn.sknet.sk> http://wtools32.szm.com Thanks Marian for your collaboration. ________________________________________________________________________ YOU CAN HELP US! We need your help to keep growing. The easiest way you can help us is voting for us in any or some of these rankings: http://www.sandbrooksoftware.com/cgi-bin/TopSite2/rankem.cgi?id=latium http://news.optimax.com/delphi/links/links.exe/click?id=70C517ECAE6E http://www.programmingpages.com/?r=latiumsoftwarecomenpascal http://www.top219.org/cgi-bin/vote.cgi?delphi&83 http://top100borland.com/in.php?who=20 http://top200.jazarsoft.com/delphi/rank.php3?id=latium http://213.65.224.200/cgi-bin/toplist.cgi/hits?Id=80 It's just a few seconds for you that really mean much to us. ________________________________________________________________________ If you haven't received the full source code examples for this issue, you can get them from http://www.latiumsoftware.com/download/p0023.zip ________________________________________________________________________ This newsletter is provided "AS IS" without warranty of any kind. Its use implies the acceptance of our licensing terms and disclaimer of warranty you can read at http://www.latiumsoftware.com/en/legal.php where you will also find a note about legal trademarks. Articles are copyright of their respective authors and they are reproduced here with their permission. You can redistribute this newsletter as long as you do it in full (including copyright notices), without changes, and gratis. ________________________________________________________________________ Main page: http://www.latiumsoftware.com/en/pascal/delphi-newsletter.php Group home page: http://groups.yahoo.com/group/pascal-newsletter/ Subscribe/join: pascal-newsletter-subscribe@yahoogroups.com Unsubscribe/leave: pascal-newsletter-unsubscribe@yahoogroups.com Problems with your subscription? eds2004 @ latiumsoftware.com ________________________________________________________________________ Latium Software http://www.latiumsoftware.com/en/index.php Copyright (c) 2001 by Ernesto De Spirito. All rights reserved. ________________________________________________________________________ |
The full source code examples of this issue are available for download.
![]() |
Errors? Omissions? Comments? Please contact us!






