-- luadraw_compile_tex.lua
-- date 2026/08/04
-- version 3.4
-- Copyright 2026 Patrick Fradin
-- This work may be distributed and/or modified under the
-- conditions of the LaTeX Project Public License.
-- The latest version of this license is in
--   https://www.ctan.org/license/lppl

-- ce module permet de compiler un texte en tex, convertir le fichier en flattened postscript,
-- de lire le contenu de ce fichier sous forme d'une liste de chemins (avec épaisseur en tête de chaque chemin, et instruction de remplissage à la fin), cette liste L peut être affichée avec la méthode g:Dcompiled_tex(anchor,L, options)
-- ces chemins peuvent aussi être traduits en chemins 3d (path3d) dans un plan donné et être affichés avec la méthode Dcompiled_tex3d(L, options), ou bien traduits en lignes polygonales 3d (on perd alors l'épaisseur et la commande de remplissage).

local ld = luadraw
local cpx, pt3d = ld.cpx, ld.pt3d
local Z = cpx.Z
local graph = ld.graph

local preamble = "\\documentclass[12pt]{article}\n"
local usepackage = "\\usepackage{amsmath,amssymb}\n\\usepackage{fourier}\n"
local pdflatexcmd = "pdflatex"
local pstoeditcmd = "pstoedit"
local pdf2pscmd = "pdf2ps"

function ld.compile_tex_default(param)
    param = param or {}
    preamble = param.preamble or "\\documentclass[12pt]{article}\n"
    usepackage = param.usepackage or "\\usepackage{amsmath,amssymb}\n\\usepackage{fourier}\n"
    pdflatexcmd = param.pdflatexcmd or "pdflatex"
    pstoeditcmd = param.pstoeditcmd or "pstoedit"
    pdf2pscmd = param.pdf2pscmd or "pdf2ps"
end

function ld.compile_tex(text,out, conv_stroke)
-- out est le nom du fichier crée, il ne doit comporter ni chemin, ni extension, il sera créé dans le dossier de travail de luadraw (nommé cachedir)
    local name = "tex2FlatPs"
    out = out or name
    local f = io.open(name..".tex","w")
    if f ~= nil then
        f:write(preamble)
        f:write(usepackage)
        f:write("\\pagestyle{empty}\n")
        f:write("\\begin{document}\n")
        f:write(text.."\n")
        f:write("\\end{document}\n")
        f:close()
        os.execute(pdflatexcmd.." -interaction=nonstopmode "..name..".tex "..name..".pdf" )
        os.execute(pdf2pscmd.." "..name..".pdf")
        os.execute(pstoeditcmd.." -dt -pta -f ps -psarg  -r2400 "..name..".ps "..out..".eps")
        os.remove(name..".tex")
        os.remove(name..".pdf")
        os.remove(name..".ps")
        os.remove(name..".log")
        os.rename(out..".eps", ld.cachedir..out..".eps")
        os.remove(name..".eps")
        os.remove(name..".aux")
    end
    return ld.read_compiled_tex(ld.cachedir..out..".eps",conv_stroke)
end

local tostrip = function(L, wd)
-- renvoie une bande centrée sur la ligne, d'une largeur égale à wd cm
    if #L <2 then return end
    local oldEpsilon = ld.epsilon
    ld.epsilon = 1e-10
    local ep = wd/2
    local i = cpx.I
    local a, b, c, v, u, w 
    local bord, dessus, ret = {}, {}, {}
    a = L[1]; b = L[2]
    while cpx.equal(b,a) do table.remove(L,1); b = L[2]end
    if b == nil then return end
    table.remove(L,1); table.remove(L,1)
    if a == L[#L] then
        a = (a+b)/2 
        table.insert(L,a)
    end
    v = i*cpx.normalize(b-a)
    bord = {a-ep*v, a+ep*v}; dessus = {bord[2]}
    table.insert(ret,bord[1])
    c = b; b = a; v = v/i
    for _,z in ipairs(L) do
        a = b; b = c; c = z; u = -v; v = cpx.normalize(c-b)
        if v == nil then 
            c = b; b = a; v =-u
        else
            w = cpx.normalize(u+v)
            if w == nil then
                bord = {b+ep*i*u, b-ep*i*u}
            else
                bord = ld.projO( bord,{b,w},u)
            end
        end
        --if bord ~= nil then
            table.insert(ret,bord[1]); table.insert(dessus,1,bord[2])
        --end
    end
    ld.epsion = oldEpsilon
    return ld.concat(ret,{c-ep*v*i, c+ep*v*i}, dessus)
end

local split = function(str, sep) -- split a string with sep
  local t = {}
  local start = 1
  while true do
    local i, j = string.find(str, sep, start, true)
    if not i then
      t[#t+1] = string.sub(str, start)
      break
    end
    t[#t+1] = string.sub(str, start, i - 1)
    start = j + 1
  end
  return t
end

 
function ld.read_compiled_tex(file, conv_stroke)
-- file est le nom complet (sans chemin sans extension) d'un fichier eps
-- créé par la fonction compile_tex() dans le dossier de travail de luadraw (nommé cachedir)
-- la fonction lit le contenu du fichier et renvoie une liste de chemins commençant par l'épaisseur de ligne et finissant la commande de remplissage.
-- si conv_stroke == true, les lignes sont changées en "bandes" 
    file = file or ld.cachedir.."tex2FlatPs.eps"
    --file = cachedir..file..".eps"
    conv_stroke = conv_stroke or false
    local f = io.open(file,"r")
    if f == nil then print('no file '..file..' found'); return end
    local str, lwd, lcap, ljoin -- string, linewidth, linecap, linejoin
    local capstyle = {["0"]="miter", ["1"]="round", ["2"]="bevel"}
    local joinstyle = {["0"]="butt", ["1"]="round", ["2"]="square"}
    local style = ""
    local lines = {}
    local ret = {}
    local x1,x2,y1,y2 = math.huge, -math.huge, math.huge, -math.huge
    local instruction = {["curveto"]="b", ["lineto"]="l",["moveto"]="m", ["closepath"]="cl"}

    local find_path = function()
        local find = false
        while (#lines ~= 0) and (not find) do
            str = table.remove(lines)
            if string.find(str,"setlinewidth") then
                lwd = string.match(str,"%d*%.%d*") -- pt
                lwd = tostring(tonumber(lwd)*10) --tostring(1+math.floor(tonumber(lwd)*10))
            elseif string.find(str,"setlinecap") then
                lcap = capstyle[string.match(str,"%d+")] -- 0 or 1 or 2
            elseif string.find(str,"setlinejoin") then
                ljoin = joinstyle[string.match(str,"%d+")] -- 0 or 1 or 2
            end
            find = (string.find(str,"newpath") ~= nil)
        end
        if find then
            str = table.remove(lines) -- next line
        end
        return find
    end
    
    local read_path = function()
        local finish = false
        style = table.concat({lwd,lcap,ljoin},"/")
        local cp = {style} --{lwd} -- current path begins with style
        local nb = 1
        local u, x, y, inst
        while not finish do
            for w in string.gmatch(str, "[%d%a]*[%.%d%a][%d%a]*") do
                u = tonumber(w)
                if u ~= nil then -- u is a number
                    if nb == 1 then x = u; nb = 2  
                    else 
                        y = u; table.insert(cp, Z(x,y)); nb = 1
                        if x < x1 then x1 = x end; if x > x2 then x2 = x end
                        if y < y1 then y1 = y end; if y > y2 then y2 = y end
                    end
                else -- w is a string
                    inst = instruction[w]
                    if inst ~= nil then table.insert(cp,inst) end
                    finish = (w =='eofill') or (w =='stroke') or (w =='fill') or (w =='clip')
                    if finish then table.insert(cp,w) end
                end
            end
            if not finish then
                str = table.remove(lines) -- next line
                finish = (#lines == 0)
            end
        end
        table.insert(ret,cp)
    end

    for line in io.lines(file) do
        table.insert(lines,1,line)
    end
    while find_path() do
        read_path()
    end
    local c = Z(x1+x2,y1+y2)/2 --center
    local ratio = 2.54/72
    local mat = ld.matrixof( function(z) return (z-c)*ratio end )
    local rep =  {}
    rep.path = {}
    ret = ld.mtransform(ret,mat)
    for _, cp in ipairs(ret) do --we change line to strip if conv_stroke = true
        local ends = cp[#cp]
        if (ends == "stroke") and conv_stroke then -- we have a stroked path, we transform it into a strip
            style = table.remove(cp,1)
            lwd, lcap, ljoin = table.unpack( split(style,"/"))
            local char = table.remove(cp,2) -- remove "m"
            table.remove(cp) -- remove "stroke"
            local instr = table.remove(cp) -- instruction
            local ep = tonumber(lwd)/10*ld.pt
            if (#cp == 2) and cpx.equal(cp[1],cp[2]) then --circle
                cp = circleb(cp[1],ep/2)
            else
                table.insert(cp,instr)
                cp = ld.path(cp)[1]
                cp = tostrip(cp,ep)
                if cp ~= nil then
                    table.insert(cp,"l"); table.insert(cp,"cl")
                end
            end
            if cp ~= nil then
                table.insert(cp,2,char)
                table.insert(cp,1,style)--table.insert(cp,1,lwd)
                table.insert(cp,"fill")
            end
        end
        table.insert(rep.path,cp)
    end
    rep.bb =  table.pack(ld.getbounds(ld.mtransform({ Z(x1,y1),Z(x2,y2)},mat) ))
    return rep
end

local splitLongSeg = function(L) -- to divide segments that are too long
    -- L is a list of list of complex numbers
    if L == nil then return end
    local ret, cp, B, A = {}
    for _, lg in ipairs(L) do
        B = lg[1]; cp = {}
        for k = 2, #lg do
            A = B; B = lg[k]
            local len = cpx.abs(B-A)
            local nb = math.floor(10*len)
            local aux = ld.linspace(A,B,nb+2) 
            if k > 2 then table.remove(aux,1) end
            table.append(cp, aux )
        end
        table.insert(ret,cp)
    end
    return ret
end

function ld.compiled_tex2polyline(L,scale)
-- L est le résultat de compile_tex()
    if L == nil then return end
    scale = scale or 1
    local scx, scy, P = 1, 1, L.path
    if scale ~= 1 then
        if type(scale) == "number" then scx = scale; scy = scale 
        else scx, scy = table.unpack(scale)
        end
        local c = cpx.isobar(L.bb)
        local mat = {c+Z(c.re*(1-scx),c.im*(1-scy)),Z(scx,0),scy*cpx.I} 
        P = ld.mtransform(P, mat)
    end
    local ret = {}
    for _, cp in ipairs(P) do
        local p = table.copy(cp) -- L must not be modified
        local style = table.remove(p,1)
        local cmd = table.remove(p) -- We remove the beginning and the end, leaving only the path
        local C1 = splitLongSeg( ld.path(p) )
        table.append(ret,C1)
    end
    return ret -- list of list of complex numbers
end

function graph:Dcompiled_tex(anchor, L, options) -- or graph:Dcompiled_tex(L, anchor, options)
--L est le résultat de la fonction read_compiled_tex(filename)
--options : {scale=1, color= default, dir=nil, hollow=false, drawbox = false, draw_options=""}
-- scale = scale number or table {scalex, scaley}
-- dir = {vector1, vector2}
-- drawbox= false
-- draw_options=""
    if (type(L) == "number") or isComplex(L) then anchor, L = L, anchor end
    anchor = anchor or 0
    anchor = cpx.toComplex(anchor)
    options = options or {}
    options.color = options.color or self.param.linecolor
    options.scale = options.scale or 1
    options.drawbox = options.drawbox or false
    options.hollow = options.hollow or false
    options.draw_options= options.draw_options or ""
    options.pos = options.pos or "center"
    if L == nil then return end
    local style, lwd, lcap, ljoin, cmd, scx, scy
    
    local oldfillstyle = self.param.fillstyle
    local oldfillcolor = self.param.fillcolor
    local oldfilleo = self.param.filleo    
    local oldlinestyle = self.param.linestyle
    local oldlinecolor = self.param.linecolor
    local oldlinewidth = self.param.linewidth
    local oldlinecap = self.param.linecap
    local oldlinejoin = self.param.linejoin

    
    if type(options.scale) == "number" then 
        scx = options.scale; scy = options.scale
    else
        scx, scy = table.unpack(options.scale)
    end
    local x1,x2,y1,y2 = table.unpack(L.bb)
    local c, tx, ty, t = Z(x1+x2,y1+y2)/2, (x2-x1)/2, (y2-y1)/2, nil
    if options.pos == "center" then
        t = nil
    elseif options.pos == "S" then
        t = Z(0,-ty)
    elseif options.pos == "SW" then
        t = Z(-tx,-ty)
    elseif options.pos == "W" then
        t = Z(-tx,0)
    elseif options.pos == "NW" then
        t = Z(-tx,ty)
    elseif options.pos == "N" then
        t = Z(0,ty)
    elseif options.pos == "NE" then
        t = Z(tx,ty)
    elseif options.pos == "E" then
        t = Z(tx,0)
    elseif options.pos == "SE" then
        t = Z(tx,-ty)
    end
    local mat = {anchor+Z(c.re*(1-scx),c.im*(1-scy)),Z(scx,0),scy*cpx.I} 
    local u, v
    if options.dir ~= nil then
        u, v = table.unpack(options.dir)
        u = u/cpx.abs(u); v = v/cpx.abs(v)
        mat = ld.composematrix(mat,{0,u,v})
    end
    if t ~= nil then mat = composematrix(mat,{t,1,cpx.I}) end
    local C = ld.mtransform(L.path,mat)
    local bb = ld.mtransform( {Z(x1,y1),Z(x2,y1),Z(x2,y2),Z(x1,y2)}, mat) -- boite englobante
     for _, p in ipairs(C) do
        style = table.remove(p,1)
        lwd, lcap, ljoin = table.unpack(split(style,"/"))
        cmd = table.remove(p)
        self:Linecap(lcap); self:Linejoin(ljoin)
        if (cmd == "eofill") or (cmd == "fill") then
            if options.hollow then
                self:Lineoptions("solid",options.color,oldlinewidth); self:Filloptions("none")
            else
                self:Lineoptions("noline"); self:Filloptions("full",options.color,1,(cmd == "eofill") )
            end
            self:Dpath(p,options.draw_options)
        elseif cmd == "stroke" then
            self:Lineoptions("solid",options.color,tonumber(lwd)*scx); self:Filloptions("none",nil,nil,false)
            self:Dpath(p,options.draw_options)
        end
    end
    self:Filloptions(oldfillstyle,oldfillcolor, nil, oldfilleo)
    self:Lineoptions(oldlinestyle,oldlinecolor,oldlinewidth); 
    self:Linecap(oldlinecap); self:Linejoin(oldlinejoin)
    if options.drawbox then
        self:Dpolyline(bb,true)
    end
end

-----------------------------------------------------
--- partie pour la 3d uniquement --------------------
-----------------------------------------------------

local splitLongSeg3d = function(L) -- to divide segments that are too long
    -- L is a list of list of 3d points
    local ret, cp, B, A = {}
    if L == nil then return end
    for _, lg in ipairs(L) do
        B = lg[1]; cp = {}
        for k = 2, #lg do
            A = B; B = lg[k]
            local len = pt3d.abs(B-A)
            local nb = math.floor(10*len)
            table.append(cp, ld.linspace(A,B,nb+2) )
        end
        table.insert(ret,cp)
    end
    return ret
end


function graph:Compiled_tex2path3d(L,options)
-- L est le résultat de la fonction read_compiled_tex(filename)
-- la fonction convertit les chemins en chemins 3d et renvoie le résultat, celui-ci peut être dessiné avec la méthode g:Dcompiled_tex3d() 
-- options = {anchor=Origin, scale = 1, dir = {vecJ,vecK}, polyline=false, pos="center"}
    options= options or {}
    options.scale= options.scale or 1
    options.anchor= options.anchor or Origin
    options.polyline= options.polyline or false
    options.dir= options.dir or {vecJ,vecK}
    options.pos = options.pos or "center"
    local plane = {options.anchor, pt3d.prod(options.dir[1], options.dir[2])}
    local scx, scy, x1,x2,y1,y2
    if L == nil  then return end
    if type(options.scale) == "number" then 
        scx = options.scale; scy = options.scale
    else
        scx, scy = table.unpack(options.scale)
    end
    x1,x2,y1,y2 = table.unpack(L.bb)
        local c, tx, ty, t = Z(x1+x2,y1+y2)/2, (x2-x1)/2, (y2-y1)/2, Z(0,0)
    if options.pos == "center" then
        t = Z(0,0)
    elseif options.pos == "S" then
        t = Z(0,-ty); y1 = y1-ty; y2 = y2-ty
    elseif options.pos == "SW" then
        t = Z(-tx,-ty); y1 = y1-ty; y2 = y2-ty; x1 = x1-tx; x2 = x2-tx
    elseif options.pos == "W" then
        t = Z(-tx,0); x1 = x1-tx; x2 = x2-tx
    elseif options.pos == "NW" then
        t = Z(-tx,ty); x1 = x1-tx; x2 = x2-tx; y1 = y1+ty; y2 = y2+ty
    elseif options.pos == "N" then
        t = Z(0,ty); y1 = y1+ty; y2 = y2+ty
    elseif options.pos == "NE" then
        t = Z(tx,ty); y1 = y1+ty; y2 = y2+ty; x1 = x1+tx; x2 = x2+tx
    elseif options.pos == "E" then
        t = Z(tx,0); x1 = x1+tx; x2 = x2+tx
    elseif options.pos == "SE" then
        t = Z(tx,-ty); x1 = x1+tx; x2 = x2+tx; y1 = y1-ty; y2 = y2-ty
    end
    local A = options.anchor
    local u = pt3d.normalize(options.dir[1])
    local v = pt3d.normalize(options.dir[2])
    local mat = {-c+t,Z(1,0),cpx.I} 
    local C = ld.mtransform(L.path,mat)
    local f = function(z)
        if type(z)=="string" then return z else return A+scx*z.re*u+scy*z.im*v end
    end
    local ret = {}
    if not options.polyline then
        ret.bb = ld.ftransform({Z(x1,y1),Z(x2,y1),Z(x2,y2),Z(x1,y2),"l","cl"},f) -- boite englobante
        for _,p in ipairs(C) do
            local lwd, lcap, ljoin = table.unpack( split(p[1],"/") )
            lwd = tonumber(p[1])*pt
            lwd = self:Abs(self:Proj3dV(lwd*v))*mm
            p[1] = table.concat( {tostring( lwd*scx ), lcap, ljoin}, "/") -- line width adaptation
        end
        ret.path = ld.ftransform(C,f)
        return ret
    else
        for _,p in ipairs(C) do
            local lwd = table.remove(p,1)
            local cmd = table.remove(p) -- We remove the beginning and the end, leaving only the path
            local C1 = splitLongSeg3d( ld.path3d( ld.ftransform(p,f) ) )
            table.append(ret,C1)
        end
        return ret -- list of list of 3d points
    end
end

function graph:Dcompiled_tex3d(L, options)
--L est le résultat de la méthode g:Conv_compiled_tex3d(filename,options)
--options : {color= default, hollow=false, drawbox=false, draw_options=""}
    options = options or {}
    options.draw_options= options.draw_options or ""
    options.color = options.color or self.param.linecolor
    options.drawbox = options.drawbox or false
    options.hollow = options.hollow or false
    local style, lwd, ljoin, lcap, cmd
    if L == nil then return end
    local oldfillstyle = self.param.fillstyle
    local oldfillcolor = self.param.fillcolor
    local oldfilleo = self.param.filleo    
    local oldlinestyle = self.param.linestyle
    local oldlinecolor = self.param.linecolor
    local oldlinewidth = self.param.linewidth
    local oldlinecap = self.param.linecap
    local oldlinejoin = self.param.linejoin
    for _, p in ipairs(L.path) do
        style = table.remove(p,1)
        lwd, lcap, ljoin = table.unpack(split(style,"/"))
        cmd = table.remove(p)
        self:Linecap(lcap); self:Linejoin(ljoin)
        if (cmd == "eofill") or (cmd == "fill") then
            if options.hollow then
                self:Lineoptions("solid",options.color,oldlinewidth); self:Filloptions("none")
            else
                self:Lineoptions("noline"); self:Filloptions("full",options.color,1,(cmd == "eofill") )
            end
            self:Dpath3d(p,options.draw_options)
        elseif cmd == "stroke" then
            self:Lineoptions("solid",options.color,lwd); self:Filloptions("none")
            self:Dpath3d(p,options.draw_options)
        end
    end
    self:Filloptions(oldfillstyle,oldfillcolor, nil, oldfilleo)
    self:Lineoptions(oldlinestyle,oldlinecolor,oldlinewidth); 
    self:Linecap(oldlinecap); self:Linejoin(oldlinejoin)
    if options.drawbox then
        self:Dpath3d(L.bb)
    end
end
