--[[ Package: ansiterm Date: 2026-08-26 Version: v0.2.1 ]] --<*lua> local M = {} local lfs = require("lfs") local md5 = require("md5") local function read(path, binary) local f, err = io.open(path, binary and "rb" or "r") if not f then error("ansiterm: " .. err) end local s = f:read("*a"); f:close(); return s end local function write(path, value, binary) local f, err = io.open(path, binary and "wb" or "w") if not f then error("ansiterm: " .. err) end f:write(value); f:close() end local function quote(s) return "'" .. tostring(s):gsub("'", "'\\''") .. "'" end local function mkdir(path) lfs.mkdir(path) end local function status_code(ok, why, code) if type(ok) == "number" then return ok end if ok then return 0 end return tonumber(code) or 1 end local function erase_tree(path) for name in lfs.dir(path) do if name ~= "." and name ~= ".." then local p = path .. "/" .. name if lfs.attributes(p, "mode") == "directory" then erase_tree(p) else os.remove(p) end end end lfs.rmdir(path) end local function normalise_controls(s) s = s:gsub("\r\n", "\n") local out = {} for line, nl in s:gmatch("([^\n]*)(\n?)") do local cells, cursor = {}, 1 local i = 1 while i <= #line do local c = line:sub(i,i) if c == "\r" then cursor = 1 elseif c == "\b" then cursor = math.max(1, cursor - 1) else cells[cursor] = c; cursor = cursor + 1 end i = i + 1 end out[#out+1] = table.concat(cells) .. nl if nl == "" then break end end return table.concat(out) end local function dedent(s) s=s:gsub("\r\n","\n") local margin for line in (s.."\n"):gmatch("(.-)\n") do if line:find("%S") then local indent=#(line:match("^[ \t]*") or "") margin=margin and math.min(margin,indent) or indent end end if not margin or margin==0 then return s end local out={} for line,nl in s:gmatch("([^\n]*)(\n?)") do out[#out+1]=line:sub(margin+1)..nl if nl=="" then break end end return table.concat(out) end local palette = { [0]={0,0,0}, {205,49,49}, {13,188,121}, {229,229,16}, {36,114,200}, {188,63,188}, {17,168,205}, {229,229,229}, {102,102,102}, {241,76,76}, {35,209,139}, {245,245,67}, {59,142,234}, {214,112,214}, {41,184,219}, {255,255,255} } local function rgb256(n) if n < 16 then return table.unpack(palette[n]) end if n < 232 then n = n - 16; local r=math.floor(n/36); local g=math.floor(n%36/6); local b=n%6 local function v(x) return x == 0 and 0 or 55 + 40*x end return v(r),v(g),v(b) end local v=8+10*(n-232); return v,v,v end local function colour(rgb) return rgb and string.format("%d,%d,%d", rgb[1],rgb[2],rgb[3]) end local function clone(s) local t={}; for k,v in pairs(s) do t[k]=type(v)=="table" and {table.unpack(v)} or v end; return t end local function sgr(state, raw) local p={}; raw=(raw=="" and "0" or raw) for n in raw:gmatch("[^;:]+") do p[#p+1]=tonumber(n) or 0 end local i=1 while i<=#p do local n=p[i] if n==0 then state={} elseif n==1 then state.bold=true elseif n==2 then state.faint=true elseif n==3 then state.italic=true elseif n==4 then state.underline=true elseif n==7 then state.inverse=true elseif n==8 then state.conceal=true elseif n==9 then state.strike=true elseif n==22 then state.bold=nil;state.faint=nil elseif n==23 then state.italic=nil elseif n==24 then state.underline=nil elseif n==27 then state.inverse=nil elseif n==28 then state.conceal=nil elseif n==29 then state.strike=nil elseif n==39 then state.fg=nil elseif n==49 then state.bg=nil elseif n>=30 and n<=37 then state.fg={table.unpack(palette[n-30])} elseif n>=40 and n<=47 then state.bg={table.unpack(palette[n-40])} elseif n>=90 and n<=97 then state.fg={table.unpack(palette[n-90+8])} elseif n>=100 and n<=107 then state.bg={table.unpack(palette[n-100+8])} elseif (n==38 or n==48) and p[i+1]==5 and p[i+2] then local r,g,b=rgb256(p[i+2]); state[n==38 and "fg" or "bg"]={r,g,b}; i=i+2 elseif (n==38 or n==48) and p[i+1]==2 and p[i+4] then state[n==38 and "fg" or "bg"]={p[i+2],p[i+3],p[i+4]}; i=i+4 end i=i+1 end return state end local function tex_escape(s) local map={['\\']='\\textbackslash{}',['{']='\\{',['}']='\\}',['#']='\\#', ['$']='\\$',['%']='\\%',['&']='\\&',['^']='\\textasciicircum{}', ['_']='\\_',['~']='\\textasciitilde{}',[' ']='\\ '} return map[s] or s end local function styled(text,state) if text=="" then return "" end local fg,bg=state.fg,state.bg if state.inverse then fg,bg=bg or {248,248,248},fg or {30,30,30} end local out={} for _,codepoint in utf8.codes(text) do local x if codepoint==10 then out[#out+1]="\n" x="" elseif codepoint==9 then x=string.rep("\\ ",4) elseif codepoint<32 or codepoint==127 then x="" else x=tex_escape(state.conceal and " " or utf8.char(codepoint)) end if x~="" then if fg then x="\\textcolor[RGB]{"..colour(fg).."}{"..x.."}" end if bg then x="\\colorbox[RGB]{"..colour(bg).."}{"..x.."}" end if state.bold then x="\\textbf{"..x.."}" end if state.italic then x="\\textit{"..x.."}" end if state.underline then x="\\underline{"..x.."}" end if state.strike then x="\\ansitermstrike{"..x.."}" end if state.faint then x="\\textcolor{black!55}{"..x.."}" end out[#out+1]=x.."\\allowbreak{}" end end return table.concat(out) end local function render_ansi(s) local result,state,pos={}, {},1 local function emit(last) if last>=pos then result[#result+1]=styled(s:sub(pos,last),state) end end while pos<=#s do local esc=s:find("\27",pos,true) if not esc then emit(#s); break end emit(esc-1) local introducer=s:sub(esc+1,esc+1) if introducer=="[" then -- A CSI sequence ends in a byte from 0x40 through 0x7e. SGR (final -- byte "m") affects subsequent text; cursor/erasure commands such as -- grep's ESC[K have no useful static-page equivalent and are ignored. local final=esc+2 while final<=#s do local byte=s:byte(final) if byte>=0x40 and byte<=0x7e then break end final=final+1 end if final>#s then break end if s:sub(final,final)=="m" then local params=s:sub(esc+2,final-1) if params:match("^[0-9;:]*$") then state=sgr(clone(state),params) end end pos=final+1 elseif introducer=="]" then -- OSC strings terminate with BEL or ST (ESC backslash). They carry -- terminal metadata such as a window title, not printable content. local i=esc+2 while i<=#s and s:byte(i)~=7 and not (s:byte(i)==27 and s:sub(i+1,i+1)=="\\") do i=i+1 end if i>#s then break end pos=(s:byte(i)==7) and i+1 or i+2 else -- Other two-byte escape sequences likewise describe terminal state. pos=math.min(#s+1,esc+2) end end return table.concat(result) end -- Records keep a per-line "input" (echoed prompt/command) vs "output" -- (actual command output) kind alongside the text, so that kind survives -- line selection and can drive the tagged Div grouping in emit\_runs. local function lines_of(s,kind) local out={}; s=s:gsub("\n$","") for line in (s.."\n"):gmatch("(.-)\n") do out[#out+1]={kind=kind,text=line} end return out end local trace_marker="__ANSITERM_INPUT_2A2F0B__" -- Shell tracing writes a uniquely marked command immediately before that -- command's output. Converting the merged stream preserves this order and, -- unlike echoing the saved source, also handles compound shell constructs. local function transcript_records(s,script,prompt) local source={} for line in (script:gsub("\n$","").."\n"):gmatch("(.-)\n") do if line:find("%S") and not line:match("^%s*#") then source[#source+1]=line end end local records,next_source={},1 for line in (normalise_controls(s):gsub("\n$","").."\n"):gmatch("(.-)\n") do if line:sub(1,#trace_marker)==trace_marker then if source[next_source] then records[#records+1]={kind="input",text=prompt..source[next_source]} next_source=next_source+1 end else records[#records+1]={kind="output",text=line} end end return records end local function select_records(records,spec,ellipsis) local selected={} for part in spec:gmatch("[^,%s]+") do local a,b=part:match("^(%d*)%-(%d*)$") if not a then a=part; b=part end a=tonumber(a) or 1; b=tonumber(b) or #records for i=math.max(1,a),math.min(#records,b) do selected[i]=true end end local out,last={},false for i,rec in ipairs(records) do if selected[i] then out[#out+1]=rec; last=true elseif last and ellipsis then out[#out+1]={kind="ellipsis",text="\27[2m ⋮\27[0m"}; last=false else last=false end end return out end -- Groups consecutive same-kind records into one rendered run each and -- wraps input/output runs in the matching \cs{ansitermtag...} macro (defined -- in ansiterm.dtx); an ellipsis run is emitted bare, it is neither. local function emit_runs(records) local macro={input="\\ansitermtaginput",output="\\ansitermtagoutput"} local out,i={},1 while i<=#records do local kind=records[i].kind local buf={} while i<=#records and records[i].kind==kind do buf[#buf+1]=records[i].text; i=i+1 end local body=render_ansi(table.concat(buf,"\n")).."\\par\n" if macro[kind] then out[#out+1]=macro[kind].."{%\n"..body.."}\n" else out[#out+1]=body end end return table.concat(out) end local function introduce_escape(s,marker) if not marker or marker=="" then return s end local out,pos={},1 while pos<=#s do local first,last=s:find(marker,pos,true) if not first then out[#out+1]=s:sub(pos); break end out[#out+1]=s:sub(pos,first-1) local next_first,next_last=s:find(marker,last+1,true) if next_first==last+1 then out[#out+1]=marker; pos=next_last+1 else out[#out+1]="\27"; pos=last+1 end end return table.concat(out) end function M.render(o) local source=dedent(read(o.source,true)) source=introduce_escape(source,o.escape_char) local tex="\\providecommand\\ansitermstrike[1]{#1}\n" .."\\raggedright\\sloppy\\obeylines\\parindent=0pt\n" ..render_ansi(normalise_controls(source)).."\\par\n" write(o.output,tex) end function M.run(o) mkdir(o.cache_dir) local script=dedent(read(o.source,true)) local key=md5.sumhexa(table.concat({"3",script,o.shell,o.cwd,tostring(o.sandbox),tostring(o.show_input),o.prompt},"\0")) local cached=o.cache_dir.."/"..key..".out" local meta=o.cache_dir.."/"..key..".status" local output,status if o.cache=="true" and lfs.attributes(cached) and lfs.attributes(meta) then output=read(cached,true); status=tonumber(read(meta)) or 1 else local work=o.cwd if o.sandbox then work=o.cache_dir.."/.run-"..key.."-"..tostring(math.random(100000,999999)); assert(lfs.mkdir(work)) end local raw=o.cache_dir.."/.output-"..key local runnable=o.cache_dir.."/.script-"..key write(runnable,script,true) local tracing=o.show_input and "PS4="..quote(trace_marker).." " or "" local option=o.show_input and " -x" or "" local command="cd "..quote(work).." && "..tracing..quote(o.shell)..option.." "..quote(lfs.currentdir().."/"..runnable).." >"..quote(lfs.currentdir().."/"..raw).." 2>&1" status=status_code(os.execute(command)); output=read(raw,true); os.remove(raw) os.remove(runnable) if o.sandbox then erase_tree(work) end if o.cache~="false" then write(cached,output,true); write(meta,tostring(status)) end end local records if o.show_input then records=transcript_records(output,script,o.prompt) else records=lines_of(normalise_controls(output),"output") end records=select_records(records,o.lines,o.ellipsis) local tex="\\providecommand\\ansitermstrike[1]{#1}\n" .."\\global\\def\\ansitermstatus{"..status.."}\n\\raggedright\\sloppy\\obeylines\\parindent=0pt\n" ..emit_runs(records) write(o.output,tex) end return M --