Skip to content

Instantly share code, notes, and snippets.

@tani
Last active April 29, 2025 09:57
Show Gist options
  • Select an option

  • Save tani/982dd75b4e27f6a3d3df7a82504f4e85 to your computer and use it in GitHub Desktop.

Select an option

Save tani/982dd75b4e27f6a3d3df7a82504f4e85 to your computer and use it in GitHub Desktop.
% SPDX-License-Identifier: MIT-0
%
% MIT No Attribution License (MIT-0)
%
% Permission is hereby granted, free of charge, to any person obtaining a copy
% of this software and associated documentation files (the "Software"), to deal
% in the Software without restriction, including without limitation the rights
% to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
% copies of the Software, and to permit persons to whom the Software is
% furnished to do so.
%
% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
% OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
% THE SOFTWARE.
%
% =======================================================
% org2tex.sty — Org‑mode to LaTeX Converter • fixed v1.5
% =======================================================
%
% Overview:
% Parses a subset of Org‑mode syntax at compile time under LuaLaTeX,
% converting it into equivalent LaTeX constructs.
%
% Changelog (v1.5 – 2025‑04‑29):
% * Fix #1 – table generator: double \\, trailing |, header‑separator col‑count
% * Fix #2 – list parser: proper environment closing & enumerate start >1
% * Fix #4 – inline formatting: allow spaces inside markers
% * Fix #6 – citation regex: avoid accidental URL matches
% * Fix #7 – respect \orgparseglobal boolean at each callback invocation
%
% =======================================================
\ProvidesPackage{org2tex}[2025/04/29 v1.5 Fixed Implementation]
\RequirePackage{luacode}
\RequirePackage{hyperref}
\RequirePackage{amsthm}
\newtheorem{definition}{Definition}
\newtheorem{example}{Example}
\newtheorem{lemma}{Lemma}
\newtheorem{theorem}{Theorem}
\newif\iforgparseglobal
\orgparseglobaltrue
\DeclareOption{global}{\orgparseglobaltrue}
\DeclareOption{env}{\orgparseglobalfalse}
\ProcessOptions\relax
\newenvironment{orgmode}{\directlua{in_org=true}}{\directlua{in_org=false}}
\AtBeginDocument{\directlua{
-- Lua side
local in_org = false
local in_math = false
local footnotes = {}
local used_footnotes = {}
local list_state = {}
local in_table = false
local table_buf = {}
local desc_open = false
local function split(s, p)
local t = {}
for v in s:gmatch("([^" .. p .. "]+)") do
t[#t + 1] = v
end
return t
end
--
-- 1. Tables
--
local function parse_table(line)
-- Collect continuous "| ... |" lines
if line:match("^%s*|.*|%s*$") then
in_table = true
table_buf[#table_buf + 1] = line
return true, ""
elseif in_table then
-- Non‑table line -> flush buffer
local rows = {}
for _, l in ipairs(table_buf) do
local cells = {}
for c in l:gmatch("|([^|]*)") do
cells[#cells + 1] = c
end
rows[#rows + 1] = cells
end
local n = #rows[1]
-- Alignment string |l|l|l| (no duplicate trailing bar)
local align = string.rep("l|", n - 1) .. "l"
local out = {"\\begin{tabular}{|" .. align .. "|}", "\\hline"}
-- Header row
out[#out + 1] = table.concat(rows[1], " & ") .. " \\\\ \\hline"
-- Detect optional separator row
local start = 2
if rows[2] then
local is_sep = (#rows[2] == n)
for _, c in ipairs(rows[2]) do
if not c:match("^%s*[:%-]+%s*$") then
is_sep = false
break
end
end
if is_sep then
start = 3
end
end
-- Body rows
for i = start, #rows do
out[#out + 1] = table.concat(rows[i], " & ") .. " \\\\ \\hline"
end
out[#out + 1] = "\\end{tabular}"
in_table = false
table_buf = {}
return true, table.concat(out, "\n") .. "\n" .. line -- keep current line for further parsing
end
return false, line
end
--
-- 2. Footnotes
--
local function collect_footnote(line)
local k, t = line:match("^%s*%[%^([^%%]]+)%]:%s*(.*)")
if k then
footnotes[k] = t
return true, ""
end
return false, line
end
local function replace_footnote(line)
return line:gsub(
"%[%^([^%%]]+)%]",
function(k)
local txt = footnotes[k] or ""
if not used_footnotes[k] then
used_footnotes[k] = true
return "\\footnote{\\label{orgfn:" .. k .. "}" .. txt .. "}"
else
return "\\footnotemark[\\ref{orgfn:" .. k .. "}]"
end
end
)
end
--
-- 3. Definition lists
--
local function parse_desc(line)
local k, v = line:match("^%s*(.-)%s*::%s*(.*)")
if k then
if not desc_open then
desc_open = true
return true, "\\begin{description}\n \\item[" .. k .. "] " .. v
end
return true, " \\item[" .. k .. "] " .. v
elseif desc_open then
desc_open = false
return true, "\\end{description}\n" .. line
end
return false, line
end
--
-- 4. Block environments
--
local function parse_env(line)
local e = line:match("^#%+begin_(quote|verse|center)")
if e then
return true, "\\begin{" .. e .. "}"
end
e = line:match("^#%+end_(quote|verse|center)")
if e then
return true, "\\end{" .. e .. "}"
end
return false, line
end
--
-- 5. Bibliography
--
local function parse_bib(line)
local b = line:match("^#%+bibliography:%s*(.*)")
if b then
local paths = {}
for q in b:gmatch('"(.-)"') do
paths[#paths + 1] = q
end
local rest = b:gsub('".-"', "")
for f in rest:gmatch("%S+") do
paths[#paths + 1] = f
end
for _, f in ipairs(paths) do
tex.print("\\bibliography{" .. f .. "}")
end
return true, ""
end
return false, line
end
--
-- 6. Citations
--
local function parse_cite(line)
-- long-form [cite:...]
line =
line:gsub(
"%[cite%s*/?[%w_-]*%s*:%s*([^%]]+)%]",
function(body)
local parts = split(body, ";")
local out = {}
for _, item in ipairs(parts) do
local s = item:match("^%s*(.-)%s*$")
local pre, key, sf = s:match("^(.-)%s*@([%w%-%_%.]+)%s*(.*)")
if not key then
pre = ""
key = s:gsub("^@", "")
sf = ""
end
local cmd = "\\cite" .. (sf ~= "" and "[" .. sf .. "]" or "")
out[#out + 1] = (pre ~= "" and (pre .. " ") or "") .. cmd .. "{" .. key .. "}"
end
return table.concat(out, "; ")
end
)
-- short-form cite:@key
-- avoid matching inside URLs (preceded by '/')
line = line:gsub("(^)cite:@?([%w%-%_%.]+)", "\\cite{%2}")
line =
line:gsub(
"([^%w/])cite:@?([%w%-%_%.]+)",
function(p, key)
return p .. "\\cite{" .. key .. "}"
end
)
return line
end
--
-- 7. Theorem-like blocks
--
local function parse_theorem(line)
local e, a = line:match("^#%+begin_(proof|definition|example|theorem|lemma)%s*(.*)")
if e then
local nm = a:match(':name%s+"(.-)"')
return "\\begin{" .. e .. "}" .. (nm and "[" .. nm .. "]" or "")
end
e = line:match("^#%+end_(proof|definition|example|theorem|lemma)")
if e then
return "\\end{" .. e .. "}"
end
return line
end
--
-- 8. Mathematics
--
local function parse_math(line)
local d = line:match("^%$%$(.-)%$%$")
if d then
return true, "\\[" .. d .. "\\]"
end
if line:match("^%$%$") then
in_math = not in_math
return true, (in_math and "\\[" or "\\]")
end
if in_math then
return true, line
end
return false, line:gsub("%$(.-)%$", "\\(%1\\)")
end
--
-- 9. Source code blocks
--
local function parse_code(line)
if line:match("^#%+begin_src") then
return true, "\\begin{verbatim}"
end
if line:match("^#%+end_src") then
return true, "\\end{verbatim}"
end
return false, line
end
--
-- 10. Headings
--
local function parse_head(line)
local s, t = line:match("^(%*+)%s*(.*)")
if s then
local cmds = {"section", "subsection", "subsubsection", "paragraph", "subparagraph"}
return true, "\\" .. (cmds[#s] or cmds[#cmds]) .. "{" .. t .. "}"
end
return false, line
end
--
-- 11. Horizontal rule
--
local function parse_hr(line)
if line:match("^%s*----") then
return true, "\\hrulefill"
end
return false, line
end
--
-- 12. Lists (fixes for proper nesting & numbering)
--
local counter_names = {"enumi", "enumii", "enumiii", "enumiv", "enumv"}
local function close_deeper(current_lvl)
for lvl = #list_state, current_lvl + 1, -1 do
local env = list_state[lvl]
if env then
tex.print(string.rep(" ", lvl - 1) .. "\\end{" .. env .. "}")
list_state[lvl] = nil
end
end
end
local function parse_list(line)
-- bullet list (- or +)
local indent, bullet, rest = line:match("^(%s*)([%-%+])%s+(.*)")
local numeric
if not bullet then -- enumerate (digits.)
indent, numeric, rest = line:match("^(%s*)(%d+)%.%s+(.*)")
if numeric then
bullet = numeric
end
end
if bullet then
local env = (bullet == "-" or bullet == "+") and "itemize" or "enumerate"
local lvl = #indent / 2 + 1
close_deeper(lvl)
local lines = {}
if not list_state[lvl] then -- first item at this level
list_state[lvl] = env
if env == "enumerate" and numeric and tonumber(numeric) > 1 then
local cnt = counter_names[lvl] or "enumi"
lines[#lines + 1] =
indent .. "\\begin{" .. env .. "}\\setcounter{" .. cnt .. "}{" .. (tonumber(numeric) - 1) .. "}"
else
lines[#lines + 1] = indent .. "\\begin{" .. env .. "}"
end
elseif list_state[lvl] ~= env then -- bullet type changed
local prev = list_state[lvl]
list_state[lvl] = env
lines[#lines + 1] = indent .. "\\end{" .. prev .. "}"
lines[#lines + 1] = indent .. "\\begin{" .. env .. "}"
end
lines[#lines + 1] = indent .. "\\item " .. rest
return true, table.concat(lines, "\n")
else
-- close all open lists when leaving list context
close_deeper(0)
end
return false, line
end
--
-- 13. Inline formatting & links (Fix #4)
--
local function fmt(line)
return line:gsub("%*([^%*]-)%*", "\\textbf{%1}"):gsub("/([^/]-)/", "\\emph{%1}"):gsub(
"_([^_]-)_",
"\\underline{%1}"
):gsub("=([^=]-)=", "\\texttt{%1}"):gsub("~([^~]-)~", "\\texttt{%1}")
end
local function link(line)
return line:gsub("%[%[(.-)%]%[(.-)%]%]", "\\href{%1}{%2}")
end
--
-- 14. Master dispatcher
--
callback.register(
"process_input_buffer",
function(line)
-- Fix #7: evaluate global flag at each call
local parse_global = tex.getboolean("orgparseglobal")
if not (parse_global or in_org) then
return line
end
local handled, out
handled, out = parse_table(line)
if handled then
return out
end
handled, out = collect_footnote(line)
if handled then
return out
end
line = replace_footnote(line)
handled, out = parse_desc(line)
if handled then
return out
end
handled, out = parse_env(line)
if handled then
return out
end
handled, out = parse_bib(line)
if handled then
return out
end
line = parse_cite(line)
line = parse_theorem(line)
handled, out = parse_math(line)
if handled then
return out
end
handled, out = parse_code(line)
if handled then
return out
end
handled, out = parse_head(line)
if handled then
return out
end
handled, out = parse_hr(line)
if handled then
return out
end
handled, out = parse_list(line)
if handled then
return out
end
return link(fmt(line))
end
)
}}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment