-- [ʞ] render/groff.lua
-- ~ lexi hale <lexi@hale.su>
-- 🄯 AGPLv3
-- ? renders cortav to groff source code, for creating pdfs,
-- dvis, manapages, and html files that are grievously
-- inferior compared to our own illustrious direct-html
-- renderer.
-- > cortav -m render:format groff
local ct = require 'cortav'
local ss = require 'sirsem'
local tcat = function(a,b)
for i,v in ipairs(b) do
table.insert(a, b)
end
return a
end
local lines = function(...)
local s = ss.strac()
for _, v in pairs{...} do s(v) end
return s
end
local function gsan(str)
-- groff does not support UTF-8
local ascii = {}
for p,c in utf8.codes(str) do
if c > 0x7F or c == 0x27 or c == 0x22 or c == 0x5C then
table.insert(ascii, string.format('\\[u%04X]', c))
else
table.insert(ascii, utf8.char(c))
end
end
str = table.concat(ascii)
str = str:gsub('\t','\\t') -- tabs are sometimes syntactically meaningful
return str
end
local gtxt = ss.declare {
ident = 'groff-text';
mk = function() return {
lines = {};
} end;
fns = {
raw = function(me, text)
if me.linbuf == nil then
me.linbuf = ss.strac()
end
me.linbuf(text)
end;
txt = function(me, str, ...)
if str == nil then return end
if me.linbuf == nil then
-- prevent unwanted linebreaks
str = str:gsub('^%s+','')
end
me:raw(gsan(str))
-- WARN this will cause problems if str is ever allowed to
-- include a line break. we can sanitize by converting
-- every line break into a new entry in the table, but i
-- don't think it should be possible for a \n to reach us
-- at this point, so i'm omitting the safety check as it
-- would involve an excessive hit to performance
me:txt(...)
end;
brk = function(me)
me:flush()
table.insert(me.lines, '')
end;
line = function(me, ...)
me:flush()
me:txt(...)
end;
req = function(me, r)
me:flush()
table.insert(me.lines, '.'..r)
end;
sreq = function(me, r)
me:flush()
table.insert(me.lines, "'"..r)
end;
esc = function(me, e)
me:raw('\\' .. e)
end;
draw = function(me, args)
for _,v in ipairs(args) do
me:esc("D'" .. v .. "'")
end
end;
flush = function(me)
if me.linbuf ~= nil then
local line = me.linbuf:compile()
local first = line:sub(1,1)
-- make sure our lines aren't accidentally interpreted
-- as groff requests. groff is kinda hostile to script
-- generation, huh?
if first == '.' or first == "'" then
line = '\\&' ..line
end
table.insert(me.lines, line)
me.linbuf = nil
end
end;
compile = function(me)
me:flush()
return table.concat(me.lines, '\n')
end;
}
}
local function mkColorDef(name, color)
return '.defcolor '..name..' rgb ' ..
table.concat({color:rgb_t()}, ' ', 1, 3)
end
local function addAccentTones(rs,hue,spread)
local base = ss.color(hue, 1, .5)
local right = spread > 0 and ss.color(hue + spread, 1, .5)
or ss.color(hue, 0.4, 0.6)
local left = spread > 0 and ss.color(hue - spread, 1, .5)
or ss.color(hue, 1, 0.3)
local steps = 6
for i=-3,3 do
local nc, nm
local o if i > 0
then o = right nm = 'R'
else o = left nm = 'L'
end
nc = base + o:alt('alpha', math.abs(i) / 3)
rs.addColor('accent'..nm..tostring(math.abs(i)),nc)
end
end
local function mkrc()
return {
clone = function(self, origin)
return {
origin = origin;
clone = self.clone;
prop = ss.clone(self.prop);
mk = self.mk;
add = self.add;
block = self.block;
blocks = self.blocks;
span = self.span;
spans = self.spans;
}
end;
blocks = {};
prop = {};
block = function(self)
local sub = self:clone()
sub.parent = self -- needed for blocks that contain blocks
sub.spans = {}
sub.blocks = nil
sub.block = nil
sub.span = function(me, ln)
local p = ss.clone(me.prop)
p.txt = ln
p.block = sub
p.origin = me.origin
table.insert(me.spans, p)
return p
end;
table.insert(self.blocks, sub)
return sub
end;
}
end
function ct.render.groff(doc, opts, setup)
-- rs contains state specific to this render job
-- that modules will need access to
local fail = function(msg, ...)
ct.exns.rdr(msg, 'groff', ...):throw()
end
local rs = {};
rs.macsets = {
strike = {
'.de ST';
[[.nr ww \w'\\$1']];
[[\Z@\v'-.25m'\l'\\n[ww]u'@\\$1]];
'..';
};
color = {'.color'};
insert = {};
footnote = {
'.de footnote-blank';
'. sp 0.25m';
'..';
'.ev footnote-env';
'. ps 8p';
'. in 0.5c';
'.ev';
'.de footnote-print';
-- '. sp |\\\\n[.p]u-\\\\n[footnote-pos]u';
'. sp 0.5c';
'. ev footnote-env';
'. blm footnote-blank';
'. fn';
'. blm np';
'. ev';
'. rm fn';
'. nr footnote-pos 0';
-- move the trap past the top of the page so it's not
-- invoked again until more footnotes have been assembled
'. ch footnote-print |-1000';
'. bp';
'..';
'.wh |\\n[.p]u footnote-print';
};
root = {
-- these are macros included in all documents
-- page offset is hideously broken and unusable; we
-- zero it out so we can use .in to control indents
-- instead. note that the upshot of this is we need
-- to manually specify the indent in every other
-- environment from now on, .evc doesn't seem to cut it
-- set up the page title environment & trap
"'in 2c";
"'ll 19.5c";
"'po 0";
"'ps 13p";
"'vs 15p";
".ev pgti";
". evc 0";
". fam H";
". ps 10pt";
".ev";
'.de ph';
'. sp 0.6c';
'. ev pgti';
'. po 1c';
'. lt 19c';
". tl '\\\\*[doctitle]'\\fB\\\\*[title]\\f[]'%'";
'. po 0';
". br";
'. ev';
'. sp 1.2c';
'..';
'.de np';
'. sp 0.6m';
'..';
'.blm np';
'.wh 0 ph';
};
}
rs.macsNeeded = {
order = {};
map = {};
count = 0;
deps = {
insert = {'color'};
strike = {'color'};
};
}
rs.linkctr = 0
function rs.macAdd(id)
if rs.macsets[id] and not rs.macsNeeded.map[id] then
rs.macsNeeded.count = rs.macsNeeded.count + 1
rs.macsNeeded.order[rs.macsNeeded.count] = id
rs.macsNeeded.map[id] = true
if not rs.macsNeeded.deps[id] then
return true
end
for k,v in pairs(rs.macsNeeded.deps[id]) do
if not rs.macsNeeded.map[v] then
rs.macAdd(v)
end
end
return true
else return false end
end
rs.macAdd 'root'
rs.colors = {}
rs.addColor = function(name,color)
if not ss.color.is(color) then
ss.bug('%s is not a color value', color):throw()
end
rs.colors[name] = color
end
if opts.accent then
addAccentTones(rs, tonumber(opts.accent), tonumber(opts['hue-spread']) or 0)
rs.addColor('new', rs.colors.accentR3)
rs.addColor('del', rs.colors.accentL3)
else
rs.addColor('new', ss.color(80, 1, .3))
rs.addColor('del', ss.color(0, 1, .3))
end
doc.stage = {
type = 'render';
format = 'groff';
groff_render_state = rs;
}
setup(doc.stage)
local job = doc:job('render_groff',nil,rs)
local function collect(rc, spans, b, s)
local rcc = rc:clone()
rcc.spans = {}
rs.renderSpans(rcc, spans, b, s)
return rcc.spans
end
local function collectText(...)
local text = collect(...)
local s = ss.strac()
for i, l in ipairs(text) do
s(l.txt)
end
return s
end
-- the way this module works is we build up a table for each block
-- of individual strings paired with attributes that say how they
-- should be rendered. we then iterate over the table, applying
-- formats as need be, and inserting blanks after each block
local spanRenderers = {}
function spanRenderers.format(rc, s, b, sec)
local rcc = rc:clone()
if s.style == 'strong' then
rcc.prop.bold = true
elseif s.style == 'emph' then
rcc.prop.emph = true
elseif s.style == 'strike' then
rcc.prop.strike = true
rs.macAdd 'strike'
rcc.prop.color = 'del'
elseif s.style == 'insert' then
rs.macAdd 'insert'
rcc.prop.color = 'new'
end
rs.renderSpans(rcc, s.spans, b, sec)
end
function spanRenderers.codepoint(rc, s, b, sec)
utf8.char(s.code)
end
function spanRenderers.link(rc, l, b, sec)
rs.renderSpans(rc, l.spans, b, sec)
rs.linkctr = rs.linkctr + 1
rs.macAdd 'footnote'
local p = rc:span(string.format('[%u]', rs.linkctr))
if type(l.ref) == 'string' then
local t = ''
if b.origin.doc.sections[l.ref] then
local hn = b.origin.doc.sections[l.ref].heading_node
if hn then
t = collectText(rc, hn.spans, b, sec):compile()
end
else
local obj = l.origin:ref(l.ref)
if type(obj) == 'string' then
t = l.origin:ref(l.ref)
end
end
p.div = { fn = tostring(rs.linkctr) .. ') ' .. t }
end
end;
function spanRenderers.raw(rc, s, b, sec)
rs.renderSpans(rc, s.spans, b, sec)
end;
function spanRenderers.var(rc,v,b,s)
local t, raw = ct.expand_var(v)
if raw then rc:span(t) else
rs.renderSpans(rc,t,b,s)
end
end
function spanRenderers.macro(rc, m,b,s)
local macroname = collectText(rc,
ct.parse_span(m.macro, b.origin),
b, s):compile()
local r = b.origin:ref(macroname)
if type(r) ~= 'string' then
b.origin:fail('%s is an object, not a reference', t.ref)
end
local mctx = b.origin:clone()
mctx.invocation = m
rs.renderSpans(rc, ct.parse_span(r, mctx))
end
function rs.renderSpans(rc, sp, b, sec)
rc = rc or mkrc(b.origin)
for i, v in ipairs(sp) do
if type(v) == 'string' then
rc:span(v)
elseif spanRenderers[v.kind] then
spanRenderers[v.kind](rc, v, b, sec)
end
end
end
local blockRenderers = {}
blockRenderers['horiz-rule'] = function(rc, b, sec)
rc.prop.margin = { top = 0.3 }
rc.prop.underline = 0.1
end
function blockRenderers.label(rc, b, sec)
if ct.sec.is(b.captions) then
local visDepth = b.captions.depth + (b.origin.docDepth or 0)
local sizes = {36,24,12,8,4,2}
local margins = {0,3}
local dedents = {2.5,1.3,0.8,0.4}
local uls = {3,1.5,0.5,0.25}
rc.prop.dsz = sizes[visDepth] or 10
rc.prop.underline = uls[visDepth]
rc.prop.bold = visDepth > 3
rc.prop.margin = {
top = margins[visDepth] or 1;
bottom = 0.1;
}
rc.prop.vassure = rc.prop.dsz+70;
rc.prop.indent = -(dedents[visDepth] or 0)
rc.prop.chtitle = collectText(rc, b.spans, b.spec):compile()
if visDepth == 1 then
rc.prop.breakBefore = true
end
rs.renderSpans(rc, b.spans, b, sec)
else
ss.bug 'tried to render label for an unknown object type':throw()
end
end
function blockRenderers.paragraph(rc, b, sec)
rs.renderSpans(rc, b.spans, b, sec)
end
function blockRenderers.macro(rc, b, sec)
local rc = rc.parent:clone()
rs.renderDoc(rc, b.doc)
end
function blockRenderers.quote(rc, b, sec)
local rc = rc.parent:clone()
rc.prop.indent = (rc.prop.indent or 0) + 1
local added = rs.renderDoc(rc, b.doc)
-- select last block of last section and increase bottom margin
local ap = added[#added].blocks
ap = ap[#ap].prop
if ap.margin then
if ap.margin.bottom then
ap.margin.bottom = ap.margin.bottom + 1.1
else
ap.margin.bottom = 1.1
end
else
ap.margin = {bottom = 1.1}
end
end
function blockRenderers.table(rc, b, sec)
function rc:begin(g)
g:req 'TS'
local aligns = {}
for i, c in ipairs(b.rows[1]) do
aligns[i] = ({
left = 'l';
center = 'c';
right = 'r';
})[c.align] or 'l'
end
table.insert(aligns, '.')
g:txt(table.concat(aligns, ' ') .. '\n')
local rc_hdr = rc:clone()
rc_hdr.prop.bold = true
for ri, r in ipairs(b.rows) do
for ci, c in ipairs(r) do
local sp = collect(c.header and rc_hdr or rc, c.spans, b, sec)
for si, s in ipairs(sp) do rs.emitSpan(g,s) end
g:raw '\t'
end
if ri ~= #b.rows then g:raw '\n' end
end
g:req 'TE'
end
end
function rs.renderBlock(rc, b, sec, outerBlockRenderContext)
if blockRenderers[b.kind] then
local rcc = rc:block()
blockRenderers[b.kind](rcc, b, sec)
end
end
rs.sanitize = gsan
local skippedFirstPagebreak = doc.secorder[1]:visible()
local deferrer = ss.declare {
ident = 'groff-deferrer';
mk = function(buf) return {ops={}, tgt=buf} end;
fns = {
esc = function(me, str) table.insert(me.ops, {0, str}) end;
req = function(me, str) table.insert(me.ops, {1, str}) end;
draw = function(me, lst) table.insert(me.ops,{2, lst}) end;
flush = function(me)
for i=#me.ops,1,-1 do
local d = me.ops[i]
if d[1] == 0 then
me.tgt:esc(d[2])
elseif d[1] == 1 then
me.tgt:req(d[2])
elseif d[1] == 2 then
me.tgt:draw(d[2])
end
end
me.ops = {}
end;
};
}
function rs.emitSpan(gtxt, s)
local defer = deferrer(gtxt)
if s.bold or s.emph then
if s.bold and s.emph then
gtxt:esc 'f(BI'
elseif s.bold then
gtxt:esc 'fB'
elseif s.emph then
gtxt:esc 'fI'
end
defer:esc'f[]'
end
if s.color and opts.color then
gtxt:esc('m[' .. s.color .. ']')
defer:esc('m[]')
end
if s.strike then
gtxt:req('ST "'..s.txt..'"')
else
gtxt:txt(s.txt)
end
defer:flush()
if s.div then
for div, body in pairs(s.div) do
if div == 'fn' then
gtxt:sreq 'ev footnote-env'
end
gtxt:sreq('boxa '..div)
gtxt:txt(body)
gtxt:raw '\n'
gtxt:sreq 'boxa'
if div == 'fn' then
gtxt:sreq 'ev'
gtxt:sreq 'nr footnote-pos (\\n[footnote-pos]u+\\n[dn]u)'
gtxt:sreq 'ch footnote-print -(\\n[footnote-pos]u+1.5c)'
end
end
end
end
function rs.emitBlock(gtxt, b)
local didfinalbreak = false
local defer = deferrer(gtxt)
local ln = b.prop
if ln.chtitle then
gtxt:req('ds title '..ln.chtitle)
end
if ln.breakBefore then
if skippedFirstPagebreak then
gtxt:req 'bp'
else
skippedFirstPagebreak = true
end
elseif ln.vassure then
gtxt:req(string.format('if (\\n[.t]u < %sp) .bp',ln.vassure))
end
if ln.indent then
if ln.indent < 0 then
gtxt:req('in '..tostring(ln.indent)..'m')
defer:req 'in'
gtxt:req('ll +'..tostring(-ln.indent)..'m')
defer:req 'll'
else
gtxt:req('in +'..tostring(ln.indent)..'m')
defer:req 'in'
end
defer:req 'br'
end
if ln.margin then
if ln.margin.top then
gtxt:req(string.format('sp %sm', ln.margin.top))
end
end
if ln.underline then
defer:req'br'
defer:draw {
"t "..tostring(ln.underline).."p";
"l \\n[.ll]u-\\n[.in]u 0";
}
defer:esc("h'-" .. tostring(ln.underline) .. "p'")
defer:esc"v'-0.5'"
end
if ln.dsz and ln.dsz > 0 then
gtxt:req('ps +' .. tostring(ln.dsz) .. 'p')
defer:req('ps -' .. tostring(ln.dsz) .. 'p')
elseif ln.sz or ln.dsz then
if ln.sz and ln.sz <= 0 then
ln.origin:fail 'font sizes must be greater than 0'
end
gtxt:req('ps ' .. tostring(ln.sz or ln.dsz) ..'p')
if ln.dsz then
defer:req('ps +' .. tostring(0 - ln.dsz) .. 'p')
else
defer:req'ps'
end
end
if b.begin then b:begin(gtxt) end
if b.spans then
for i,s in pairs(b.spans) do
rs.emitSpan(gtxt, s)
end
end
if b.complete then b:complete(gtxt) end
if ln.margin then
if ln.margin.bottom then
gtxt:req(string.format('sp %sm', ln.margin.bottom))
end
end
defer:flush()
if not ln.margin then gtxt:brk() end
end
function rs.renderDoc(gctx, doc, ir) ir = ir or {}
for i, sec in ipairs(doc.secorder) do
if sec.kind == 'ordinary' then
local rc = gctx and gctx:clone() or mkrc()
for j, b in ipairs(sec.blocks) do
rs.renderBlock(rc, b, sec)
end
table.insert(ir, {blocks = rc.blocks, src = sec})
end
end
return ir
end
local ir = rs.renderDoc(nil, doc)
local gd = gtxt()
for i, s in ipairs(ir) do
for j, b in ipairs(s.blocks) do
rs.emitBlock(gd,b)
end
end
local macs = ss.strac()
for _, m in pairs(rs.macsNeeded.order) do
for _,ln in pairs(rs.macsets[m]) do macs(ln) end
end
if rs.macsNeeded.map.color and opts.color then
for k,v in pairs(rs.colors) do
macs(mkColorDef(k,v))
end
end
local doctitle = '' if opts.title then
doctitle = opts.title
else
local top = math.huge
for i,s in ipairs(doc.secorder) do
if s.heading_node and s.depth < top then
top = s.depth
doctitle = collectText(mkrc():block(), s.heading_node.spans, s.heading_node, s):compile()
end
end
end
macs('.ds doctitle '..doctitle)
return macs:compile'\n' .. '\n' .. gd:compile() .. '\n'
-- if the document doesn't end with the character \n, groff will bitch
-- and moan in certain circumstances
end