Part of the Petrarca Project
Pulpitum is the distributed digital library front-end of the Petrarca Project: an Astro static site that turns a folder of exported document editions (HTML transcription + PDF facsimile + TEI-XML source) into a browsable, static, dependency-free digital library — a synchronized side-by-side reading room where a diplomatic HTML transcription and its original page-image facsimile stay in lockstep as you read.
This project is the standalone, directly-editable engine behind that library. It is also bundled as-is inside Praelum, the Electron app that validates a set of exported document folders and runs this engine's build for you — but nothing here depends on Praelum, and the engine can be built and run entirely on its own.
A Pulpitum demo is here.
- Data model
- Library index
- Document viewer
- HTML reading panel
- PDF panel
- Pop-out windows & cross-window sync
- Build helper
- Getting started
- Tech Stack
- Project Structure
Pulpitum reads its content from two folders at the project root, populated before running astro build (normally by exporting from Scriptorium, or by copying in already-exported document folders):
/json— one metadata file per document, named[format].[uuid].[name].json, whereformatishtml(a fully readable edition, with transcription) or another format for facsimile-only entries. Anhtmldocument's JSON declaresuuid,title,language,htmlPath,pdfPath, andxmlPath; other formats only needpdfPathandxmlPath./repo— the actual files referenced by the JSON metadata, split intohtml/,pdf/, andxml/subfolders.
Only html-format documents get a reading page generated for them (loadHtmlDocuments() in src/lib/metadata.js); at build time, each one's HTML transcription is read directly off disk and embedded into its static page, while the PDF is left to be fetched by the browser at runtime from its statically-served URL.
The homepage (src/pages/index.astro) lists every available html-format document: title, language tag, and a warning badge if the PDF or XML companion file declared in its metadata is missing. If /json is empty, it shows a hint to either run the build helper's dummy-content generator or export real documents from Scriptorium.
Each document gets its own statically pre-rendered page at /doc/[uuid] (getStaticPaths iterates every html-format entry in /json), hosting the DocumentViewer Vue island: a split-panel reading view with the HTML transcription on one side and the PDF facsimile on the other, kept in sync.
- Draggable divider — the split between the two panels can be resized by dragging (mouse, touch, or arrow keys when focused), clamped between 25% and 75% so neither panel disappears entirely.
- Bidirectional page sync — scrolling the HTML transcription past a page break moves the PDF to the matching page, and navigating the PDF scrolls the HTML transcription to the corresponding page break; each viewer tags its own updates with a
sourceidentifier so it never re-applies a page change it just caused itself (no feedback loops). - Responsive single-view mode — below a 992px-wide panel (not window — the check is based on the actual rendered panel width, since a panel in a resized split can be narrow even on a wide screen), the layout switches to one full-width panel at a time with a toggle button ("Show facsimile" / "Show text") instead of the draggable split.
- Shared toolbar (
SynchronizationControls) — back-to-index link, document title/language, a page indicator using the philological verso/recto convention (labelledV./R.), download links for the HTML/PDF/XML source files, and pop-out links to open either panel alone in its own window.
HtmlViewer is a read-only adaptation of Scriptorium's own document Viewer, reused directly rather than reimplemented: the search functionality, sidebar/TOC navigation, TEI-list browsing, header-metadata modal, and TEI processing logic are carried over largely verbatim from Scriptorium. The differences from the Scriptorium original:
- The HTML content arrives as a prop (read from
/repo/htmlat Astro build time) rather than from a Pinia store or Electron API — Pulpitum has no application state or IPC layer, since it's a static site. - Note editing is removed entirely — no note toolbar, no add/edit/delete logic. Notes already embedded in the exported TEI/HTML remain visible (click the note marker to view it), but Pulpitum is a reading surface, not an editing one.
- Page-sync integration is added — the panel watches its own active page-break index and emits a
page-changeevent upward, and exposes agoToPage()method so the parentDocumentViewercan drive it from the PDF side. - The container sizing was changed from a fixed full-viewport size (meant for Scriptorium's dedicated child windows) to
height: 100%, so it correctly fills its half of the split-panel layout instead.
PdfViewer never talks to a PDF rendering library directly — it goes through a small internal adapter abstraction (src/components/pdf-viewer/PdfAdapter.js), currently implemented on top of PDF.js. The component itself only owns the container mount point, the toolbar (page indicator, zoom in/out, fit-to-width), and the same page-sync contract as the HTML panel (page-change event, goToPage() method). Isolating the concrete rendering engine behind this adapter is a deliberate choice to leave room for swapping in a different viewer technology later (the code comments specifically call out IIIF/Universal Viewer as a possible future backend) without touching the surrounding sync or toolbar logic.
Every reading page also gets two statically pre-rendered pop-out variants — /view/html/[uuid] and /view/pdf/[uuid] — each showing just one panel, full-screen, in its own minimal layout (PopupLayout.astro). These are reachable from the main document page's toolbar ("Isola: HTM / PDF") and open in a new browser tab/window via standard links.
The page-sync mechanism (src/lib/sync.js) is built to keep these pop-outs in step with each other and with the main split view, using two layers:
- A per-document, module-scoped Vue ref shared by any viewers mounted in the same page (the two halves of the main split view).
- A
BroadcastChannel, one per documentuuid, that relays page-change messages to any other open browser tab/window showing the same document — so opening the HTML transcription and the PDF facsimile in two separate pop-out windows still keeps them synchronized as you navigate either one. Environments withoutBroadcastChannelsupport simply fall back to same-page-only sync.
scripts/build-helper.js is a small CLI used both directly and as part of the Astro build itself:
node scripts/build-helper.js generate # scaffold a 3-page dummy document (HTML + PDF + XML + JSON)
node scripts/build-helper.js validate # check /json ↔ /repo consistency before building
node scripts/build-helper.js sync-public # copy /repo and /json into /public (served statically)
generateproduces a complete dummy document (a short "Fedro" text) — TEI-shaped HTML (<span class="tei-X" data-tag="X" data-*="...">, matching Scriptorium's ownconvertXmlStringToHtml()output format), a matching 3-page PDF built withpdf-lib(numbered pages, so end-to-end page-sync is actually testable), a TEI-XML source, and the corresponding JSON metadata entry — useful for developing or testing Pulpitum without needing a real exported edition on hand.validateis the same internal consistency check Praelum's own validator performs, re-run here as a final safety net directly beforeastro build.sync-publiccopies/repoand/jsoninto Astro'spublic/folder so they're included as static assets in the final build output; this runs automatically as part ofnpm run build.
npm install
npm run generate:dummy # optional: scaffold a sample document to preview the reading experience
npm run dev # local dev server
npm run build # sync:public + astro build → dist/
npm run preview # serve the built dist/ locallyTo populate the library with real content instead of the dummy document, place your exported [format].[uuid].[name].json files in /json and their referenced HTML/PDF/XML files under the matching /repo/html, /repo/pdf, /repo/xml folders, then run npm run validate followed by npm run build.
- Site generator: Astro (static output, per-document static paths via
getStaticPaths) - Interactive islands: Vue 3 (
@astrojs/vue), hydrated withclient:load - PDF rendering: PDF.js (
pdfjs-dist), behind an internal adapter abstraction - Cross-window sync:
BroadcastChannelWeb API - Build tooling:
pdf-lib(dummy-content generation only, dev dependency)
Pulpitum/
├── astro.config.mjs
├── scripts/
│ └── build-helper.js # generate / validate / sync-public CLI
├── src/
│ ├── layouts/
│ │ ├── BaseLayout.astro # Shared page shell (library index, document pages)
│ │ └── PopupLayout.astro # Minimal shell for pop-out single-panel windows
│ ├── pages/
│ │ ├── index.astro # Library index
│ │ ├── doc/[uuid].astro # Main split-panel document reading page
│ │ └── view/
│ │ ├── html/[uuid].astro # Pop-out: HTML panel only
│ │ └── pdf/[uuid].astro # Pop-out: PDF panel only
│ ├── components/
│ │ ├── DocumentViewer.vue # Split layout, drag divider, responsive mode, sync wiring
│ │ ├── SynchronizationControls.vue # Toolbar: page indicator, downloads, pop-out links
│ │ ├── PdfViewer.vue # PDF panel (toolbar + adapter-based rendering)
│ │ ├── PdfPopupViewer.vue # PDF-only pop-out variant
│ │ ├── HtmlPopupViewer.vue # HTML-only pop-out variant
│ │ ├── pdf-viewer/
│ │ │ └── PdfAdapter.js # PDF.js-backed rendering adapter (swappable)
│ │ └── html-viewer/
│ │ ├── HtmlViewer.vue # Read-only adaptation of Scriptorium's Viewer
│ │ ├── Sidebar.vue # TOC / TEI-list navigation
│ │ ├── SearchSection.vue # Full-text search
│ │ ├── HeaderModal.vue # Document metadata (teiHeader) display
│ │ ├── NoteDisplay.vue # Read-only note viewing
│ │ ├── modules/
│ │ │ ├── teiProcessor.js
│ │ │ ├── teiHeaderProcessor.js
│ │ │ ├── userNotes.js
│ │ │ ├── search.js
│ │ │ └── utils.js
│ │ └── styles/
│ │ ├── styles.css
│ │ └── tei-header.css
│ └── lib/
│ ├── metadata.js # Build-time /json + /repo access (loadHtmlDocuments, etc.)
│ └── sync.js # Page-sync state: same-page ref + BroadcastChannel
├── json/ # Document metadata (populated before build)
├── repo/ # html/ pdf/ xml/ source files (populated before build)
└── package.json