123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290(* Claude Code
*
* Copyright (C) 2026 Yoann Padioleau
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Library General Public License
* (LGPL) as published by the Free Software Foundation; either version
* 2 of the License, or (at your option) any later version.
*)(* See Looks.mli *)typecolor=int*int*inttypealign=Left|Center|Righttypet={size:float;bold:bool;italic:bool;underline:bool;strike:bool;monospace:bool;color:color;link:stringoption;pre:bool;align:align;link_color:color;visited_color:color;base:float;extensions:bool;}letroot?(extensions=false)~(size:float)():t={size;bold=false;italic=false;underline=false;strike=false;monospace=false;color=(0,0,0);link=None;pre=false;align=Left;link_color=(0,0,238);visited_color=(85,26,139);base=size;extensions;}letleading=1.2(*****************************************************************************)(* Looks: inherited *)(*****************************************************************************)(* a heading's size, in its parent's ems *)letheading_size(name:string):floatoption=matchnamewith|"h1"->Some2.|"h2"->Some1.5|"h3"->Some1.17|"h4"->Some1.|"h5"->Some0.83|"h6"->Some0.75|_->None(* HTML 3.2's align= (div's; p's and a heading's are Netscape's) *)letalign_of(l:t)(e:Dom.element)(inherited:align):align=matchOption.mapString.lowercase_ascii(Dom.attribute~extensions:l.extensions"align"e)with|Some"center"->Center|Some"right"->Right|Some"left"->Left|_->inheritedletcolors=[("black",(0,0,0));("silver",(192,192,192));("gray",(128,128,128));("white",(255,255,255));("maroon",(128,0,0));("red",(255,0,0));("purple",(128,0,128));("fuchsia",(255,0,255));("green",(0,128,0));("lime",(0,255,0));("olive",(128,128,0));("yellow",(255,255,0));("navy",(0,0,128));("blue",(0,0,255));("teal",(0,128,128));("aqua",(0,255,255));]letcolor_of_string(s:string):coloroption=lets=String.lowercase_ascii(String.trims)in(* the # was often left out, and browsers took the digits anyway;
* CSS's #rgb is #rrggbb, each digit twice *)lethex=matchString.lengthswith|7whens.[0]='#'->Some(String.subs16)|6->Somes|4whens.[0]='#'->Some(String.concat""(List.map(funi->String.make2s.[i])[1;2;3]))|_->Nonein(* CSS's rgb(r, g, b) *)letrgb=ifString.lengths>5&&String.subs04="rgb("&&s.[String.lengths-1]=')'thenmatchList.map(funx->int_of_string_opt(String.trimx))(String.split_on_char','(String.subs4(String.lengths-5)))with|[Somer;Someg;Someb]->Some(min255r,min255g,min255b)|_->NoneelseNoneinmatch(List.assoc_optscolors,hex,rgb)with|Somec,_,_->Somec|None,Someh,_->(matchint_of_string_opt("0x"^h)with|Somen->Some((nlsr16)land255,(nlsr8)land255,nland255)|None->None)|None,None,rgb->rgbletfont_scale(s:string):floatoption=letscale=[|0.625;0.8125;1.;1.125;1.5;2.;3.|]inlets=String.trimsinletn=ifs<>""&&(s.[0]='+'||s.[0]='-')thenOption.map(fund->3+d)(int_of_string_opt(ifs.[0]='+'thenString.subs1(String.lengths-1)elses))elseint_of_string_optsinOption.map(funn->scale.(max1(min7n)-1))n(* claude: the look an extension gives, when honoured *)letnetscape(l:t)(e:Dom.element):t=letcolorname=Option.bind(Dom.attribute~extensions:truenamee)color_of_stringinletsetcdefault=Option.valuec~defaultinmatche.namewith|"center"->{lwithalign=Center}|"font"->letsize=Option.bind(Dom.attribute"size"e)font_scalein{lwithsize=(matchsizewithSomek->k*.l.base|None->l.size);color=set(color"color")l.color}|"body"->{lwithcolor=set(color"text")l.color;link_color=set(color"link")l.link_color;visited_color=set(color"vlink")l.visited_color}(* a cell starts on the left, whatever it is in (<center>); a heading
* cell is bold and centred *)|"td"->{lwithalign=align_ofleLeft}|"th"->{lwithbold=true;align=align_ofleCenter}|_->lletlook(parent:t)(e:Dom.element):t=letl=parentinmatche.namewith(* an extension not honoured: an unknown tag, as Mosaic had it *)|_whene.origin=Netscape&¬l.extensions->l|_whene.origin=Netscape->netscapele|"body"whenl.extensions->netscapele|namewhenheading_sizename<>None->{lwithbold=true;size=l.size*.Option.get(heading_sizename);align=align_oflel.align}|"b"|"strong"->{lwithbold=true}|"i"|"em"|"cite"|"var"|"dfn"|"address"->{lwithitalic=true}|"tt"|"code"|"kbd"|"samp"->{lwithmonospace=true}|"pre"|"listing"|"xmp"->{lwithmonospace=true;pre=true}|"u"->{lwithunderline=true}|"s"|"strike"|"del"->{lwithstrike=true}|"big"->{lwithsize=l.size*.1.17}|"small"|"sub"|"sup"->{lwithsize=l.size*.0.83}|"p"|"div"->{lwithalign=align_oflel.align}|"a"->(matchDom.attribute"href"ewith|Somehref->{lwithcolor=l.link_color;underline=true;link=Somehref}|None->l)|_->l(*****************************************************************************)(* Boxes: not inherited *)(*****************************************************************************)typedisplay=Block|Inline|Rule|Hiddentypebox={display:display;margin_top:float;margin_bottom:float;indent:float;right:float;background:coloroption;}lethidden=["head";"title";"script";"style";"meta";"link";"base"]letblocks=["html";"body";"address";"blockquote";"center";"dir";"div";"dl";"dt";"dd";"form";"li";"menu";"ol";"p";"pre";"listing";"xmp";"ul";"table";"caption";"thead";"tbody";"tfoot";"tr";"td";"th";]letlists=["ul";"ol";"dir";"menu"]letbox(look:t)(e:Dom.element):box=letemk=k*.look.sizeinletblock?(indent=0.)?(right=0.)topbottom={display=Block;margin_top=top;margin_bottom=bottom;indent;right;background=None}inletnonedisplay={display;margin_top=0.;margin_bottom=0.;indent=0.;right=0.;background=None}inmatche.namewith|_whene.origin=Netscape&¬look.extensions->noneInline|namewhenList.memnamehidden->noneHidden|"hr"->{(noneRule)withmargin_top=em0.5;margin_bottom=em0.5}|"body"->block~indent:8.~right:8.8.8.|"h1"->block(em0.67)(em0.67)|"h2"->block(em0.75)(em0.75)|"h3"->block(em0.83)(em0.83)|"h4"->block(em1.12)(em1.12)|"h5"->block(em1.5)(em1.5)|"h6"->block(em1.67)(em1.67)|"p"|"dl"->block(em1.12)(em1.12)|"blockquote"->block~indent:40.~right:40.(em1.12)(em1.12)|namewhenList.memnamelists->block~indent:40.(em1.12)(em1.12)|"dd"->block~indent:40.0.0.|"pre"|"listing"|"xmp"->block(em1.)(em1.)|namewhenList.memnameblocks->block0.0.|_->noneInline(*****************************************************************************)(* Style sheets: what a declaration does *)(*****************************************************************************)(* a length: px, em (of [em]), or a number alone (px); % of [percent] *)letlength~(em:float)?(percent=em)(v:string):floatoption=letv=String.trim(String.lowercase_asciiv)inletnumbersuffix=ifString.ends_with~suffixvthenfloat_of_string_opt(String.trim(String.subv0(String.lengthv-String.lengthsuffix)))elseNoneinmatch(number"px",number"em",number"%",number"pt")with|Somen,_,_,_->Somen|_,Somen,_,_->Some(n*.em)|_,_,Somen,_->Some(n*.percent/.100.)(* a point is 1/72 inch, a pixel 1/96 *)|_,_,_,Somen->Some(n*.96./.72.)|_->float_of_string_optv(* CSS1's font-size keywords: medium the root's, a step 1.2 *)letfont_size~(parent:t)(v:string):floatoption=letsteps=[("xx-small",-3);("x-small",-2);("small",-1);("medium",0);("large",1);("x-large",2);("xx-large",3)]inmatchString.lowercase_asciivwith|kwhenList.mem_assocksteps->Some(parent.base*.(1.2**float_of_int(List.assocksteps)))|"larger"->Some(parent.size*.1.2)|"smaller"->Some(parent.size/.1.2)|v->length~em:parent.sizevletstyled~(parent:t)(l:t)(declarations:(string*string)list):t=List.fold_left(fun(l:t)(property,value)->letv=String.lowercase_asciivalueinmatchpropertywith|"color"->(matchcolor_of_stringvaluewithSomec->{lwithcolor=c}|None->l)|"font-size"->(matchfont_size~parentvaluewithSomeswhens>0.->{lwithsize=s}|_->l)|"font-weight"->(match(v,int_of_string_optv)with|("bold"|"bolder"),_->{lwithbold=true}|("normal"|"lighter"),_->{lwithbold=false}|_,Somen->{lwithbold=n>=600}|_->l)|"font-style"->{lwithitalic=v="italic"||v="oblique"}|"font-family"->(* one pen: all we can tell is fixed width or not *){lwithmonospace=List.exists(funf->String.trimf="monospace"||String.trimf="courier")(String.split_on_char','v)}|"text-decoration"->{lwithunderline=v="underline";strike=v="line-through"}|"text-align"->(matchvwith"center"->{lwithalign=Center}|"right"->{lwithalign=Right}|"left"->{lwithalign=Left}|_->l)|"white-space"->{lwithpre=v="pre"}|_->l)ldeclarationsletstyled_box(l:t)(b:box)(declarations:(string*string)list):box=letlenv=Option.value(length~em:l.sizev)~default:0.inList.fold_left(fun(b:box)(property,value)->match(property,String.lowercase_asciivalue)with|"display","none"->{bwithdisplay=Hidden}|"display","block"whenb.display=Inline->{bwithdisplay=Block}|"display","inline"whenb.display=Block->{bwithdisplay=Inline}|"margin-top",v->{bwithmargin_top=lenv}|"margin-bottom",v->{bwithmargin_bottom=lenv}|"margin-left",v->{bwithindent=lenv}|"margin-right",v->{bwithright=lenv}|"margin",v->((* 1 to 4 values: top, right, bottom, left, the missing ones
* copied from their opposite *)matchList.maplen(String.split_on_char' 'v|>List.filter((<>)""))with|[a]->{bwithmargin_top=a;right=a;margin_bottom=a;indent=a}|[a;c]->{bwithmargin_top=a;right=c;margin_bottom=a;indent=c}|[a;c;d]->{bwithmargin_top=a;right=c;margin_bottom=d;indent=c}|[a;c;d;e]->{bwithmargin_top=a;right=c;margin_bottom=d;indent=e}|_->b)|("background-color"|"background"),_->(matchcolor_of_stringvaluewithSomec->{bwithbackground=Somec}|None->b)|_->b)bdeclarations