Skip to content

How the export pipeline works ​

  1. The lesson state ({ title, sections: [{ name, blocks: [...] }] }) is turned into a docx Document in @spelling-creator/core/browser/docxExport.
  2. DOCX export packs that document to a Blob and downloads it.
  3. PDF print (@spelling-creator/core/browser/pdfExport) packs the same document, converts it to HTML with mammoth, applies print styles, and renders it to PDF with html2pdf.js. Using one shared document builder keeps the two outputs in sync.
  4. Import (browser/docxImport) is the same machinery in the other direction (mammoth again, this time reading a user's file), and Save to Google Docs uploads the very same .docx.

What a printed lesson looks like ​

The exported lesson is laid out as a finished worksheet, not as an outline of the editor:

  • A centred title block on the first page: the title, then By {author}, then the lesson's age range and release month when it has them. All of it is derived from the lesson (doc.ageRange) and the record it was published from; nothing about any particular publisher is baked in, and a lesson with no author or publication date simply prints without those lines.
  • No section headings. A section's name is an organising device for the editor; the printed lesson runs straight through from one block to the next.
  • Questions marked by colour alone: the prompt in its type's colour, its answer in black on the same line, with no [Label] in front of it. The one exception is Suggested answers, which shares the amber of Multiple answers and is set in italic to separate them (see Question blocks).
  • Spelling words on one running line, Spell: FIRST SECOND THIRD.
  • A footer on every page: the copyright line above a legend naming each question type in its own colour (and its own italic, where it has one), which is what makes the colour coding legible. The legend prints each type's label, except Would you rather, which it abbreviates to W.Y.R. so the line still fits. VAKT activities are deliberately not in the legend: they aren't questions, and their VAKT: label already names them.
  • A page number in the top right corner.

lessonTitleLines and lessonCopyright in @spelling-creator/core/lessonLayout decide the text of the title block and the footer once, so the DOCX and the PDF print the same strings.

Passing the by-line in ​

The author and publication date live on the lesson record, not in the document, so both exporters take them as a second argument:

js
await exportDocx(doc, { author: lesson.author, published: lesson.createdAt });

Omit it and the by-line and copyright line are left out; the editor passes the signed-in user's display name, since a draft has no author of its own yet.

Colour and alignment have to be smuggled past mammoth ​

mammoth drops run colours and paragraph alignment, so on the PDF path neither the question colour coding nor the centred title lines survive on their own. The docx therefore marks both with named Word styles (a character style per question type and one paragraph style for the title lines), which pdfExport maps onto CSS classes with a styleMap and colours again from questions.js.

That is also how import recovers a question's type now that nothing in the visible text names it: docxImport asks mammoth for the same style map and reads the type off the <span class="s2c-q-…">. Section divisions have nothing left to carry them, so a DOCX round trip collapses a lesson into a single section; Export/Import JSON is the lossless one. A document that was never exported from here at all, a hand-typed page or a Word file written elsewhere, goes through Import from text instead, which reads the lesson by its structure rather than by styles. Export/Import JSON carries the title, the age range, the lesson's sources and every section. The one thing it leaves out on purpose is the lesson's trusted-collaborator list, because that is a list of email addresses and a lesson file is something people pass around. On import, an age range the editor doesn't offer is dropped and the lesson reads as "any age".

Formatting, footnotes and sources ​

A text block's bold, italics and underlining become Word run formatting, and its footnotes become real Word footnotes, numbered in reading order at the foot of each page. The lesson's sources close the document under a "Sources" line, written with named paragraph styles rather than as a heading, because the importer reads headings as section breaks. mammoth drops underlining unless its style map asks for u => u, which both pdfExport and docxImport do.

mammoth turns Word footnotes into a numbered list at the very end of its HTML, after the Sources list, with a back arrow on each. A page has no foot to put them at, so layoutNotes in pdfExport moves the list above the Sources list, heads it "Notes" and drops the arrows. On import, the footnotes come back as footnotes and the Sources list back into the lesson's sources. See Formatting, footnotes & sources.

The page number and the footer are drawn straight onto the finished PDF pages with jsPDF: they repeat on every page, so they cannot come from the flowed HTML, and mammoth converts only the document body, never the docx's own header and footer.

Building a Word file is for Word files only ​

Those four are the whole list. Nothing else in the app touches docx or mammoth; in particular, preview does not. The editor's preview mode renders the lesson model directly with LessonView (the same component the public /hub/:id page uses), so previewing builds no document, waits on no chunk, and shows exactly what a reader will see. (It used to build a docx and convert it back to HTML with mammoth just to fill a dialog.)

Keep it that way: a new "show me the lesson" surface should render LessonView, not the export pipeline.

On screen it follows the theme ​

LessonView draws a lesson in the app's own theme, light or dark, on both surfaces that show one (the public lesson page and the editor's preview mode), exactly as interactive mode does. It keeps the export's measurements (the fitWithin image maths against DOCX_MAX_IMAGE_WIDTH, the spacing) and its block layout (no section headings, a question's answer inline after its coloured prompt, spelling words on one line, a VAKT activity in red under its VAKT: label), so a lesson keeps the shape it will print in; only the colours and the typeface are the theme's. What it does not copy is the paper furniture: the title block's by-line, the page numbers and the footer legend belong to the printed sheet, and the app already shows the author and the legend in its own chrome.

There is no second "paper" rendering to keep in sync. A lesson is read on screen far more often than it is printed, and a white sheet glaring out of a dark page is the wrong default for reading; the printout look lives in the thing that actually prints, the DOCX and PDF exports.

Question-type and spelling colours stay literal there, as they are in the editor and in interactive mode: they're content (the same colour coding the docx carries) rather than chrome.

It loads on demand ​

Together those libraries (docx, mammoth, html2pdf.js, html2canvas, jszip) are the largest single cluster in the dependency graph: about 390 kB gzipped. None of it is needed until someone clicks Export, Print, Import or Save to Drive, so it lives in its own chunk behind src/lib/exports/load.js, in the same shape as the git engine:

js
const { exportDocx } = await loadExportEngine();
await exportDocx(doc);

src/lib/exports/engine.js is the chunk; nothing imports it directly. It's one chunk rather than four because the entry points share nearly all their weight: the PDF path builds the docx and converts it with mammoth, which is also what the importer uses.

The constants trap ​

DOCX_MAX_IMAGE_WIDTH lives in @spelling-creator/core/lessonLayout, not beside the code that first needed it. It used to sit in browser/docxExport, which meant LessonView.jsx (wanting one number, and rendering on the public /hub/:id page) pulled the entire Word toolchain into the bundle every visitor downloaded.

Being outside browser/ is also what lets the server render a lesson at all: that tier needs a DOM and is unreachable from the Worker by design, and /hub/:id is server-rendered.

If you add a shared constant to any of these modules, put it in lessonLayout and re-export it, rather than importing the module for the constant's sake.

Copyright © 2026 Spelling Creator.