cortav  groff.lua at [a8358d587d]

File render/groff.lua artifact 3a20caca9a part of check-in a8358d587d


-- [ʞ] 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.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