Index: cortav.ct
==================================================================
--- cortav.ct
+++ cortav.ct
@@ -137,11 +137,11 @@
 * footnote {span ^|ref|[$styled-text]}: annotates the text with a defined footnote. in interactive output media [`\[^citations.qtheo Quantum Theosophy: A Neophyte's Catechism\]] will insert a link with the text [`Quantum Theosophy: A Neophyte's Catechism] that, when clicked, causes a footnote to pop up on the screen. for static output media, the text will simply have a superscript integer after it denoting where the footnote is to be found.
 * superscript {obj '|[$styled-text]}
 * subscript {obj ,|[$styled-text]}
 * raw {obj \\ |[$raw-text]}: causes all characters within to be interpreted literally, without expansion. the only special characters are square brackets, which must have a matching closing bracket, and backslashes.
 * raw literal [` \["[$raw-text]\]]: shorthand for a raw inside a literal, that is ["[`[\\ā¦]]]
-* macro [` \{[$name] [$arguments]}]: invokes a [>ex.mac macro], specified with a reference
+* macro [` \{[$name] [$arguments]}]: invokes a [>ex.mac macro] inline, specified with a reference. if the result of macro expansion contains newlines, they will be treated as line breaks, rather than paragraph breaks as they would be in a multiline context.
 * argument {obj #|var}: in macros only, inserts the [$var]-th argument. otherwise, inserts a context variable provided by the renderer.
 * raw argument {obj ##|var}: like above, but does not evaluate [$var].
 * term {obj &|name}, {span &|name|[$expansion]}: quotes a defined term with a link to its definition, optionally with a custom expansion of the term (for instance, to expand the first use of an acronym)
 * inline image {obj &@|name}: shows a small image or other object inline. the unicode character [`š¼] can also be used instead of [`&@].
 * unicode codepoint {obj U+|hex-integer}: inserts an arbitrary UCS codepoint in the output, specified by [$hex-integer]. lowercase [`u] is also legal.
Index: cortav.lua
==================================================================
--- cortav.lua
+++ cortav.lua
@@ -131,10 +131,18 @@
 					return self.sec.refs[id], id, self.sec
 				else self:fail("no such ref %s in current section", id or '') end
 			else
 				local sec, ref = string.match(id, "(.-)%.(.+)")
 				local s = self.doc.sections[sec]
+				if not s then -- fall back on inheritance tree
+					for i, p in ipairs(self.doc.parents) do
+						if p.sections[sec] then
+							s = p.sections[sec]
+							break
+						end
+					end
+				end
 				if s then
 					if s.refs[ref] then
 						return s.refs[ref], ref, sec
 					else self:fail("no such ref %s in section %s", ref, sec) end
 				else self:fail("no such section %s", sec) end
@@ -151,11 +159,11 @@
 		depth = 0;
 		kind = 'ordinary';
 	} end;
 	construct = function(self, id, depth)
 		self.id = id
-		self.depth = depth
+		self.depth = depth or self.depth
 	end;
 	fns = {
 		visible = function(self)
 			if self.kind == 'nonprinting' then return false end
 			local invisibles = {
@@ -266,10 +274,11 @@
 			-- are intentionally excluded here; subdocs can have their own vars
 			-- without losing access to parent vars
 			local nctx = ctx:clone()
 			nctx:init(newdoc, ctx.src)
 			nctx.line = ctx.line
+			nctx.docDepth = (ctx.docDepth or 0) + ctx.sec.depth - 1
 			return newdoc, nctx
 		end;
 	};
 	mk = function(...) return {
 		sections = {};
@@ -832,15 +841,17 @@
 
 local function
 blockwrap(fn)
 	return function(l,c,j,d)
 		local block = fn(l,c,j,d)
-		block.origin = c:clone();
-		table.insert(d, block);
-		j:hook('block_insert', c, block, l)
-		if block.spans then
-			c.doc.docjob:hook('meddle_span', block.spans, block)
+		if block then
+			block.origin = c:clone();
+			table.insert(d, block);
+			j:hook('block_insert', c, block, l)
+			if block.spans then
+				c.doc.docjob:hook('meddle_span', block.spans, block)
+			end
 		end
 	end
 end
 
 local insert_paragraph = blockwrap(function(l,c)
@@ -1050,11 +1061,15 @@
 		local last = d[#d]
 		if (not last) or (last.kind ~= 'reference') then
 			c:fail('reference continuations must immediately follow a reference')
 		end
 		local str = l:match '^\t\t(.-)%s*$'
-		last.val = last.val .. '\n' .. str
+		if last.val == '' then
+			last.val = str
+		else
+			last.val = last.val .. '\n' .. str
+		end
 		c.sec.refs[last.key] = last.val
 	end};
 	{seq = '\t', pred = function(l)
 		return (l:match '\t+([^:]+):%s*(.*)$')
 	end; fn = blockwrap(function(l,c,j,d)
@@ -1061,10 +1076,11 @@
 		local ref, val = l:match '\t+([^:]+):%s*(.*)$'
 		local last = d[#d]
 		local rsrc
 		if last and last.kind == 'resource' then
 			last.props[ref] = val
+			j:hook('rsrc_set_prop', c, last, ref, val, l)
 			rsrc = last
 		elseif last and last.kind == 'reference' and last.rsrc then
 			last.rsrc.props[ref] = val
 			rsrc = last.rsrc
 		else
@@ -1120,27 +1136,27 @@
 			end
 		elseif crit == '!' then
 			c:fail('critical directive %s not supported',cmd)
 		end
 	end;};
-	{pred = function(s) return s:match '^(>+)([^%s]*)%s*(.*)$' end,
+	{pred = function(s) return s:match '^>[^>%s]*%s*.*$' end,
 	 fn   = function(l,c,j,d)
-		local lvl,id,txt = l:match '^(>+)([^%s]*)%s*(.*)$'
-		lvl = utf8.len(lvl)
+		local id,txt = l:match '^>([^>%s]*)%s*(.*)$'
+		if id == '' then id = nil end
 		local last = d[#d]
 		local node
 		local ctx
-		if last and last.kind == 'quote' and (id == nil or id == '' or id == last.id) then
+		if last and last.kind == 'quote' and (id == nil or id == last.id) then
 			node = last
 			ctx = node.ctx
 			ctx.line = c.line -- is this enough??
 		else
 			local doc
 			doc, ctx = c.doc:sub(c)
-			node = { kind = 'quote', doc = doc, ctx = ctx, id = id }
-			j:hook('block_insert', c, node, l)
+			node = { kind = 'quote', doc = doc, ctx = ctx, id = id, origin = c }
 			table.insert(d, node)
+			j:hook('block_insert', c, node, l)
 		end
 
 		ct.parse_line(txt, ctx, ctx.sec.blocks)
 	end};
 	{seq = '~~~', fn = blockwrap(function(l,c,j)
@@ -1178,11 +1194,10 @@
 		c.mode = mode
 		if id then
 			if c.sec.refs[id] then c:fail('duplicate ID %s', id) end
 			c.sec.refs[id] = c.mode.listing
 		end
-		j:hook('block_insert', c, mode.listing, l)
 		return c.mode.listing;
 	end)};
 	{pred = function(s,c)
 		if s:match '^[%-_][*_%-%s]+' then return true end
 		if startswith(s, 'ā') then
@@ -1195,33 +1210,118 @@
 			return true
 		end
 	end; fn = blockwrap(function()
 		return { kind = 'horiz-rule' }
 	end)};
-	{seq='@', fn=blockwrap(function(s,c)
-		local id = s:match '^@%s*(.-)%s*$'
+	{seq='@', fn=function(s,c,j,d)
+		local function mirror(b)
+			local ch = {}
+			local rev = {
+				['['] = ']'; [']'] = '[';
+				['{'] = '}'; ['}'] = '{';
+				['('] = ')'; [')'] = '(';
+				['<'] = '>'; ['>'] = '<';
+			}
+			for i = 1,#b do
+				local c = string.sub(b,-i,-i)
+				if rev[c] then
+					ch[i] = rev[c]
+				else
+					ch[i] = c
+				end
+			end
+			return table.concat(ch)
+		end
+
+		local id,rest = s:match '^@([^%s]*)%s*(.*)$'
+		local bs, brak = rest:match '()([{[(<][^%s]*)%s*$'
+		local src
+		if brak then
+			src = rest:sub(1,bs-1):gsub('%s+$','')
+		else src = rest end
+		if src == '' then src = nil end
+		if id == '' then id = nil end
 		local rsrc = {
 			kind = 'resource';
-			props = {};
+			props = {src = src};
 			id = id;
+			origin = c;
 		}
-		if c.sec.refs[id] then
-			c:fail('an object with id ā%sā already exists in that section',id)
+		if brak then
+			rsrc.bracket = {
+				open = brak;
+				close = mirror(brak);
+			}
+			rsrc.raw = '';
+			if src == nil then
+				rsrc.props.src = 'text/x.cortav'
+			end
 		else
-			c.sec.refs[id] = rsrc
+			-- load the raw body, where possible
+		end
+		if id then
+			if c.sec.refs[id] then
+				c:fail('an object with id ā%sā already exists in that section',id)
+			else
+				c.sec.refs[id] = rsrc
+			end
+		end
+		table.insert(d, rsrc)
+		j:hook('block_insert', c, rsrc, s)
+		if id == '' then --shorthand syntax
+			local embed = {
+				kind = 'embed';
+				rsrc = rsrc;
+				origin = c;
+			}
+			table.insert(d, embed)
+			j:hook('block_insert', c, embed, s)
+		end
+
+		if brak then
+			c.mode = {
+				kind = 'inline-rsrc';
+				rsrc = rsrc;
+				indent = nil;
+				depth = 0;
+			}
+		end
+	end};
+	{seq='&$', fn=blockwrap(function(s,c)
+		local id, args = s:match('^&$([^%s]+)%s?(.-)$')
+		if id == nil or id == '' then
+			c:fail 'malformed macro block'
+		end
+		local argv = ss.str.split(c.doc.enc, args, c.doc.enc.encodeUCS'|', {esc=true})
+		return {
+			kind = 'macro';
+			macro = id;
+			args = argv;
+		}
+	end)};
+	{seq='&', fn=blockwrap(function(s,c)
+		local id, cap = s:match('^&([^%s]+)%s*(.-)%s*$')
+		if id == nil or id == '' then
+			c:fail 'malformed embed block'
 		end
-		return rsrc
+		if cap == '' then cap = nil end
+		return {
+			kind = 'embed';
+			ref = id;
+			cap = cap;
+		}
 	end)};
 	{fn = insert_paragraph};
 }
 
-function ct.parse_line(l, ctx, dest)
+function ct.parse_line(rawline, ctx, dest)
 	local newspan
 	local job = ctx.doc.stage.job
-	job:hook('line_read',ctx,l)
-	if l then
-		l = l:gsub("^ +","") -- trim leading spaces
+	job:hook('line_read',ctx,rawline)
+	local l
+	if rawline then
+		l = rawline:gsub("^ +","") -- trim leading spaces
 	end
 	if ctx.mode then
 		if ctx.mode.kind == 'code' then
 			if l and l:match '^~~~%s*$' then
 				job:hook('block_listing_end',ctx,ctx.mode.listing)
@@ -1235,11 +1335,34 @@
 					else newline = {l}
 				end
 				table.insert(ctx.mode.listing.lines, newline)
 				job:hook('block_listing_newline',ctx,ctx.mode.listing,newline)
 			end
-		elseif ctx.mode.kind == 'quote' then
+		elseif ctx.mode.kind == 'inline-rsrc' then
+			local r = ctx.mode.rsrc
+			if rawline then
+				if rawline == r.bracket.close then
+					if ctx.mode.depth == 0 then
+						-- TODO how to handle depth?
+						ctx.mode = nil
+					end
+				else
+					if r.indent ~= nil then
+						r.raw = r.raw .. '\n'
+					else
+						r.indent = (rawline:sub(1,1) == '\t')
+					end
+
+					if r.indent == true then
+						if rawline:sub(1,1) == '\t' then
+							rawline = rawline:sub(2)
+						end
+					end
+
+					r.raw = r.raw .. rawline
+				end
+			end
 		else
 			local mf = job:proc('modes', ctx.mode.kind)
 			if not mf then
 				ctx:fail('unimplemented syntax mode %s', ctx.mode.kind)
 			end
@@ -1310,42 +1433,101 @@
 		return ctx.doc.enc.iswhitespace(cp)
 	end
 
 	if setup then setup(ctx) end
 
-
 	for full_line in file:lines() do ctx.line = ctx.line + 1
-		local l
-		for p, c in utf8.codes(full_line) do
-			if not is_whitespace(c) then
-				l = full_line:sub(p)
-				break
-			end
-		end
-		ct.parse_line(l, ctx, ctx.sec.blocks)
+	-- 		local l
+	-- 		for p, c in utf8.codes(full_line) do
+	-- 			if not is_whitespace(c) then
+	-- 				l = full_line:sub(p)
+	-- 				break
+	-- 			end
+	-- 		end
+		ct.parse_line(full_line, ctx, ctx.sec.blocks)
 	end
 
 	for i, sec in ipairs(ctx.doc.secorder) do
-		for refid, r in ipairs(sec.refs) do
-			if type(r) == 'table' and r.kind == 'resource' and r.props.src then
+		for n, r in pairs(sec.blocks) do
+			if r.kind == 'resource' and r.props.src then
 				local lines = ss.str.breaklines(ctx.doc.enc, r.props.src)
 				local srcs = {}
 				for i,l in ipairs(lines) do
 					local args = ss.str.breakwords(ctx.doc.enc, l, 2, {escape=true})
-					if #args < 3 then
-						r.origin:fail('invalid syntax for resource %s', t.ref)
+					if #args > 3 or (r.raw and #args > 2) then
+						r.origin:fail('invalid syntax for resource %s', r.id or '(anonymous)')
+					end
+					local p_mode, p_mime, p_uri
+					if r.raw then
+						p_mode = 'embed'
+					end
+					if #args == 1 then
+						if r.raw then -- inline content
+							p_mime = ss.mime(args[1])
+						else
+							p_uri = args[1]
+						end
+					elseif #args == 2 then
+						local ok, m = pcall(ss.mime, args[1])
+						if r.raw then
+							if not ok then
+								r.origin:fail('invalid mime-type ā%sā', args[1])
+							end
+							p_mode, p_mime = args[1], m
+						else
+							if ok then
+								p_mime, p_uri = m, args[2]
+							else
+								p_mode, p_uri = table.unpack(args)
+							end
+						end
+					else
+						p_mode, p_mime, p_uri = table.unpack(args)
+						p_mime = ss.mime(args[2])
+					end
+					local resource = {
+						mode = p_mode;
+						mime = p_mime or 'text/x.cortav';
+						uri = p_uri and ss.uri(p_uri) or nil;
+					}
+					if resource.mode == 'embed' or resource.mode == 'auto' then
+						-- the resource must be available for reading within this job
+						-- open it and read its source into memory
+						if resource.uri then
+							if resource.uri:canfetch() then
+								resource.raw = resource.uri:fetch()
+							elseif resource.mode == 'auto' then
+								-- resource cannot be accessed; force linking
+								resource.mode = 'link'
+							else
+								r.origin:fail('resource ā%sā wants to embed unfetchable URI ā%sā',
+												  r.id or "(anonymous)", tostring(resource.uri))
+							end
+						elseif r.raw then
+							resource.raw = r.raw
+						else
+							r.origin:fail('resource ā%sā is not inline and supplies no URI',
+											  r.id or "(anonymous)")
+						end
+
+						-- the resource has been cached. check the mime-type to see if
+						-- we need to parse it or if it is suitable as-is
+
+						if resource.mime.class == "text" then
+							if resource.mime.kind == "x.cortav" then
+								local sd, sc = r.origin.doc:sub(r.origin)
+								local lines = ss.str.breaklines(r.origin.doc.enc, resource.raw, {})
+								for i, ln in ipairs(lines) do
+									sc.line = sc.line + 1
+									ct.parse_line(ln, sc, sc.sec.blocks)
+								end
+								resource.doc = sd
+							end
+						end
 					end
-					local mime = ss.mime(args[2]);
-					local class = mimeclasses[mime]
-					table.insert(srcs, {
-						mode = args[1];
-						mime = mime;
-						uri = args[3];
-						class = class or mime[1];
-					})
+					table.insert(srcs, resource)
 				end
-				 --ideally move this into its own mimetype lib
 				r.srcs = srcs
 				-- note that resources do not themselves have kinds. when a
 				-- document requests to insert a resource, the renderer must
 				-- iterate through the sources and find the first source it
 				-- is capable of emitting. this allows constructions like
@@ -1352,10 +1534,41 @@
 				-- emitting a video for HTML outputs, a photo for printers,
 				-- and a screenplay for tty/plaintext outputs.
 			end
 		end
 	end
+
+	-- expand block macros
+	for i, sec in ipairs(ctx.doc.secorder) do
+		for n, r in pairs(sec.blocks) do
+			if r.kind == 'macro' then
+				local mc = r.origin:clone()
+				mc.invocation = r
+				local mac = r.origin:ref(r.macro)
+				if not mac then
+					r.origin:fail('no such reference or resource ā%sā', r.macro)
+				end
+				local subdoc, subctx = ctx.doc:sub(mc)
+				local rawbody
+
+				if type(mac) == 'string' then
+					rawbody = mac
+				elseif mac.raw then
+					rawbody = mac.raw
+				else
+					r.origin:fail('block macro ā%sā must be either a reference or an embedded text/x.cortav resource', r.macro)
+				end
+
+				local lines = ss.str.breaklines(ctx.doc.enc, rawbody)
+				for i, ln in ipairs(lines) do
+					ct.parse_line(ln, subctx, subctx.sec.blocks)
+				end
+				r.doc = subdoc
+			end
+		end
+	end
+
 	ctx.doc.stage = nil
 	ctx.doc.docjob:hook('meddle_ast')
 	return ctx.doc
 end
 
Index: ext/toc.lua
==================================================================
--- ext/toc.lua
+++ ext/toc.lua
@@ -138,12 +138,13 @@
 			local _, op, val = words(2)
 			if op == nil then
 				local toc = {kind='toc'}
 				ctx:insert(toc)
 				-- same deal here -- directives are processed as part of
-				-- the parse job, which is forked off the document job,
-				-- so we need to climb the jobstack
+				-- the parse job, which is forked off the document job;
+				-- if we want state that can persist into the render job,
+            -- we need to climb the jobstack
 				job:unwind(1).state.toc_custom_position = true
 				job:hook('ext_toc_position', ctx, toc)
 			else
 				ctx:fail 'bad %toc directive'
 			end
@@ -190,24 +191,47 @@
 				local top = function() return stack[#stack] end
 				-- job.doc is the document the render job is bound to, and
 				-- its secorder field is a list of all the doc's sections in
 				-- the order they occur ("doc.sections" is a hashmap from name
 				-- to section object)
-				local all = job.doc.secorder
+				local all = {}
+
+				local function blockHasSubdoc(b)
+					local subdocBlockKinds = {
+						quote = true;
+						embed = true;
+						macro = true;
+					}
+					return subdocBlockKinds[b.kind] and ct.doc.is(b.doc)
+				end
+
+            local function scandoc(doc, depth)
+	            for i, sec in ipairs(doc.secorder) do
+						table.insert(all, {ref = sec, depth = sec.depth + depth})
+						for j, block in ipairs(sec.blocks) do
+							if blockHasSubdoc(block) then
+								scandoc(block.doc, depth + sec.depth-1)
+							end
+						end
+	            end
+            end
+
+				scandoc(job.doc,0)
 
-				for i, sec in ipairs(all) do
+				for i, secptr in ipairs(all) do
+					local sec = secptr.ref
 					if sec.heading_node then -- does this section have a label?
 						local ent = tag('li',nil,
 							 catenate{tag('a', {href='#'..getSafeID(sec)},
 								sr.htmlSpan(sec.heading_node.spans, sec.heading_node, sec))})
-						if sec.depth > #stack then
+						if secptr.depth > #stack then
 							local n = {tag = 'ol', attrs={}, nodes={ent}}
 							table.insert(top().nodes[#top().nodes].nodes, n)
 							table.insert(stack, n)
 						else
-							if sec.depth < #stack then
-								for j=#stack,sec.depth+1,-1 do stack[j] = nil end
+							if secptr.depth < #stack then
+								for j=#stack,secptr.depth+1,-1 do stack[j] = nil end
 							end
 							table.insert(top().nodes, ent)
 						end
 
 						-- now we need to assemble a list of items within the
@@ -236,11 +260,12 @@
 									attrs = {href = '#' .. l.id};
 									nodes = {sr.htmlSpan(l.label, l.block, sec)};
 								}
 								table.insert(n.nodes, {tag = 'li', attrs = {}, nodes={nn}})
 							end
-							table.insert(ent.nodes, n)
+							table.insert(top().nodes, n)
+							table.insert(stack, n)
 						end
 					end
 				end
 				return lst
 			end;
Index: render/groff.lua
==================================================================
--- render/groff.lua
+++ render/groff.lua
@@ -21,14 +21,21 @@
 	for _, v in pairs{...} do s(v) end
 	return s
 end
 
 local function gsan(str)
-	local tocodepoint = function(ch)
-		return string.format('\\[u%04X]', utf8.codepoint(ch))
+	-- 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 = str:gsub('(["\'\\])',tocodepoint)
+	str = table.concat(ascii)
+	str = str:gsub('\t','\\t') -- tabs are sometimes syntactically meaningful
 	return str
 end
 
 local gtxt = ss.declare {
 	ident = 'groff-text';
@@ -142,12 +149,14 @@
 		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
@@ -402,34 +411,83 @@
 		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[b.captions.depth] or 10
-			rc.prop.underline = uls[b.captions.depth]
-			rc.prop.bold = b.captions.depth > 3
+			rc.prop.dsz = sizes[visDepth] or 10
+			rc.prop.underline = uls[visDepth]
+			rc.prop.bold = visDepth > 3
 			rc.prop.margin = {
-				top = margins[b.captions.depth] or 1;
+				top = margins[visDepth] or 1;
 				bottom = 0.1;
 			}
 			rc.prop.vassure = rc.prop.dsz+70;
-			rc.prop.indent = -(dedents[b.captions.depth] or 0)
+			rc.prop.indent = -(dedents[visDepth] or 0)
 			rc.prop.chtitle = collectText(rc, b.spans, b.spec):compile()
-			if b.captions.depth == 1 then
+			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)
@@ -558,14 +616,17 @@
 			else
 				defer:req'ps'
 			end
 		end
 
-		for i,s in pairs(b.spans) do
-			rs.emitSpan(gtxt, s)
+		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
@@ -574,20 +635,23 @@
 		defer:flush()
 
 		if not ln.margin then gtxt:brk() end
 	end
 
-	local ir = {}
-	for i, sec in ipairs(doc.secorder) do
-		if sec.kind == 'ordinary' then
-			local rc = mkrc()
-			for j, b in ipairs(sec.blocks) do
-				rs.renderBlock(rc, b, sec)
+	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
-			table.insert(ir, {blocks = rc.blocks, src = sec})
 		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)
@@ -615,7 +679,9 @@
 			end
 		end
 	end
 	macs('.ds doctitle '..doctitle)
 
-	return macs:compile'\n' .. '\n' .. gd:compile()
+	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
Index: render/html.lua
==================================================================
--- render/html.lua
+++ render/html.lua
@@ -636,11 +636,11 @@
 		function span_renderers.macro(m,b,s)
 			local macroname = plainrdr.htmlSpan(
 				ct.parse_span(m.macro, b.origin), b,s)
 			local r = b.origin:ref(macroname)
 			if type(r) ~= 'string' then
-				b.origin:fail('%s is an object, not a reference', t.ref)
+				b.origin:fail('%s is an object, not a reference', r.id)
 			end
 			local mctx = b.origin:clone()
 			mctx.invocation = m
 			return htmlSpan(ct.parse_span(r, mctx),b,s)
 		end
@@ -734,11 +734,13 @@
 			label = function(b,s)
 				if ct.sec.is(b.captions) then
 					if not (opts['fossil-uv'] or opts.snippet) then
 						addStyle 'header'
 					end
-					local h = math.min(6,math.max(1,b.captions.depth))
+					-- use correct styling in subdocuments
+					local visDepth = b.captions.depth + (b.origin.docDepth or 0)
+					local h = math.min(6,math.max(1,visDepth))
 					return tag(f('h%u',h), nil, sr.htmlSpan(b.spans, b, s), b)
 				else
 					-- handle other uses of labels here
 				end
 			end;
@@ -788,35 +790,206 @@
 				-- lists need to be rewritten to work like asides
 				return '';
 			end;
 		}
 
-		function block_renderers.quote(b,s)
+		local function renderSubdoc(doc)
 			local ir = {}
-			local toIR = block_renderers
-			for i, sec in ipairs(b.doc.secorder) do
+			for i, sec in ipairs(doc.secorder) do
 				local secnodes = {}
 				for i, bl in ipairs(sec.blocks) do
-					if toIR[bl.kind] then
-						table.insert(secnodes, toIR[bl.kind](bl,sec))
+					if block_renderers[bl.kind] then
+						table.insert(secnodes, block_renderers[bl.kind](bl,sec))
 					end
 				end
 				if next(secnodes) then
-					if b.doc.secorder[2] then --#secs>1?
+					if doc.secorder[2] then --#secs>1?
 						-- only wrap in a section if >1 section
 						table.insert(ir, tag('section',
 													{id = getSafeID(sec)},
 													secnodes))
 					else
 						ir = secnodes
 					end
 				end
 			end
-			return tag('blockquote', b.id and {id=getSafeID(b)} or {}, catenate(ir))
+			return ir
+		end
+
+		local function flatten(t)
+			if t == nil then
+				return ''
+			elseif type(t) == 'string' then
+				return t
+			elseif type(t) == 'table' then
+				if t[1] then
+					return catenate(ss.map(flatten, t))
+				elseif t.tag then
+					return tag(t.tag, t.attrs or {}, flatten(t.nodes))
+				elseif t.elt then
+					return tag(t.elt, t.attrs or {})
+				end
+			end
+		end
+
+		function block_renderers.embed(b,s)
+			local obj
+			if b.rsrc
+				then obj = b.rsrc
+				else obj = b.origin:ref(b.ref)
+			end
+			local function htmlURI(u)
+				local family = u:canfetch()
+				if  family == 'file' or
+					(family == 'http' and u.namespace == nil) then
+					-- TODO asset:
+					return u.path
+				else
+					return tostring(u)
+				end
+			end
+			local function uriForSource(s)
+				if s.mode == 'link' or s.mode == 'auto' then
+					return htmlURI(s.uri)
+				elseif s.mode == 'embed' then
+					local mime = s.mime:clone()
+					mime.opts = {}
+					return string.format('data:%s;base64,%s', mime, ss.str.b64e(s.raw))
+				end
+			end
+			--figure out how to embed the given object
+			local embedActs = {
+				{ss.mime'image/*',       function(s,ctr)
+					if s == nil then
+						return {tag = "picture", nodes = {}}
+					else
+						local uri = uriForSource(s)
+						local fbimg, idx
+						if next(ctr.nodes) == nil then
+							idx = 1
+							fbimg = {
+								elt = 'img'; --fallback
+								attrs = {
+									alt = '';
+									src = uri;
+								};
+							}
+						else idx = #ctr.nodes end
+						table.insert(ctr.nodes, idx, {
+							elt = 'source'; --fallback
+							attrs = { srcset = uri; };
+						})
+						if fbimg then
+							table.insert(ctr.nodes,fbimg)
+						else
+							-- fallback 
 should be lowest-prio image
+							ctr.nodes[#ctr.nodes].attrs.src = uri;
+						end
+					end
+				end};
+				{ss.mime'text/x.cortav', function(s,ctr)
+					if s == nil then
+						return {}
+					elseif next(ctr) == nil then
+						if (s.mode == 'embed' or s.mode == 'auto') and s.doc then
+							ctr.tag = 'div'; -- kinda hacky, maybe fix
+							ctr.nodes = renderSubdoc(s.doc)
+						elseif s.mode == 'link' then
+							-- yeah this is not gonna work my dude
+							ctr.elt = 'embed';
+							ctr.attrs = {
+								type = 'text/x.cortav';
+								src = htmlURI(s.uri);
+							}
+						end
+					end
+				end};
+				{ss.mime'text/html',     function(s,ctr)
+					if s == nil then
+						return {}
+					elseif next(ctr) == nil then
+						if (s.mode == 'embed' or s.mode == 'auto') and s.raw then
+							ctr.tag = 'div'
+							ctr.nodes = s.raw
+						elseif s.mode == 'link' then
+							ctr.elt = 'embed';
+							ctr.attrs = {
+								type = 'text/html';
+								src = htmlURI(s.uri);
+							}
+						end
+					end
+				end};
+				{ss.mime'text/*',     function(s,ctr)
+					if s == nil then
+						return {}
+					elseif next(ctr) == nil then
+						local mime = s.mime:clone()
+						mime.opts={}
+						if (s.mode == 'embed' or s.mode == 'auto') and s.raw then
+							ctr.tag = 'pre';
+							ctr.nodes = s.raw
+						elseif s.mode == 'link' then
+							ctr.elt = 'embed';
+							ctr.attrs = {
+								type = tostring(mime);
+								src = htmlURI(s.uri);
+							}
+						end
+					end
+				end};
+			}
+
+			local rtype
+			local fallback
+			for n, src in ipairs(obj.srcs) do
+				if fallback == nil and (src.mode == 'link' or src.mode == 'auto') then
+					fallback = src
+				end
+
+				for i, ea in ipairs(embedActs) do
+					if ea[1] < src.mime then -- fits!
+						rtype = ea
+						goto compatFound
+					end
+				end
+			end
+			-- nothing found; install fallback link
+				if fallback then
+					local lnk = htmlURI(fallback.uri)
+					return tag('a', {href=lnk},
+								  tag('div',{class=xref},
+										string.format("ā %s [%s]", b.cap or '', tostring(fallback.mime))))
+				else
+					addStyle 'docmeta'
+					return tag('div',{class="render-warn"},
+								  'could not embed object type ' .. tostring(obj.srcs.mime))
+				end
+
+			::compatFound::
+			local top = rtype[2]() -- create container
+			for n, src in ipairs(obj.srcs) do
+				if rtype[1] < src.mime then
+					rtype[2](src, top)
+				end
+			end
+			local ft = flatten(top)
+			return ft
+		end
+
+		function block_renderers.macro(b,s)
+			local all = renderSubdoc(b.doc)
+			local cat = catenate(ss.map(flatten,all))
+			return tag('div', {}, cat)
+		end
+
+		function block_renderers.quote(b,s)
+			local ir = renderSubdoc(b.doc)
+			return tag('blockquote', b.id and {id=getSafeID(b)} or {}, catenate(ss.map(flatten,ir)))
 		end
 
-		return block_renderers;
+		return block_renderers
 	end
 
 	local function getRenderers(procs)
 		local span_renderers = getSpanRenderers(procs)
 		local r = getBaseRenderers(procs,span_renderers)
Index: sirsem.lua
==================================================================
--- sirsem.lua
+++ sirsem.lua
@@ -1,11 +1,11 @@
 -- [Ź] sirsem.lua
 --  ~ lexi hale 
 --    glowpelt (hsl conversion)
 --  ? utility library with functionality common to
 --    cortav.lua and its extensions
---    from Ranuir "software utility"
+--  \ from Ranuir "software utility"
 --  > local ss = require 'sirsem.lua'
 
 local ss
 do -- pull ourselves up by our own bootstraps
 	local package = _G.package
@@ -475,10 +475,62 @@
 			end
 		end
 	::skip::end
 	return tbl[bestmatch] or tbl[true], bestmatch
 end
+
+function ss.str.b64e(str)
+	local bytes = {}
+	local n = 1
+	for i=1, #str, 3 do
+		local triple = {string.byte(str, i, i+2)}
+		local T = function(q)
+			return triple[q] or 0
+		end
+		local B = function(q)
+		print(q)
+			if q <= 25 then
+				return string.char(0x41 + q)
+			elseif q <= 51 then
+				return string.char(0x61 + (q-26))
+			elseif q <= 61 then
+				return string.char(0x30 + (q-52))
+			elseif q == 62 then
+				return '+'
+			elseif q == 63 then
+				return '/'
+			else error('base64 algorithm broken') end
+		end
+		local quads = {
+			((T(1) & 0xFC) >> 2);
+			((T(1) & 0x03) << 4) | ((T(2) & 0xF0) >> 4);
+			((T(2) & 0x0F) << 2) | ((T(3) & 0xC0) >> 6);
+			((T(3) & 0x3F));
+		}
+
+		bytes[n + 0] = B(quads[1])
+		bytes[n + 1] = B(quads[2])
+		if triple[2] then
+			bytes[n + 2] = B(quads[3])
+			if triple[3] then
+				bytes[n + 3] = B(quads[4])
+			else
+				bytes[n + 3] = '='
+			end
+		else
+			bytes[n + 2] = '='
+			bytes[n + 3] = '='
+		end
+
+		n = n + 4
+	end
+
+	return table.concat(bytes)
+end
+
+function ss.str.b64d(str)
+end
 
 ss.math = {}
 
 function ss.math.lerp(t, a, b)
 	return (1-t)*a + (t*b)
@@ -1112,10 +1164,215 @@
 		else
 			return s
 		end
 	end, ...))
 end
+
+local fetchexn = ss.exnkind 'fetch'
+local fetchableProtocols = {
+	http = {
+		proto = {
+			{'http'};
+			{'https'};
+			{'http', 'tls'};
+		};
+		fetch = function(uri)
+			fetchexn('cortav must be compiled with the C shim and libcurl support to use http fetch'):throw()
+		end;
+	};
+	file = {
+		proto = {
+			{'file'};
+			{'file', 'txt'};
+			{'file', 'bin'};
+			{'asset'};
+			{'asset', 'txt'};
+			{'asset', 'bin'};
+		};
+		fetch = function(uri, env)
+			local assetDir = env.asset_base or '.'
+			if uri.namespace then
+				fetchexn('authority (hostname) segment is not supported in file: URIs'):throw()
+			end
+			if uri.svc then
+				fetchexn('service segment is not supported in file: URIs'):throw()
+			end
+			local mode = 'r'
+			local path = uri.path
+			if uri.class[1] == 'asset' then path = assetDir ..'/'.. path end
+			if uri.class[2] == 'bin'   then mode = 'rb' end
+			local fd,e = io.open(path, mode)
+			if not fd then
+				fetchexn('IO error fetching URI ā%sā (%s)', tostring(uri), e):throw()
+			end
+			local data = fd:read '*a'
+			fd:close()
+			return data
+		end;
+	};
+}
+
+function ss.match(a,b, eq)
+	if #a ~= #b then return false end
+	eq = eq or function(p,q) return p == q end
+	for i = 1, #a do
+		if not eq(a[i],b[i]) then return false end
+	end
+	return true
+end
+
+ss.uri = ss.declare {
+	ident = 'uri';
+	mk = function() return {
+		class = nil;
+		namespace = nil;
+		path = nil;
+		query = nil;
+		frag = nil;
+		auth = nil;
+	} end;
+	construct = function(me, str)
+		local enc = ss.str.enc.utf8
+		-- URIs must be either ASCII or utf8, so we  read and
+		-- store as UTF8. to use a URI in another encoding, it
+		-- must be manually converted to and fro using the
+		-- appropriate functions, such as encodeUCS
+		if not str then return end
+		me.raw = str
+		local rem = str
+		local s_class do
+			local s,r = rem:match '^([^:]+):(.*)$'
+			s_class, rem = s,r
+		end
+		if not rem then
+			ss.uri.exn('invalid URI ā%sā', str):throw()
+		end
+		local s_ns do
+			local s,r = rem:match '^//([^/]*)(.*)$'
+			if s then s_ns, rem = s,r end
+		end
+		local h_query
+		local s_frag
+		local s_path if rem ~= '' then
+			local s,q,r = rem:match '^([^?#]*)([?#]?)(.*)$'
+			if s == '' then s = nil end
+			s_path, rem = s,r
+
+			if q == '#' then
+				s_frag = rem
+			elseif q == '?' then
+				h_query = true
+			end
+		else s_path = '' end
+
+		local s_query if h_query then
+			local s,q,r = rem:match '^([^#]*)(#?)(.*)$'
+			s_query, rem = s,r
+			if q~='' then s_frag = rem end
+		end
+
+		local function dec(str)
+			if not str then return end
+			return str:gsub('%%([0-9A-Fa-f][0-9A-Fa-f])', function(hex)
+				return string.char(tonumber(hex,16))
+			end)
+		end
+
+		local s_auth if s_ns then
+			local s,r = s_ns:match('^([^@]*)@(.*)$')
+			if s then
+				s_ns = r
+				if s ~= '' then
+					 s_auth = s
+				end
+			end
+		end
+
+		local s_svc if s_ns then
+			local r,s = s_ns:match('^(.*):(.-)$')
+			if r then
+				s_ns = r
+				if s and s ~= '' then
+					s_svc = s
+				end
+			end
+		end
+
+		me.class = ss.str.split(enc, s_class, '+', {keep_empties=true})
+		for i,v in ipairs(me.class) do me.class[i] = dec(v) end
+		me.auth = dec(s_auth)
+		me.svc = dec(s_svc)
+		me.namespace = dec(s_ns)
+		me.path = dec(s_path)
+		me.query = dec(s_query)
+		me.frag = dec(s_frag)
+	end;
+	cast = {
+		string = function(me)
+			local function san(str, chars)
+				-- TODO IRI support
+				chars = chars or ''
+				local ptn = '-a-zA-Z0-9_.,;'
+				ptn = ptn .. chars
+				return (str:gsub('[^'..ptn..']', function(c)
+					if c == ' ' then return '+' end
+					return string.format('%%%02X', string.byte(c))
+				end))
+			end
+			if me.class == nil or next(me.class) == nil then
+				return 'none:'
+			end
+			local parts = {
+				table.concat(ss.map(san,me.class), '+') .. ':';
+			}
+			if me.namespace or me.auth or me.svc then
+				table.insert(parts, '//')
+				if me.auth then
+					table.insert(parts, san(me.auth,':') .. '@')
+				end
+				if me.namespace then
+					table.insert(parts, san(me.namespace))
+				end
+				if me.svc then
+					table.insert(parts, ':' .. san(me.svc))
+				end
+				if me.path and not ss.str.begins(me.path, '/') then
+					table.insert(parts, '/')
+				end
+			end
+			if me.path then
+				table.insert(parts, san(me.path,'+/=&'))
+			end
+			if me.query then
+				table.insert(parts, '?' .. san(me.query,'?+/=&'))
+			end
+			if me.frag then
+				table.insert(parts, '#' .. san(me.frag,'+/=&'))
+			end
+			return table.concat(parts)
+		end;
+	};
+	fns = {
+		canfetch = function(me)
+			for id, pr in pairs(fetchableProtocols) do
+				for _, p in ipairs(pr.proto) do
+					if ss.match(me.class, p) then return id end
+				end
+			end
+			return false
+		end;
+		fetch = function(me, env)
+			local pid = me:canfetch()
+			if (not pid) or fetchableProtocols[pid].fetch == nil then
+				ss.uri.exn("URI ā%sā is unfetchable", tostring(me)):throw()
+			end
+			local proto = fetchableProtocols[pid]
+			return proto.fetch(me, env or {})
+		end;
+	};
+}
+ss.uri.exn = ss.exnkind 'URI'
 
 ss.mime = ss.declare {
 	ident = 'mime-type';
 	mk = function() return {
 		class = nil;