% ltmermaid.sty -- Embed Mermaid diagrams in LaTeX (LuaLaTeX).
%
% Copyright (C) 2026 Ryoya Ando (https://ryoya9826.github.io/)
%
% This work may be distributed and/or modified under the
% conditions of the LaTeX Project Public License, either version 1.3c
% of this license or (at your option) any later version.
% The latest version of this license is in
%   https://www.latex-project.org/lppl.txt
% and version 1.3c or later is part of all distributions of LaTeX
% version 2008/05/04 or later.
\NeedsTeXFormat{LaTeX2e}
\ProvidesPackage{ltmermaid}[2026/04/16 v1.0 Mermaid via Mermaid CLI / PDF]
\RequirePackage{ifluatex}
\ifluatex\else
  \PackageError{ltmermaid}{This package requires LuaLaTeX (lualatex).}{}
  \expandafter\endinput
\fi
\RequirePackage{graphicx}
\RequirePackage{adjustbox}
\RequirePackage{fancyvrb}
\RequirePackage{luacode}
\RequirePackage{kvoptions}
\SetupKeyvalOptions{family=mermaid,prefix=mermaid@,setkeys=\kvsetkeys}
\DeclareStringOption[]{Renderer}
\ProcessKeyvalOptions*
\begin{luacode*}
mermaid_cli = mermaid_cli or {}
mermaid_cli.default_renderer = "mmdc"
local lfs = require("lfs")
local function shq(p)
  return "'" .. string.gsub(p, "'", "'\\''") .. "'"
end
local function abspath(rel)
  rel = (rel or ""):gsub("\\", "/")
  if rel == "" then return rel end
  if string.match(rel, "^/") or string.match(rel, "^[A-Za-z]:[\\/]") then
    return rel
  end
  local cwd = (lfs.currentdir()):gsub("\\", "/")
  if string.sub(cwd, -1) == "/" then
    return cwd .. rel
  end
  return cwd .. "/" .. rel
end
local function ensure_dir(path)
  path = path:gsub("\\", "/")
  local attrs = lfs.attributes(path)
  if attrs and attrs.mode == "directory" then
    return
  end
  local parent = path:match("^(.*)/[^/]+$")
  if parent and parent ~= "" then
    ensure_dir(parent)
  end
  local ok, err = lfs.mkdir(path)
  if not ok and lfs.attributes(path, "mode") ~= "directory" then
    tex.error("ltmermaid: cannot create directory (" .. tostring(err) .. "): " .. path)
  end
end
local function mermaid_root_abs()
  local out = os.getenv("TEXMF_OUTPUT_DIRECTORY")
  if out and out ~= "" then
    out = out:gsub("\\", "/"):gsub("/$", "")
    if not string.match(out, "^/") and not string.match(out, "^[A-Za-z]:[\\/]") then
      out = abspath(out)
    end
    return out .. "/mermaid"
  end
  return abspath("mermaid")
end
function mermaid_cli.ensure_mermaid_root()
  if mermaid_cli._root_ready then
    return
  end
  ensure_dir(mermaid_root_abs())
  mermaid_cli._root_ready = true
end
function mermaid_cli.path_for_diag(n, ext)
  mermaid_cli.ensure_mermaid_root()
  n = tonumber(n)
  local name = tex.jobname .. "-mermaid-" .. tostring(n) .. "." .. ext
  return "mermaid/" .. name
end
function mermaid_cli.resolve_path(rel)
  rel = (rel or ""):gsub("\\", "/")
  local fname = rel:match("^mermaid/(.+)$") or rel
  return mermaid_root_abs() .. "/" .. fname
end
local function resolve_renderer(tex_cmd)
  if tex_cmd ~= nil and tex_cmd ~= "" then
    return tex_cmd
  end
  return mermaid_cli.default_renderer
end
local function resolve_extra_args(tex_args)
  if tex_args ~= nil and tex_args ~= "" then
    return tex_args
  end
  return ""
end
local function interpret_execute_result(a, b, c)
  if a == nil then
    return "spawn_failed", nil
  end
  if a == true then
    if b == "exit" and type(c) == "number" then
      if c == 0 then
        return "ok", 0
      end
      return "exit", c
    end
    return "ok", 0
  end
  if a == false then
    if b == "exit" and type(c) == "number" then
      return "exit", c
    end
    return "exit", c or 1
  end
  if type(a) == "number" then
    if a == 0 then
      return "ok", 0
    end
    local exitcode = math.floor(a / 256) % 256
    if exitcode == 0 then
      exitcode = a % 256
    end
    return "exit", exitcode
  end
  return "unknown", nil
end
local function mermaid_log(msg)
  local line = "[ltmermaid] " .. msg
  texio.write_nl("log", line)
  texio.write_nl("term", line)
end
local function file_size_bytes(path)
  local sz = lfs.attributes(path, "size")
  if type(sz) == "number" then
    return tostring(sz)
  end
  return "(missing)"
end
local function diagram_index_from_rel(rel)
  rel = (rel or ""):gsub("\\", "/")
  return rel:match("%-mermaid%-(%d+)%.")
end
local function renderer_uses_npx(renderer)
  if not renderer or renderer == "" then
    return false
  end
  renderer = renderer:match("^%s*(.-)%s*$") or ""
  if string.match(renderer, "^npx%s") then
    return true
  end
  if string.find(renderer, "%snpx%s", 1, true) then
    return true
  end
  return false
end
local function output_pdf_missing(renderer, out_abs)
  local msg = "ltmermaid: PDF was not produced: " .. out_abs
  if renderer_uses_npx(renderer) then
    msg = msg .. " (possible npx -y fetch failure, network issue, or missing Chromium setup)."
  else
    msg = msg .. " (check mmdc failure, Chromium setup, or Mermaid syntax errors)."
  end
  tex.error(msg)
end
local function verify_pdf(path)
  local f, err = io.open(path, "rb")
  if not f then
    tex.error("ltmermaid: cannot open PDF (" .. tostring(err) .. "): " .. path)
    return
  end
  local head = f:read(5)
  f:close()
  if not head or string.len(head) < 4 or string.sub(head, 1, 4) ~= "%PDF" then
    tex.error("ltmermaid: PDF is invalid or corrupt (check mmdc and Mermaid syntax). Path: " .. path)
    return
  end
end
function mermaid_cli.run(inf, outf, tex_cmd, tex_xargs, tex_pdffit)
  local renderer = resolve_renderer(tex_cmd)
  local xargs = resolve_extra_args(tex_xargs)
  local fit = (tex_pdffit or ""):gsub("^%s+", ""):gsub("%s+$", "")
  if fit ~= "" then
    if string.match(xargs or "", "%S") then
      xargs = fit .. " " .. xargs
    else
      xargs = fit
    end
  end
  local inf_abs = mermaid_cli.resolve_path(inf)
  local out_abs = mermaid_cli.resolve_path(outf)
  local cmd = renderer
  if string.match(xargs or "", "%S") then
    cmd = cmd .. " " .. xargs
  end
  cmd = cmd .. " -i " .. shq(inf_abs) .. " -o " .. shq(out_abs)
  local idx = diagram_index_from_rel(inf)
  mermaid_log("----------")
  mermaid_log("diagram " .. (idx or "?") .. ": " .. inf .. " -> " .. outf)
  mermaid_log("cwd: " .. ((lfs.currentdir() or ""):gsub("\\", "/")))
  mermaid_log("renderer (resolved): " .. renderer)
  if string.match(xargs or "", "%S") then
    mermaid_log("CLI extra args (incl. pdf-fit): " .. xargs)
  end
  mermaid_log("full shell command: " .. cmd)
  mermaid_log(".mmd size (bytes): " .. file_size_bytes(inf_abs))
  local t_clock0 = os.clock()
  local t_wall0 = os.time()
  local a, b, c = os.execute(cmd)
  local d_clock = os.clock() - t_clock0
  local d_wall = os.time() - t_wall0
  mermaid_log(string.format(
    "timing: wall ~= %d s (os.time), lua_cpu = %.3f s (os.clock; often small while waiting on CLI)",
    d_wall,
    d_clock
  ))
  local status, exitcode = interpret_execute_result(a, b, c)
  if status == "ok" then
    mermaid_log("renderer subprocess: finished (parsed as success)")
  elseif status == "spawn_failed" then
    local msg = "ltmermaid: could not run the renderer (check -shell-escape, PATH, and Renderer package option)."
    if renderer_uses_npx(renderer) then
      msg = msg .. " With npx, Node/npm must be on PATH."
    end
    tex.error(msg)
    return
  elseif status == "unknown" then
    mermaid_log("os.execute return could not be parsed (a=" .. tostring(a) .. ", b=" .. tostring(b) .. ", c=" .. tostring(c) .. ")")
    tex.error("ltmermaid: cannot interpret os.execute return value; check the renderer.")
    return
  elseif status == "exit" then
    mermaid_log("renderer exit code: " .. tostring(exitcode))
    local msg = "ltmermaid: renderer exited with code " .. tostring(exitcode) .. "."
    if renderer_uses_npx(renderer) then
      msg = msg .. " npx -y needs registry access; missing Chromium or syntax errors often yield exit 1."
    end
    tex.error(msg)
    return
  end
  local out_mode = lfs.attributes(out_abs, "mode")
  if out_mode ~= "file" then
    mermaid_log("expected output PDF missing after successful os.execute (path not a file)")
    output_pdf_missing(renderer, out_abs)
    return
  end
  verify_pdf(out_abs)
  mermaid_log("output PDF size (bytes): " .. file_size_bytes(out_abs))
  mermaid_log("%PDF header check: OK")
  mermaid_log("----------")
end
\end{luacode*}
\def\mermaid@cmd{}
\def\mermaid@xargs{}
\AtEndOfPackage{%
  \edef\mermaid@renderer@nonempty{\mermaid@Renderer}%
  \ifx\mermaid@renderer@nonempty\@empty
  \else
    \let\mermaid@cmd\mermaid@Renderer
  \fi
}
\newcommand{\MermaidRendererOptions}[1]{\def\mermaid@xargs{#1}}
\def\mermaid@pdffit{-f}
\newcommand{\MermaidNoPdfFit}{\def\mermaid@pdffit{}}
\def\mermaid@graphicsopts{}
\newcommand{\MermaidGraphicsOpts}[1]{\def\mermaid@graphicsopts{#1}}
\def\mermaid@boxopts{max width=0.9\linewidth,center}
\newcommand{\MermaidAdjustBoxOpts}[1]{\def\mermaid@boxopts{#1}}
\newcount\c@mermaid@diag
\c@mermaid@diag=\z@
\def\mermaid@run#1#2{%
  \luaexec{%
    mermaid_cli.run(%
      \luastring{#1}, \luastring{#2},%
      \luastring{\mermaid@cmd}, \luastring{\mermaid@xargs},%
      \luastring{\mermaid@pdffit}%
    )%
  }%
}
\newenvironment{mermaid}{%
  \global\advance\c@mermaid@diag\@ne\relax
  \edef\mermaid@in{%
    \directlua{tex.sprint(-2, mermaid_cli.path_for_diag(\the\c@mermaid@diag, "mmd"))}%
  }%
  \edef\mermaid@out{%
    \directlua{tex.sprint(-2, mermaid_cli.path_for_diag(\the\c@mermaid@diag, "pdf"))}%
  }%
  \VerbatimOut{\mermaid@in}%
}{%
  \endVerbatimOut
  \mermaid@run{\mermaid@in}{\mermaid@out}%
  \begin{center}%
    \bgroup
    \edef\mermaid@tmp{%
      \egroup
      \noexpand\adjustbox{\mermaid@boxopts}{%
        \noexpand\includegraphics[\mermaid@graphicsopts]{\mermaid@out}%
      }%
    }%
    \mermaid@tmp
  \end{center}%
}
\endinput