local C=require("occss") local unicode=require("unicode") local ulen,usub=unicode.len,unicode.sub local L={} local BLOCKISH={block=1,["list-item"]=1,flex=1,grid=1,table=1,["table-row"]=1,["table-cell"]=1, ["inline-flex"]=1,["inline-grid"]=1, ["table-row-group"]=1,["table-header-group"]=1,["table-footer-group"]=1,["flow-root"]=1} function L.viewport(cols) if cols>=110 then return math.max(cols*8,1280)end return math.max(cols*8,1024) end function L.layout(dom,cols,base,yieldFn) local vw=L.viewport(cols) local PX=vw/cols local ops,links,inputs,images,forms={},{},{},{},{} local steps=0 local maxRows=L.maxRows or 3000 local function tick()steps=steps+1 if yieldFn and steps%200==0 then yieldFn()end end local function cw(px)return px and math.floor(px/PX+0.5)or nil end local function rh(px)return px and math.floor(px/16+0.35)or 0 end local function len(st,prop,ref)return C.px(st[prop],ref,C.px(st["font-size"],nil,16))end local function hidden(st)return st.display=="none"or st.visibility=="hidden"or st.visibility=="collapse"end local function outOfFlow(st) return st.position=="absolute" end local function colorOf(v,bg) local c,a=C.color(v) if not c then return nil end if a and a<1 and bg then if a<0.15 then return nil end local function mix(sh)return math.floor(((c>>sh)&255)*a+((bg>>sh)&255)*(1-a)+0.5)end return mix(16)<<16|mix(8)<<8|mix(0) end return c end local function lum(c)return 0.2126*(c>>16)+0.7152*((c>>8)&255)+0.0722*(c&255)end local function readable(fg,bg) if not fg then fg=0 end if bg and math.abs(lum(fg)-lum(bg))<50 then return lum(bg)<128 and 0xFFFFFF or 0x000000 end return fg end local function maxContent(el,cap,depth) depth=depth or 0 if el.text then return math.min(cap,ulen((el.text:gsub("%s+"," "))))end local st=el.style if not st or hidden(st)or outOfFlow(st)or depth>30 then return 0 end local w=len(st,"width") if w then return math.min(cap,cw(w))end if el.tag=="img"then return math.min(cap,cw(C.px(el.attrs.width)or 64))end if el.tag=="input"then return math.min(cap,tonumber(el.attrs.size or"")or 16)end local pad=(cw(len(st,"padding-left")or 0)or 0)+(cw(len(st,"padding-right")or 0)or 0) if not BLOCKISH[st.display]then pad=pad+math.min(4,math.max(0,cw(len(st,"margin-left")or 0)or 0))+math.min(4,math.max(0,cw(len(st,"margin-right")or 0)or 0)) end local inlineSum,best=0,0 local row=st.display=="flex"and(st["flex-direction"]or"row"):sub(1,3)=="row" for _,c in ipairs(el.children)do local m=maxContent(c,cap,depth+1) if row and c.style and BLOCKISH[c.style.display]then m=m+math.max(0,cw(len(c.style,"margin-left")or 0)or 0)+math.max(0,cw(len(c.style,"margin-right")or 0)or 0) end if c.text or(c.style and not BLOCKISH[c.style.display])or row then inlineSum=inlineSum+m else best=math.max(best,inlineSum,m);inlineSum=0 end end return math.min(cap,math.max(best,inlineSum)+pad) end local function emitFill(x,y,w,h,rgb) if w<=0 or h<=0 or not rgb then return nil end local op={"fill",x,y,w,h,rgb} ops[#ops+1]=op return op end local function shiftFrom(i0,li0,ii0,dx,dy) for i=i0,#ops do local o=ops[i]o[2],o[3]=o[2]+dx,o[3]+dy end for i=li0,#links do local o=links[i]o.x,o.y=o.x+dx,o.y+dy end for i=ii0,#inputs do local o=inputs[i]o.x,o.y=o.x+dx,o.y+dy end end local layoutBox,flowChildren local function collectInline(node,items,ctx) tick() if node.text then local t=node.text local st=ctx.style if st["text-transform"]=="uppercase"then t=unicode.upper(t)elseif st["text-transform"]=="lowercase"then t=unicode.lower(t)end local pre=st["white-space"]=="pre"or st["white-space"]=="pre-wrap" if pre then local first=true for line in(t.."\n"):gmatch("([^\n]*)\n")do if not first then items[#items+1]={kind="br"}end first=false if line~=""then items[#items+1]={kind="word",text=line:gsub("\t"," "),fg=ctx.fg,bg=ctx.ibg,href=ctx.href}end end else for sp,word in t:gmatch("(%s*)(%S*)")do if sp~=""then items[#items+1]={kind="space",fg=ctx.fg,bg=ctx.ibg,href=ctx.href}end if word~=""then items[#items+1]={kind="word",text=word,fg=ctx.fg,bg=ctx.ibg,href=ctx.href}end end end return end local st=node.style if not st or hidden(st)or outOfFlow(st)then return end local tag=node.tag local fg=colorOf(st.color,ctx.bg)or ctx.fg local ibg=ctx.ibg local own=colorOf(st["background-color"],ctx.bg) if own and own~=ctx.bg then ibg=own end local sub={style=st,fg=fg,ibg=ibg,bg=ibg or ctx.bg,href=ctx.href,form=ctx.form} if tag=="a"and node.attrs.href then sub.href=node.attrs.href end if tag=="br"then items[#items+1]={kind="br"}return end if tag=="img"then local wpx=len(st,"width",ctx.wpx)or C.px(node.attrs.width) local hpx=len(st,"height")or C.px(node.attrs.height) local nat=ctx.natural and ctx.natural(base(node.attrs.src or"")) if nat and not wpx and not hpx then wpx,hpx=nat[1],nat[2]end if wpx and not hpx then hpx=nat and wpx*nat[2]/nat[1]or wpx/2 end if hpx and not wpx then wpx=nat and hpx*nat[1]/nat[2]or hpx*2 end wpx,hpx=wpx or 160,hpx or 96 local w=math.max(1,math.min(ctx.width,cw(wpx))) local h=math.max(1,math.floor(hpx*(w/math.max(1,cw(wpx)))/16+0.5)) if wpx<=24 and hpx<=24 then w,h=math.max(1,cw(wpx)),1 end items[#items+1]={kind="box",w=w,h=h,img=base(node.attrs.src or""),alt=node.attrs.alt or"",href=sub.href} return end if tag=="input"or tag=="button"or tag=="select"or tag=="textarea"then local typ=(node.attrs.type or(tag=="button"and"submit"or"text")):lower() if typ=="hidden"then return end local label,w if tag=="button"or typ=="submit"or typ=="button"or typ=="reset"then label=" "..((tag=="button"and require("ocdom").text(node)or node.attrs.value or"Submit"):gsub("%s+"," "):gsub("^ ",""):gsub(" $","")).." " w=ulen(label) items[#items+1]={kind="word",text=label,fg=colorOf(st.color)or 0,bg=colorOf(st["background-color"])or 0xE9E9ED, input={el=node,form=ctx.form,kind="submit"}} return elseif typ=="checkbox"or typ=="radio"then label=typ=="checkbox"and(node.attrs.checked and"[x]"or"[ ]")or(node.attrs.checked and"(•)"or"( )") items[#items+1]={kind="word",text=label,fg=ctx.fg,input={el=node,form=ctx.form,kind=typ}} return elseif tag=="select"then local first require("ocdom").walk(node,function(n)if not first and n.tag=="option"then first=n end end) label=(first and require("ocdom").text(first)or""):gsub("%s+"," ") label=" "..label.." ▾" else label=node.attrs.value or"" end w=cw(len(st,"width",ctx.wpx)or 0)or 0 if w<=0 then w=tonumber(node.attrs.size or"")or(tag=="textarea"and 30 or 18)end w=math.max(4,math.min(ctx.width,w)) local shown=label~=""and label or(""..(node.attrs.placeholder or"")) items[#items+1]={kind="word",text=usub(shown..string.rep(" ",w),1,w),fg=label~=""and 0x000000 or 0x808080, bg=colorOf(st["background-color"])or 0xFFFFFF,input={el=node,form=ctx.form,kind=tag=="select"and"select"or typ,w=w}} return end if tag=="form"then sub.form=node end local mL=cw(len(st,"margin-left")or 0)or 0 local mR=cw(len(st,"margin-right")or 0)or 0 if mL>0 then for _=1,math.min(mL,4)do items[#items+1]={kind="word",text=" ",fg=ctx.fg}end end local padL=st.display=="inline-block"and cw(len(st,"padding-left")or 0)or 0 local padR=st.display=="inline-block"and cw(len(st,"padding-right")or 0)or 0 if padL>0 and sub.ibg then items[#items+1]={kind="word",text=string.rep(" ",math.min(padL,3)),bg=sub.ibg,fg=fg,href=sub.href}end local function generated(which) local g=node.pseudo and node.pseudo[which] if g and g.text and g.text~=""then local gfg=colorOf(g.color,ctx.bg)or fg collectInline({text=g.text},items,{style=st,fg=gfg,ibg=sub.ibg,bg=sub.bg,href=sub.href}) end end generated("before") for _,c in ipairs(node.children)do if c.text or not BLOCKISH[c.style and c.style.display or"inline"]then collectInline(c,items,sub) else items[#items+1]={kind="block",el=c,ctx=sub}end end generated("after") if padR>0 and sub.ibg then items[#items+1]={kind="word",text=string.rep(" ",math.min(padR,3)),bg=sub.ibg,fg=fg,href=sub.href}end if mR>0 then for _=1,math.min(mR,4)do items[#items+1]={kind="word",text=" ",fg=ctx.fg}end end end local function flowInline(items,x,y,w,align,ctx) local lines,line,lw,lh={},{},0,1 local function finish() while#line>0 and line[#line].kind=="space"do lw=lw-1;line[#line]=nil end lines[#lines+1]={items=line,w=lw,h=lh} line,lw,lh={},0,1 end for _,it in ipairs(items)do if it.kind=="br"then finish() elseif it.kind=="space"then if#line>0 and line[#line].kind~="space"and lww and#line>0 then finish()end if it.kind=="word"and iw>w then local t=it.text while ulen(t)>w-lw do local room=math.max(1,w-lw) line[#line+1]={kind="word",text=usub(t,1,room),fg=it.fg,bg=it.bg,href=it.href} lw=lw+room t=usub(t,room+1) finish() end if t~=""then line[#line+1]={kind="word",text=t,fg=it.fg,bg=it.bg,href=it.href,input=it.input};lw=lw+ulen(t)end else line[#line+1]=it lw=lw+iw if it.kind=="box"then lh=math.max(lh,it.h)end end end end if#line>0 then finish()end local cy=y for _,ln in ipairs(lines)do local cx=x if align=="center"then cx=x+math.max(0,(w-ln.w)//2) elseif align=="right"or align=="end"then cx=x+math.max(0,w-ln.w)end local row=cy+ln.h-1 for _,it in ipairs(ln.items)do if it.kind=="box"then ops[#ops+1]={"img",cx,cy+ln.h-it.h,it.w,it.h,it.img,it.alt} images[#images+1]=ops[#ops] if it.href then links[#links+1]={x=cx,y=cy+ln.h-it.h,w=it.w,h=it.h,href=it.href}end cx=cx+it.w else local t=it.kind=="space"and" "or it.text local n=ulen(t) ops[#ops+1]={"text",cx,row,t,readable(it.fg,it.bg or ctx.bg),it.bg} if it.href then local l=links[#links] if l and l.href==it.href and l.y==row and l.x+l.w==cx then l.w=l.w+n else links[#links+1]={x=cx,y=row,w=n,h=1,href=it.href}end end if it.input then local inp=it.input inp.x,inp.y,inp.w,inp.h=cx,row,n,1 inputs[#inputs+1]=inp end cx=cx+n end end cy=cy+ln.h end return cy-y end local function blockMargins(st,ref) return rh(len(st,"margin-top",ref)or 0),rh(len(st,"margin-bottom",ref)or 0) end function flowChildren(el,x,y,w,ctx) local st=el.style local cy=y local prevMB=0 local first=true local pending={} local align=st["text-align"]or"left" local listN=0 local function flushInline() if#pending==0 then return end local has=false for _,it in ipairs(pending)do if it.kind~="space"then has=true break end end if has then cy=cy+(first and 0 or 0) cy=cy+flowInline(pending,x,cy,w,align,ctx) first=false prevMB=0 end pending={} end local floats={} local function flushFloats() if#floats==0 then return end local rowH,lx,rx=0,x,x+w local placed={} for _,f in ipairs(floats)do local fst=f.style local fw=cw(len(fst,"width",w*PX)or 0) if not fw or fw<=0 then fw=math.min(w,maxContent(f,w))end fw=math.min(fw,w) if lx+fw>rx then cy=cy+rowH;rowH,lx,rx=0,x,x+w end local fx if fst.float=="right"then rx=rx-fw;fx=rx else fx=lx;lx=lx+fw end local h=layoutBox(f,fx,cy,fw,ctx) rowH=math.max(rowH,h) end cy=cy+rowH floats={} first=false end for _,c in ipairs(el.children)do tick() if cy>maxRows then break end local cst=c.style if c.text then flushFloats() collectInline(c,pending,{style=st,fg=ctx.fg,bg=ctx.bg,href=ctx.href,form=ctx.form,width=w,wpx=w*PX,natural=ctx.natural}) elseif cst and not hidden(cst)and not outOfFlow(cst)then if cst.float=="left"or cst.float=="right"then flushInline() floats[#floats+1]=c elseif BLOCKISH[cst.display]then flushInline() flushFloats() if cst.display=="list-item"then listN=listN+1;c.listIndex=listN end local mt,mb=blockMargins(cst,w*PX) if not first then cy=cy+math.max(0,math.max(mt,prevMB)-prevMB)else cy=cy+mt end local h=layoutBox(c,x,cy,w,ctx) cy=cy+h+mb prevMB=mb first=false else collectInline(c,pending,{style=st,fg=ctx.fg,bg=ctx.bg,href=ctx.href,form=ctx.form,width=w,wpx=w*PX,natural=ctx.natural}) local nb={} for i=#pending,1,-1 do if pending[i].kind=="block"then table.insert(nb,1,table.remove(pending,i))end end if#nb>0 then flushInline() for _,b in ipairs(nb)do local h=layoutBox(b.el,x,cy,w,b.ctx) cy=cy+h end end end end end flushInline() flushFloats() return cy-y end local function trackWidths(spec,n,w,gap) local toks={} spec=spec:gsub("repeat%(%s*(%d+)%s*,%s*([^%)]+%)?[^%)]*)%)",function(k,v) return string.rep(v.." ",tonumber(k)) end) for t in spec:gsub("minmax%b()","1fr"):gmatch("%S+")do toks[#toks+1]=t end if#toks==0 then return nil end local free=w-gap*(#toks-1) local fixed,fr=0,0 local ws={} for i,t in ipairs(toks)do local f=t:match("^([%d%.]+)fr$") if f then fr=fr+tonumber(f);ws[i]={fr=tonumber(f)} elseif t=="auto"then fr=fr+1;ws[i]={fr=1} else local px=C.px(t,w*PX)ws[i]={w=px and cw(px)or 1};fixed=fixed+ws[i].w end end local out={} for i,t in ipairs(ws)do out[i]=t.w or math.max(1,math.floor((free-fixed)*t.fr/math.max(fr,1e-9)))end return out end local function rowLayout(el,items,x,y,w,ctx,opts) local st=el.style local gap=cw(C.px(st["column-gap"],w*PX)or 0)or 0 local rgap=rh(C.px(st["row-gap"],nil)or 0) local widths={} local grow={} for i,it in ipairs(items)do local ist=it.style or st local bw=it.style and(len(ist,"width",w*PX)or len(ist,"flex-basis",w*PX)) if opts.fixed and opts.fixed[i]then widths[i]=opts.fixed[i] elseif bw then widths[i]=math.max(1,cw(bw)) else local g=it.style and tonumber(ist["flex-grow"]or"")or 0 grow[i]=g widths[i]=(g>0 and opts.flex)and math.min(w,math.max(1,maxContent(it,w)//2))or math.max(1,maxContent(it,w)) end if it.style then local mx=len(it.style,"max-width",w*PX) if mx then widths[i]=math.min(widths[i],cw(mx))end if not(opts.fixed and opts.fixed[i])then local m=(cw(len(it.style,"margin-left",w*PX)or 0)or 0)+(cw(len(it.style,"margin-right",w*PX)or 0)or 0) widths[i]=math.min(w,widths[i]+math.max(0,m)) end end end local lines={{}} local used=0 for i=1,#items do local need=widths[i]+(#lines[#lines]>0 and gap or 0) if opts.wrap and#lines[#lines]>0 and used+need>w then lines[#lines+1]={};used=0;need=widths[i]end table.insert(lines[#lines],i) used=used+need end local cy=y for li,line in ipairs(lines)do local total=gap*(#line-1) for _,i in ipairs(line)do total=total+widths[i]end local free=w-total local gsum=0 for _,i in ipairs(line)do gsum=gsum+(grow[i]or 0)end local lw={} for _,i in ipairs(line)do lw[i]=widths[i]end if free>0 and gsum>0 and opts.flex then local left=free for k,i in ipairs(line)do local add=k==#line and left or math.floor(free*(grow[i]or 0)/gsum) if(grow[i]or 0)>0 then lw[i]=lw[i]+add;left=left-add end end free=0 elseif free<0 then local scale=(w-gap*(#line-1))/math.max(1,total-gap*(#line-1)) for _,i in ipairs(line)do lw[i]=math.max(1,math.floor(widths[i]*scale))end free=0 end local jc=st["justify-content"]or"flex-start" local cx,extra=x,0 if free>0 and opts.flex then if jc=="center"then cx=x+free//2 elseif jc=="flex-end"or jc=="end"or jc=="right"then cx=x+free elseif jc=="space-between"and#line>1 then extra=free/(#line-1) elseif jc=="space-around"or jc=="space-evenly"then extra=free/(#line+1);cx=x+math.floor(extra)end end local rowH=0 local placed={} local acc=0 for k,i in ipairs(line)do local it=items[i] local o0,l0,i0=#ops+1,#links+1,#inputs+1 local ix=cx+math.floor(acc) local h if it.text then h=flowInline((function()local t={}collectInline(it,t,{style=st,fg=ctx.fg,bg=ctx.bg,width=lw[i],wpx=lw[i]*PX,natural=ctx.natural})return t end)(), ix,cy,lw[i],"left",ctx) else local mt,mb=blockMargins(it.style,w*PX) local ml=math.max(0,cw(len(it.style,"margin-left",w*PX)or 0)or 0) local mr=math.max(0,cw(len(it.style,"margin-right",w*PX)or 0)or 0) if ml+mr>=lw[i]then ml,mr=0,0 end h=layoutBox(it,ix+ml,cy+mt,lw[i]-ml-mr,ctx,true)+mt+mb end placed[#placed+1]={it=it,h=h,o0=o0,l0=l0,i0=i0,x=ix,w=lw[i]} rowH=math.max(rowH,h) acc=acc+lw[i]+gap+extra end local ai=st["align-items"]or"stretch" for _,p in ipairs(placed)do if(ai=="center")and p.h0 then h=h+rowLayout(el,cells[r],cx,cy+h,cwid,sub,{fixed=f,gap=1}) end end end else h=flowChildren(el,cx,cy,cwid,sub) end local minH=rh(len(st,"min-height")or 0) local fixH=len(st,"height") local total=pt+h+pb if fixH and(bg or bgImg)then total=math.max(rh(fixH),1)elseif minH>total then total=minH end if border and not border:find("none")and(bg or bgImg)==nil and total>0 then local col=colorOf(border:match("(#%x+)")or border:match("(rgba?%b())")or"") if col and total>=2 and bwc>=3 then ops[#ops+1]={"text",bx,y+total,"└"..string.rep("─",bwc-2).."┘",col,nil} total=total+1 end end if bgOp then bgOp[5]=math.max(1,total)end if imgOp then imgOp[5]=math.max(1,total)end return total end local html=require("ocdom").find(dom,"html")or dom local body=require("ocdom").find(dom,"body")or html local pageBg=colorOf(body.style and body.style["background-color"])or colorOf(html.style and html.style["background-color"])or 0xFFFFFF local fg=colorOf(body.style and body.style.color)or 0x000000 local ctx={fg=fg,bg=pageBg,natural=L.natural} local h if body.style then local mt=rh(len(body.style,"margin-top",vw)or 0) h=mt+layoutBox(body,1,1+mt,cols,ctx) else h=flowChildren(html,1,1,cols,ctx) end return{ops=ops,links=links,inputs=inputs,images=images,height=math.max(1,h),bg=pageBg,fg=fg,cols=cols} end return L