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;