Last active
April 29, 2025 09:57
-
-
Save tani/982dd75b4e27f6a3d3df7a82504f4e85 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| % 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