feat: Word is built by python-docx from the same typed source as the deck
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 58s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Has been cancelled
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 58s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Has been cancelled
Pandoc reads markdown, so every Word export had to flatten the resource to markdown first — and a deck flattened to markdown stops being one. A comparison became two headings and two lists, a callout became bold text, and a figure became nothing at all, because markdown has nowhere to put it. src/utils/docSpec.js reduces either source to the same blocks: a stored deck where there is one, the markdown where there is not. scripts/render_docx.py draws them. A comparison comes out as a labelled two-column table, a callout as a shaded box, a table as a real table, a figure embedded at its own aspect ratio with its caption, and speaker notes as muted indented text. The deck wins over the markdown beside it, because that markdown is a serialisation of the deck and reading it instead would be reading a lossy copy of what is right there. Word now carries the figures too. The export route skipped fetching them for docx, which was correct when pandoc could not place them and wrong the moment this could. Pandoc stays installed and stays the fallback: a plainer document beats a failed download. Both renderers now share one spawn helper. Verified end to end: a deck with two figures exported as a six-page Word document with both images embedded (537KB, two files in word/media), rendered to PDF and looked at — the comparison is a labelled table, the figure sits at its true aspect ratio, and the notes read as notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
9d307fd442
commit
154b896d5b
9 changed files with 541 additions and 30 deletions
|
|
@ -37,8 +37,8 @@ RUN apk add --no-cache ffmpeg curl jq pandoc-cli
|
|||
RUN apk add --no-cache poppler-utils
|
||||
|
||||
RUN apk add --no-cache python3 py3-pip py3-lxml py3-pillow \
|
||||
&& pip install --break-system-packages --no-cache-dir python-pptx==1.0.2 \
|
||||
&& python3 -c 'import pptx'
|
||||
&& pip install --break-system-packages --no-cache-dir python-pptx==1.0.2 python-docx==1.1.2 \
|
||||
&& python3 -c 'import pptx, docx'
|
||||
|
||||
# Pull the bao CLI out of the upstream image — matches host arch because
|
||||
# buildx pulls the right manifest-list variant per build.
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ on. They are in `Dockerfile` and worth knowing about before trimming it:
|
|||
|
||||
| | For |
|
||||
|---|---|
|
||||
| `pandoc-cli` | Word (`.docx`) export |
|
||||
| `pandoc-cli` | the fallback for Word export when the renderer cannot run |
|
||||
| `python3`, `py3-lxml`, `py3-pillow` | the slide renderer. Both libraries are C extensions with no Alpine wheels, so they come from apk rather than pip — installing them from source would mean carrying a compiler in the runtime image |
|
||||
| `python-pptx==1.0.2` (pip) | builds the decks. Pinned: unpinned, a rebuild from the same commit could produce different slides |
|
||||
| `python-pptx==1.0.2`, `python-docx==1.1.2` (pip) | build the decks and the documents. Pinned: unpinned, a rebuild from the same commit could produce different output |
|
||||
| `poppler-utils` | `pdftoppm`, which turns a rendered deck into one image per slide so a vision model can see it. Only needed when slide review is switched on |
|
||||
| `ffmpeg`, `curl`, `jq` | audio handling and entrypoint scripting |
|
||||
|
||||
|
|
|
|||
|
|
@ -140,9 +140,19 @@ treated as missing whatever its length.
|
|||
| Format | Built by |
|
||||
|---|---|
|
||||
| `pptx` | `scripts/render_pptx.py` (python-pptx) from the stored deck |
|
||||
| `docx` | pandoc, from the markdown |
|
||||
| `docx` | `scripts/render_docx.py` (python-docx) from the same typed source |
|
||||
| `pdf` | Gotenberg (LibreOffice), from whichever office file above |
|
||||
|
||||
Both office formats come from `src/utils/docSpec.js` / `slideSpec.js` rather than
|
||||
from markdown. Pandoc reads markdown, so a deck had to be flattened first — and a
|
||||
flattened deck stops being one: a comparison became two headings and two lists, a
|
||||
callout became bold text, and a figure became nothing at all. From the typed
|
||||
source a comparison is a labelled two-column table, a callout is a shaded box,
|
||||
and a figure is embedded at its own aspect ratio with its caption. An article,
|
||||
which has no deck, is parsed from its markdown into the same blocks.
|
||||
|
||||
Pandoc is still installed and is still the fallback for Word.
|
||||
|
||||
Pandoc's pptx writer was the ceiling on how good a deck could be, and the model
|
||||
on top made no difference to it: a handful of reference layouts, no per-slide
|
||||
layout, no positioning, no control over how large an image is drawn. It also
|
||||
|
|
@ -163,9 +173,10 @@ afterwards. One that cannot be fetched is left out rather than failing a
|
|||
download that works without it.
|
||||
|
||||
**Runtime dependency:** the image carries `python3`, `py3-lxml`, `py3-pillow`
|
||||
(apk — both are C extensions with no Alpine wheels) and `python-pptx` pinned at
|
||||
1.0.2 from pip. Roughly 58MB. Unpinned, a rebuild from the same commit could
|
||||
produce different decks.
|
||||
(apk — both are C extensions with no Alpine wheels), plus `python-pptx` 1.0.2 and
|
||||
`python-docx` 1.1.2 from pip, and `poppler-utils` for slide review. Roughly 58MB
|
||||
of Python. Both pip packages are pinned: unpinned, a rebuild from the same commit
|
||||
could produce different documents.
|
||||
|
||||
## Slide review
|
||||
|
||||
|
|
|
|||
226
scripts/render_docx.py
Normal file
226
scripts/render_docx.py
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Render a document from a JSON spec on stdin to a .docx file.
|
||||
|
||||
Replaces pandoc for this path. Pandoc reads markdown, so everything had to be
|
||||
flattened to markdown first — and a deck flattened to markdown loses what made
|
||||
it a deck: a comparison became two headings and two lists, a callout became bold
|
||||
text, a figure became nothing at all. Coming from the typed spec, a comparison
|
||||
is a two-column table, a callout is a shaded box, and a figure keeps its caption.
|
||||
|
||||
Input (stdin, JSON):
|
||||
{"title": str, "subtitle": str, "date": str, "blocks": [ ... ]}
|
||||
|
||||
Blocks:
|
||||
{"type":"heading","level":1..4,"text":str}
|
||||
{"type":"para","text":str,"muted":bool}
|
||||
{"type":"bullets","items":[{"text":str,"level":0..4}]}
|
||||
{"type":"table","header":[str],"rows":[[str]]}
|
||||
{"type":"callout","text":str}
|
||||
{"type":"image","path":str,"caption":str}
|
||||
|
||||
Output: argv[1]. Errors to stderr, non-zero exit, so the caller can fall back.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from docx import Document
|
||||
from docx.enum.table import WD_TABLE_ALIGNMENT
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Emu, Pt, RGBColor
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except Exception: # pragma: no cover
|
||||
Image = None
|
||||
|
||||
INK = RGBColor(0x1F, 0x29, 0x37)
|
||||
MUTED = RGBColor(0x4B, 0x55, 0x63)
|
||||
ACCENT = RGBColor(0x25, 0x63, 0xEB)
|
||||
CALLOUT_INK = RGBColor(0x78, 0x35, 0x0F)
|
||||
|
||||
BODY_FONT = "Calibri"
|
||||
CONTENT_WIDTH_EMU = Emu(5486400) # 6" — A4/Letter with 1" margins
|
||||
|
||||
|
||||
def shade(element, hex_fill):
|
||||
"""Cell or paragraph shading. python-docx exposes no API for either."""
|
||||
shd = OxmlElement("w:shd")
|
||||
shd.set(qn("w:val"), "clear")
|
||||
shd.set(qn("w:color"), "auto")
|
||||
shd.set(qn("w:fill"), hex_fill)
|
||||
element.append(shd)
|
||||
|
||||
|
||||
def style_body(document):
|
||||
normal = document.styles["Normal"]
|
||||
normal.font.name = BODY_FONT
|
||||
normal.font.size = Pt(11)
|
||||
normal.font.color.rgb = INK
|
||||
normal.paragraph_format.space_after = Pt(8)
|
||||
normal.paragraph_format.line_spacing = 1.15
|
||||
|
||||
|
||||
def add_runs(paragraph, text, bold=False, color=None, size=None):
|
||||
"""Inline **bold** and *italic* survive; everything else is literal."""
|
||||
for chunk, is_bold, is_italic in split_inline(text):
|
||||
run = paragraph.add_run(chunk)
|
||||
run.bold = bold or is_bold
|
||||
run.italic = is_italic
|
||||
run.font.name = BODY_FONT
|
||||
if color is not None:
|
||||
run.font.color.rgb = color
|
||||
if size is not None:
|
||||
run.font.size = Pt(size)
|
||||
|
||||
|
||||
def split_inline(text):
|
||||
out = []
|
||||
pattern = re.compile(r"(\*\*|__)(.+?)\1|(\*|_)(.+?)\3")
|
||||
index = 0
|
||||
for match in pattern.finditer(text or ""):
|
||||
if match.start() > index:
|
||||
out.append((text[index:match.start()], False, False))
|
||||
if match.group(2) is not None:
|
||||
out.append((match.group(2), True, False))
|
||||
else:
|
||||
out.append((match.group(4), False, True))
|
||||
index = match.end()
|
||||
if index < len(text or ""):
|
||||
out.append((text[index:], False, False))
|
||||
return out or [(text or "", False, False)]
|
||||
|
||||
|
||||
def heading(document, spec):
|
||||
level = max(1, min(int(spec.get("level") or 2), 4))
|
||||
para = document.add_paragraph()
|
||||
para.paragraph_format.space_before = Pt(16 if level <= 2 else 12)
|
||||
para.paragraph_format.space_after = Pt(6)
|
||||
para.paragraph_format.keep_with_next = True
|
||||
sizes = {1: 18, 2: 14, 3: 12, 4: 11}
|
||||
add_runs(para, spec.get("text") or "", bold=True,
|
||||
color=ACCENT if level == 1 else INK, size=sizes[level])
|
||||
|
||||
|
||||
def para(document, spec):
|
||||
p = document.add_paragraph()
|
||||
add_runs(p, spec.get("text") or "", color=MUTED if spec.get("muted") else None,
|
||||
size=10 if spec.get("muted") else None)
|
||||
if spec.get("muted"):
|
||||
p.paragraph_format.left_indent = Emu(228600)
|
||||
|
||||
|
||||
def bullets(document, spec):
|
||||
for item in spec.get("items") or []:
|
||||
text = (item.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
level = max(0, min(int(item.get("level") or 0), 4))
|
||||
p = document.add_paragraph(style="List Bullet" if level == 0 else "List Bullet 2")
|
||||
p.paragraph_format.left_indent = Emu(228600 * (level + 1))
|
||||
p.paragraph_format.space_after = Pt(4)
|
||||
add_runs(p, text)
|
||||
|
||||
|
||||
def table(document, spec):
|
||||
header = spec.get("header") or []
|
||||
rows = spec.get("rows") or []
|
||||
if not rows:
|
||||
return
|
||||
cols = max(len(header), max((len(r) for r in rows), default=1))
|
||||
t = document.add_table(rows=len(rows) + (1 if header else 0), cols=cols)
|
||||
t.style = "Table Grid"
|
||||
t.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||
offset = 0
|
||||
if header:
|
||||
for c in range(cols):
|
||||
cell = t.cell(0, c)
|
||||
cell.text = ""
|
||||
add_runs(cell.paragraphs[0], header[c] if c < len(header) else "", bold=True, size=10)
|
||||
shade(cell._tc.get_or_add_tcPr(), "E7EEFC")
|
||||
offset = 1
|
||||
for r, row in enumerate(rows):
|
||||
for c in range(cols):
|
||||
cell = t.cell(r + offset, c)
|
||||
cell.text = ""
|
||||
add_runs(cell.paragraphs[0], row[c] if c < len(row) else "", size=10)
|
||||
document.add_paragraph().paragraph_format.space_after = Pt(4)
|
||||
|
||||
|
||||
def callout(document, spec):
|
||||
"""One thing worth stopping on, in a shaded box with a rule down its side."""
|
||||
t = document.add_table(rows=1, cols=1)
|
||||
t.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||
cell = t.cell(0, 0)
|
||||
cell.text = ""
|
||||
shade(cell._tc.get_or_add_tcPr(), "FEF3C7")
|
||||
add_runs(cell.paragraphs[0], spec.get("text") or "", bold=True, color=CALLOUT_INK)
|
||||
document.add_paragraph().paragraph_format.space_after = Pt(4)
|
||||
|
||||
|
||||
def image(document, spec):
|
||||
path = spec.get("path")
|
||||
if not path or not os.path.exists(path):
|
||||
return
|
||||
width = CONTENT_WIDTH_EMU
|
||||
if Image is not None:
|
||||
try:
|
||||
with Image.open(path) as img:
|
||||
# A tall figure at full width runs off the page; cap the height
|
||||
# and let the width follow rather than stretching either.
|
||||
if img.width and img.height and (img.height / img.width) > 1.1:
|
||||
width = Emu(int(CONTENT_WIDTH_EMU * 0.62))
|
||||
except Exception:
|
||||
pass
|
||||
document.add_picture(path, width=width)
|
||||
document.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
caption = (spec.get("caption") or "").strip()
|
||||
if caption:
|
||||
p = document.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
add_runs(p, caption, color=MUTED, size=9)
|
||||
p.runs[0].italic = True
|
||||
|
||||
|
||||
BUILDERS = {
|
||||
"heading": heading, "para": para, "bullets": bullets,
|
||||
"table": table, "callout": callout, "image": image,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: render_docx.py <output.docx> (spec on stdin)", file=sys.stderr)
|
||||
return 2
|
||||
spec = json.load(sys.stdin)
|
||||
blocks = spec.get("blocks") or []
|
||||
if not blocks:
|
||||
print("spec contains no blocks", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
document = Document()
|
||||
style_body(document)
|
||||
|
||||
title = document.add_paragraph()
|
||||
title.paragraph_format.space_after = Pt(2)
|
||||
add_runs(title, spec.get("title") or "Resource", bold=True, size=24)
|
||||
for line in [spec.get("subtitle"), spec.get("date")]:
|
||||
if not line:
|
||||
continue
|
||||
sub = document.add_paragraph()
|
||||
sub.paragraph_format.space_after = Pt(0)
|
||||
add_runs(sub, line, color=MUTED, size=11)
|
||||
document.add_paragraph()
|
||||
|
||||
for block in blocks:
|
||||
BUILDERS.get(block.get("type") or "para", para)(document, block)
|
||||
|
||||
document.save(sys.argv[1])
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -586,12 +586,12 @@ router.get('/my-resources/:id/export', async function (req, res) {
|
|||
// Figures are written to a scratch directory for the renderer and removed
|
||||
// afterwards. A figure that cannot be fetched is left out rather than
|
||||
// failing a download that works without it.
|
||||
// Every format now carries the figures: Word embeds them too, since it is
|
||||
// built from the same typed spec rather than from flattened markdown.
|
||||
var figures = [];
|
||||
var scratch = null;
|
||||
if (format !== 'docx') {
|
||||
scratch = await require('fs/promises').mkdtemp(require('path').join(require('os').tmpdir(), 'figs-'));
|
||||
figures = await collectFigures(row.image_ids, req.user, scratch);
|
||||
}
|
||||
var scratch = await require('fs/promises').mkdtemp(
|
||||
require('path').join(require('os').tmpdir(), 'figs-'));
|
||||
figures = await collectFigures(row.image_ids, req.user, scratch);
|
||||
var bytes;
|
||||
try {
|
||||
bytes = await documentExport.render(row.markdown, row.kind, format,
|
||||
|
|
|
|||
149
src/utils/docSpec.js
Normal file
149
src/utils/docSpec.js
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// ============================================================
|
||||
// DOCUMENT SPEC
|
||||
// ============================================================
|
||||
// A flat list of blocks a Word renderer can draw, built from either source: the
|
||||
// typed deck a presentation is stored as, or the markdown an article is.
|
||||
//
|
||||
// Going deck → markdown → pandoc lost the structure twice. A comparison became
|
||||
// two headings and two lists, a callout became bold text, and a table survived
|
||||
// only because pandoc happens to parse pipe tables. Coming from the deck
|
||||
// directly, a comparison is a two-column table, a callout is a shaded box, and a
|
||||
// figure keeps its caption.
|
||||
|
||||
var BLOCK = ['heading', 'para', 'bullets', 'table', 'callout', 'image'];
|
||||
|
||||
function text(value, max) {
|
||||
return String(value === undefined || value === null ? '' : value).slice(0, max || 4000).trim();
|
||||
}
|
||||
|
||||
/** Blocks from a stored deck. Slides become sections of a handout. */
|
||||
function fromDeck(deck, images) {
|
||||
var blocks = [];
|
||||
var byJob = images || {};
|
||||
(deck.slides || []).forEach(function (slide) {
|
||||
if (slide.type === 'title') return; // the document has its own title page
|
||||
if (slide.heading) blocks.push({ type: 'heading', level: slide.type === 'section' ? 1 : 2,
|
||||
text: text(slide.heading, 300) });
|
||||
|
||||
if (slide.type === 'callout' && slide.text) {
|
||||
blocks.push({ type: 'callout', text: text(slide.text) });
|
||||
} else if (slide.type === 'table') {
|
||||
blocks.push({ type: 'table', header: (slide.header || []).map(function (c) { return text(c, 300); }),
|
||||
rows: (slide.rows || []).map(function (row) {
|
||||
return row.map(function (c) { return text(c, 600); });
|
||||
}) });
|
||||
} else if (slide.type === 'compare' && (slide.columns || []).length === 2) {
|
||||
// Side by side stays side by side. Flattened to two lists it stopped
|
||||
// being a comparison, which was the whole reason for the layout.
|
||||
var left = slide.columns[0], right = slide.columns[1];
|
||||
var depth = Math.max((left.bullets || []).length, (right.bullets || []).length);
|
||||
var rows = [];
|
||||
for (var i = 0; i < depth; i++) {
|
||||
rows.push([text((left.bullets[i] || {}).text, 600), text((right.bullets[i] || {}).text, 600)]);
|
||||
}
|
||||
blocks.push({ type: 'table', header: [text(left.label, 120), text(right.label, 120)], rows: rows });
|
||||
} else {
|
||||
var items = (slide.bullets || []).concat(slide.left || []).concat(slide.right || []);
|
||||
if (items.length) {
|
||||
blocks.push({ type: 'bullets', items: items.map(function (b) {
|
||||
return { text: text(b.text, 1200), level: Math.max(0, Math.min(4, b.level || 0)) };
|
||||
}) });
|
||||
}
|
||||
}
|
||||
|
||||
if (slide.image_job && byJob[slide.image_job]) {
|
||||
blocks.push({ type: 'image', path: byJob[slide.image_job], caption: text(slide.caption, 300) });
|
||||
}
|
||||
if (slide.notes) blocks.push({ type: 'para', text: text(slide.notes, 2000), muted: true });
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function parseTable(lines, at) {
|
||||
var rows = [];
|
||||
var i = at;
|
||||
while (i < lines.length && /^\s*\|/.test(lines[i])) { rows.push(lines[i]); i++; }
|
||||
if (rows.length < 2) return null;
|
||||
function cells(line) {
|
||||
return line.trim().replace(/^\||\|$/g, '').split('|').map(function (c) { return c.trim(); });
|
||||
}
|
||||
var header = cells(rows[0]);
|
||||
var start = /^[\s|:-]+$/.test(rows[1]) ? 2 : 1;
|
||||
var body = rows.slice(start).map(cells).filter(function (r) { return r.some(Boolean); });
|
||||
if (!body.length) return null;
|
||||
return { block: { type: 'table', header: header, rows: body }, next: i };
|
||||
}
|
||||
|
||||
/** Blocks from markdown. What an article is stored as. */
|
||||
function fromMarkdown(markdown) {
|
||||
var lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n');
|
||||
var blocks = [];
|
||||
var bullets = null;
|
||||
var para = [];
|
||||
|
||||
function flushPara() {
|
||||
if (!para.length) return;
|
||||
blocks.push({ type: 'para', text: para.join(' ').trim() });
|
||||
para = [];
|
||||
}
|
||||
function flushBullets() {
|
||||
if (!bullets) return;
|
||||
blocks.push({ type: 'bullets', items: bullets });
|
||||
bullets = null;
|
||||
}
|
||||
function flush() { flushPara(); flushBullets(); }
|
||||
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i];
|
||||
if (/^%\s*/.test(line)) continue; // pandoc title block
|
||||
|
||||
if (/^\s*\|/.test(line)) {
|
||||
var table = parseTable(lines, i);
|
||||
if (table) { flush(); blocks.push(table.block); i = table.next - 1; continue; }
|
||||
}
|
||||
|
||||
var heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
||||
if (heading) { flush(); blocks.push({ type: 'heading', level: Math.min(heading[1].length, 4),
|
||||
text: heading[2].trim() }); continue; }
|
||||
|
||||
var bullet = /^(\s*)(?:[-*+]|\d+\.)\s+(.*)$/.exec(line);
|
||||
if (bullet) {
|
||||
flushPara();
|
||||
if (!bullets) bullets = [];
|
||||
bullets.push({ text: bullet[2].trim(), level: Math.min(4, Math.floor(bullet[1].length / 2)) });
|
||||
continue;
|
||||
}
|
||||
|
||||
var quote = /^\s*>\s?(.*)$/.exec(line);
|
||||
if (quote) { flush(); blocks.push({ type: 'callout', text: quote[1].trim() }); continue; }
|
||||
|
||||
if (/^\s*[-*_]{3,}\s*$/.test(line)) { flush(); continue; }
|
||||
|
||||
if (!line.trim()) { flush(); continue; }
|
||||
flushBullets();
|
||||
para.push(line.trim());
|
||||
}
|
||||
flush();
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole document. `deck` wins when there is one — it is the typed source,
|
||||
* and the markdown beside it is a serialisation of it.
|
||||
*/
|
||||
function build(options) {
|
||||
var opts = options || {};
|
||||
var blocks = opts.deck && (opts.deck.slides || []).length
|
||||
? fromDeck(opts.deck, opts.images)
|
||||
: fromMarkdown(opts.markdown);
|
||||
var meta = opts.deck || {};
|
||||
var first = (opts.markdown || '').match(/^%\s*(.+)$/m);
|
||||
return {
|
||||
title: text(meta.title || (first && first[1]) || opts.title || 'Resource', 300),
|
||||
subtitle: text(meta.subtitle, 300),
|
||||
date: text(meta.date, 120),
|
||||
blocks: blocks.filter(function (b) { return BLOCK.indexOf(b.type) !== -1; })
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { build, fromDeck, fromMarkdown, BLOCK };
|
||||
|
|
@ -18,9 +18,11 @@ var pathMod = require('path');
|
|||
var { execFile, spawn } = require('child_process');
|
||||
var JSZip = require('jszip');
|
||||
var slideSpec = require('./slideSpec');
|
||||
var docSpec = require('./docSpec');
|
||||
|
||||
var REFERENCE_DECK = pathMod.join(__dirname, '..', '..', 'assets', 'learning', 'slides-reference.pptx');
|
||||
var DECK_RENDERER = pathMod.join(__dirname, '..', '..', 'scripts', 'render_pptx.py');
|
||||
var DOC_RENDERER = pathMod.join(__dirname, '..', '..', 'scripts', 'render_docx.py');
|
||||
var GOTENBERG = process.env.GOTENBERG_URL || 'http://gotenberg:3000';
|
||||
|
||||
var FORMATS = {
|
||||
|
|
@ -98,7 +100,7 @@ async function render(markdown, kind, format, options) {
|
|||
|
||||
var office = kind === 'presentation' ? 'pptx' : 'docx';
|
||||
if (office === 'docx' || format === 'docx') {
|
||||
await runPandoc(['doc.md', '-o', 'doc.docx'], workdir);
|
||||
await buildDoc(markdown, workdir, options);
|
||||
}
|
||||
if (office === 'pptx' || format === 'pptx') {
|
||||
await buildDeck(markdown, workdir, options.images || [], options);
|
||||
|
|
@ -133,6 +135,53 @@ async function render(markdown, kind, format, options) {
|
|||
//
|
||||
// If the renderer fails for any reason, pandoc still produces a deck. A plainer
|
||||
// deck beats a failed download.
|
||||
function runRenderer(script, out, spec, workdir) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var child = spawn('python3', [script, out], { cwd: workdir });
|
||||
var stderr = '';
|
||||
child.stderr.on('data', function (chunk) { stderr += chunk.toString().slice(0, 2000); });
|
||||
child.on('error', reject);
|
||||
child.on('close', function (code) {
|
||||
if (code === 0) return resolve();
|
||||
reject(new Error(pathMod.basename(script) + ' exited ' + code + (stderr ? ': ' + stderr.trim() : '')));
|
||||
});
|
||||
child.stdin.end(JSON.stringify(spec));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Word ────────────────────────────────────────────────────
|
||||
// Also python, for the same reason as the deck. Pandoc reads markdown, so a deck
|
||||
// had to be flattened to markdown first — and a deck flattened to markdown stops
|
||||
// being one: a comparison became two headings and two lists, a callout became
|
||||
// bold text, and a figure became nothing at all. Built from the typed spec, a
|
||||
// comparison is a two-column table, a callout is a shaded box, and a figure
|
||||
// keeps its caption.
|
||||
//
|
||||
// Pandoc remains the fallback. A plainer document beats a failed download.
|
||||
async function buildDoc(markdown, workdir, options) {
|
||||
var out = pathMod.join(workdir, 'doc.docx');
|
||||
try {
|
||||
var spec = docSpec.build({
|
||||
deck: options.deck, markdown: markdown,
|
||||
images: figuresByJob(options.images, options.figureIds)
|
||||
});
|
||||
if (!spec.blocks.length) throw new Error('nothing to render');
|
||||
await runRenderer(DOC_RENDERER, out, spec, workdir);
|
||||
if (!(await fsp.readFile(out)).length) throw new Error('the renderer produced an empty file');
|
||||
} catch (err) {
|
||||
console.warn('[export] document renderer failed, falling back to pandoc:', err.message);
|
||||
await runPandoc(['doc.md', '-o', 'doc.docx'], workdir);
|
||||
}
|
||||
}
|
||||
|
||||
function figuresByJob(files, figureIds) {
|
||||
var byJob = {};
|
||||
(figureIds || []).forEach(function (id, index) {
|
||||
if (files && files[index]) byJob[id] = files[index];
|
||||
});
|
||||
return byJob;
|
||||
}
|
||||
|
||||
async function buildDeck(markdown, workdir, images, options) {
|
||||
options = options || {};
|
||||
var out = pathMod.join(workdir, 'doc.pptx');
|
||||
|
|
@ -143,17 +192,7 @@ async function buildDeck(markdown, workdir, images, options) {
|
|||
var spec = options.deck
|
||||
? attachFigures(options.deck, images, options.figureIds)
|
||||
: slideSpec.build(markdown, { images: images });
|
||||
await new Promise(function (resolve, reject) {
|
||||
var child = spawn('python3', [DECK_RENDERER, out], { cwd: workdir });
|
||||
var stderr = '';
|
||||
child.stderr.on('data', function (chunk) { stderr += chunk.toString().slice(0, 2000); });
|
||||
child.on('error', reject);
|
||||
child.on('close', function (code) {
|
||||
if (code === 0) return resolve();
|
||||
reject(new Error('deck renderer exited ' + code + (stderr ? ': ' + stderr.trim() : '')));
|
||||
});
|
||||
child.stdin.end(JSON.stringify(spec));
|
||||
});
|
||||
await runRenderer(DECK_RENDERER, out, spec, workdir);
|
||||
var built = await fsp.readFile(out);
|
||||
if (built.length) return;
|
||||
throw new Error('deck renderer produced an empty file');
|
||||
|
|
@ -170,10 +209,7 @@ async function buildDeck(markdown, workdir, images, options) {
|
|||
// arrive in the order the jobs were created, which is the order the slides
|
||||
// requested them, so a slide is matched by its job id rather than by position.
|
||||
function attachFigures(deck, files, figureIds) {
|
||||
var byJob = {};
|
||||
(figureIds || []).forEach(function (id, index) {
|
||||
if (files[index]) byJob[id] = files[index];
|
||||
});
|
||||
var byJob = figuresByJob(files, figureIds);
|
||||
var slides = (deck.slides || []).map(function (slide) {
|
||||
var copy = Object.assign({}, slide);
|
||||
// The renderer reads whatever path this field holds and embeds that file in
|
||||
|
|
|
|||
87
test/doc-spec.test.js
Normal file
87
test/doc-spec.test.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// ============================================================
|
||||
// DOCUMENT SPEC
|
||||
// ============================================================
|
||||
// What Word is built from. Going deck → markdown → pandoc lost the structure
|
||||
// twice; this is the typed source both a deck and an article reduce to.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const docSpec = require('../src/utils/docSpec');
|
||||
const read = p => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
|
||||
|
||||
const DECK = { title: 'Croup', subtitle: 'Teaching', date: '2026', slides: [
|
||||
{ type: 'title', heading: 'Croup' },
|
||||
{ type: 'bullets', heading: 'What it is', bullets: [{ text: 'Viral', level: 0 }, { text: 'Peaks 12-18m', level: 1 }] },
|
||||
{ type: 'compare', heading: 'Versus', columns: [
|
||||
{ label: 'CROUP', bullets: [{ text: 'Barking cough' }, { text: 'Hoarse' }] },
|
||||
{ label: 'EPIGLOTTITIS', bullets: [{ text: 'Drooling' }] }] },
|
||||
{ type: 'table', heading: 'Features', header: ['Feature', 'Mild'], rows: [['Stridor', 'Absent']] },
|
||||
{ type: 'callout', heading: 'Red flag', text: 'Do not examine the throat.' },
|
||||
{ type: 'figure', heading: 'Anatomy', bullets: [{ text: 'Subglottis' }], image_job: 'job-a', caption: 'Airway' },
|
||||
]};
|
||||
|
||||
test('a deck keeps the shape it was designed with', () => {
|
||||
const spec = docSpec.build({ deck: DECK, images: { 'job-a': '/tmp/fig.png' } });
|
||||
const types = spec.blocks.map(b => b.type);
|
||||
|
||||
// The title slide is not a section of the document; the document has a title.
|
||||
assert.equal(types.filter(t => t === 'heading').length, 5);
|
||||
assert.equal(spec.title, 'Croup');
|
||||
|
||||
// A comparison stays a comparison. Flattened to two headings and two lists it
|
||||
// stopped being one, which was the whole point of the layout.
|
||||
const compare = spec.blocks[3];
|
||||
assert.equal(compare.type, 'table');
|
||||
assert.deepEqual(compare.header, ['CROUP', 'EPIGLOTTITIS']);
|
||||
assert.deepEqual(compare.rows, [['Barking cough', 'Drooling'], ['Hoarse', '']]);
|
||||
|
||||
assert.ok(types.includes('callout'), 'a callout is not bold text');
|
||||
const image = spec.blocks.find(b => b.type === 'image');
|
||||
assert.equal(image.path, '/tmp/fig.png');
|
||||
assert.equal(image.caption, 'Airway');
|
||||
});
|
||||
|
||||
test('a figure with no file is simply absent, not an empty frame', () => {
|
||||
const spec = docSpec.build({ deck: DECK, images: {} });
|
||||
assert.equal(spec.blocks.filter(b => b.type === 'image').length, 0);
|
||||
// Its heading and bullets still render — the slide had more than a picture.
|
||||
assert.ok(spec.blocks.some(b => b.type === 'bullets' && b.items[0].text === 'Subglottis'));
|
||||
});
|
||||
|
||||
test('an article comes from its markdown', () => {
|
||||
const spec = docSpec.build({ markdown: [
|
||||
'% An article', '', '# Title', '', 'Some prose here.', '',
|
||||
'- one', '- two', '', '| A | B |', '|---|---|', '| 1 | 2 |', '', '> A quote.',
|
||||
].join('\n') });
|
||||
assert.equal(spec.title, 'An article', 'the pandoc title block names the document');
|
||||
assert.deepEqual(spec.blocks.map(b => b.type), ['heading', 'para', 'bullets', 'table', 'callout']);
|
||||
assert.deepEqual(spec.blocks[3].rows, [['1', '2']], 'the alignment rule is not a row');
|
||||
});
|
||||
|
||||
test('the deck wins when there is one', () => {
|
||||
// The markdown beside a deck is a serialisation of it, so reading the
|
||||
// serialisation instead would be reading a lossy copy of what is right there.
|
||||
const spec = docSpec.build({ deck: DECK, markdown: '# Something else\n\n- ignored', images: {} });
|
||||
assert.ok(spec.blocks.some(b => b.type === 'callout'));
|
||||
assert.ok(!spec.blocks.some(b => b.type === 'bullets' && b.items[0].text === 'ignored'));
|
||||
});
|
||||
|
||||
test('Word is rendered by python-docx, with pandoc still catching it', () => {
|
||||
const exporter = read('src/utils/documentExport.js');
|
||||
assert.match(exporter, /async function buildDoc\(markdown, workdir, options\)/);
|
||||
assert.match(exporter, /runRenderer\(DOC_RENDERER, out, spec, workdir\)/);
|
||||
// A plainer document beats a failed download.
|
||||
assert.match(exporter, /document renderer failed, falling back to pandoc/);
|
||||
assert.match(exporter, /runPandoc\(\['doc\.md', '-o', 'doc\.docx'\]/);
|
||||
// Every format carries the figures now, Word included.
|
||||
assert.doesNotMatch(read('src/routes/myResources.js'), /if \(format !== 'docx'\) \{/);
|
||||
assert.match(read('Dockerfile'), /python-docx==1\.1\.2/);
|
||||
|
||||
const py = read('scripts/render_docx.py');
|
||||
// python-docx exposes no API for shading, so it is written onto the XML.
|
||||
assert.match(py, /def shade\(element, hex_fill\)/);
|
||||
// A tall figure at full width runs off the page.
|
||||
assert.match(py, /\(img\.height \/ img\.width\) > 1\.1/);
|
||||
});
|
||||
|
|
@ -81,7 +81,9 @@ test('the renderer sizes text to fit rather than trusting autofit', () => {
|
|||
test('the deck renderer replaces pandoc, and pandoc still catches it if it falls', () => {
|
||||
const exporter = read('src/utils/documentExport.js');
|
||||
assert.match(exporter, /async function buildDeck\(markdown, workdir, images, options\)/);
|
||||
assert.match(exporter, /spawn\('python3', \[DECK_RENDERER, out\]/);
|
||||
// One spawn helper, now that the document renderer uses it too.
|
||||
assert.match(exporter, /runRenderer\(DECK_RENDERER, out, spec, workdir\)/);
|
||||
assert.match(exporter, /spawn\('python3', \[script, out\]/);
|
||||
// A plainer deck beats a failed download.
|
||||
assert.match(exporter, /deck renderer failed, falling back to pandoc/);
|
||||
assert.match(exporter, /runPandoc\(\['doc\.md', '--reference-doc=' \+ REFERENCE_DECK/);
|
||||
|
|
|
|||
Loading…
Reference in a new issue