// src/word-live-builder.ts
//
// M4 of the v0.7 plan. Post-processes a .docx (produced by `docx create`)
// to inject Word's native citation system so the user can edit the
// document in Word with renumbering-on-F9 and live Source Manager.
//
// What gets injected (per data/word-reference-xml/README.md):
// - customXml/item1.xml (b:Sources with one b:Source per citation)
// - customXml/itemProps1.xml (datastoreItem + schemaRef)
// - customXml/_rels/item1.xml.rels (rId1 -> itemProps1.xml)
// - word/_rels/document.xml.rels (rId1 -> ../customXml/item1.xml)
// - [Content_Types].xml (Override for itemProps1.xml only;
// item1.xml is covered by the default
// "xml" extension rule.)
// - word/document.xml (every superscript [N] replaced with an
// field; the
// bibliography block at the end becomes
// a DOUBLE with docPartGallery=
// Bibliographies + .)
//
// The byte-level structure was captured from a real Word-saved .docx
// (data/word-citation-reference.docx). The builder does NOT replace
// the base document's root element — it preserves namespaces and adds
// only the bits Word needs to recognise the source list.
//
// KNOWN limitations (M0.5 must validate the runtime):
// - We do not validate that the user has Word's IEEE2006.OfficeOnline.xsl
// style installed. The .docx is set to "IEEE" by default; if Word
// cannot find the style, it falls back to Word's built-in APA
// rendering, which is a mismatch but not a crash.
// - We do not test the renumber-after-delete flow (needs a human in
// real Word — M0.5).
// - The builder does not handle the case where [Content_Types].xml
// already has a customXml Override (we would silently produce
// invalid XML). For the M4 first cut, we assume a clean
// `docx create` output and validate in tests.
import AdmZip from "adm-zip";
import { existsSync, mkdirSync, copyFileSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
import type { CslItem } from "./csl/schema.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, "..");
/**
* v0.7.6: install the bundled superscript bibliography XSL to Word's style
* folder (%APPDATA%\Microsoft\Bibliography\Style on Windows) if it is not
* already present. Word reads citation/bibliography styles from this folder;
* without our custom IEEE-Superscript XSL, live citations render plain.
* No-op on non-Windows (Word isn't the target there). Exported for testing.
*/
export function installSuperscriptStyle(): { installed: boolean; path?: string; stale?: boolean } {
if (process.platform !== "win32") return { installed: false };
const appdata = process.env.APPDATA;
if (!appdata) return { installed: false };
const styleDir = join(appdata, "Microsoft", "Bibliography", "Style");
const dst = join(styleDir, "IEEE2006SuperscriptOfficeOnline.xsl");
// Bundled copy ships in data/ next to the package root.
const src = join(REPO_ROOT, "data", "IEEE2006SuperscriptOfficeOnline.xsl");
if (!existsSync(src)) return { installed: false };
const bundledVer = readStyleVersion(src);
if (existsSync(dst)) {
// BUG 11 fix: don't clobber a user-installed (possibly hand-edited) XSL.
// Instead detect staleness (version mismatch) and surface it so the user
// can re-install deliberately. We NEVER overwrite without explicit consent.
const installedVer = readStyleVersion(dst);
return { installed: false, path: dst, stale: bundledVer !== installedVer };
}
try {
mkdirSync(styleDir, { recursive: true });
copyFileSync(src, dst);
return { installed: true, path: dst };
} catch {
return { installed: false };
}
}
/** Read the pi-paper-lab version marker from a superscript XSL (BUG 11). */
function readStyleVersion(path: string): string | undefined {
try {
const head = readFileSync(path, "utf-8").slice(0, 400);
const m = head.match(/pi-paper-lab superscript bibliography XSL v(\d+)/);
return m ? m[1] : undefined;
} catch {
return undefined;
}
}
/**
* Convert a CslItem (from crossrefToCsl / OpenAlex / Europe PMC) into
* the WordLiveBuilderSource shape that buildWordLive has always consumed.
*
* This is the v0.7.5 replacement for the v0.7.0 regex parser that ran
* over the formatVancouver() string. With direct CslItem → b:Source
* mapping, the bug surface area documented in M4 (CRIT-3, CRIT-4,
* MED-1, MED-2, MED-3) disappears entirely — we never re-parse what
* we already have structured.
*
* Mapping table (CslItem → b:Source):
*
* CslItem field → WordLiveBuilderSource field
* ────────────────────────────────────────────────────────────
* id → tag (Ref{id}); numeric part → id
* title → title
* author[].family → authors[].family
* author[].given → authors[].given
* author[].literal → authors[].family (with given="")
* "container-title" → journal
* volume → volume
* issue → issue
* page → pages
* issued["date-parts"][0][0] → year
* DOI → doi
* URL → url
*/
export function cslItemToWordSource(
csl: CslItem,
numericId: number,
): WordLiveBuilderSource {
// Citation-number N comes from the citation-order index, not from
// the CslItem id (which is a DOI hash). We extract the numeric id
// from the CslItem id's "10.1242__dmm.049298" form by trusting the
// caller's order — caller passes 1, 2, 3 as the N.
const tag = csl.id ? `Ref${numericId}` : `Ref${numericId}`;
// Map CSL author fields to WordLiveBuilderSource.authors. CSL authors
// can carry `literal` for institutional authors (e.g. "World Health
// Organization"); Word's b:Person wants Last/First, so we treat
// literal as family with empty given.
const authors = (csl.author ?? []).map((a) => ({
family: a.literal ?? a.family ?? "?",
given: a.literal ? "" : a.given,
}));
return {
id: numericId,
tag,
title: csl.title ?? "(untitled)",
year: csl.issued?.["date-parts"]?.[0]?.[0]?.toString(),
journal: csl["container-title"],
doi: csl.DOI,
url: csl.URL,
authors,
volume: csl.volume,
issue: csl.issue,
pages: csl.page,
};
}
/**
* Convert a list of CslItems to WordLiveBuilderSource[], assigning
* numeric ids 1..N in input order. This is the bridge the v0.7.5
* pipeline uses; the regex Vancouver parser is GONE from the
* word-live path entirely.
*/
export function cslItemsToWordSources(items: CslItem[]): WordLiveBuilderSource[] {
return items.map((csl, i) => cslItemToWordSource(csl, i + 1));
}
export interface WordLiveBuilderSource {
/** Numeric id (1, 2, 3, ...). The [N] in the prose. */
id: number;
/** b:Tag — the citation key Word uses internally. */
tag: string;
/** b:SourceType. Defaults to "JournalArticle". */
sourceType?: "JournalArticle" | "Book" | "BookChapter" | "Report" | "InternetSite" | "ConferenceProceedings";
/** b:Title (required). */
title: string;
/** Year as a string (b:Year). */
year?: string;
/** Journal/book name (b:JournalName). */
journal?: string;
/** DOI. */
doi?: string;
/** URL. */
url?: string;
/** Authors. Each name becomes a with /. */
authors?: { family: string; given?: string }[];
/** Volume. */
volume?: string;
/** Issue. */
issue?: string;
/** Pages (e.g. "123-130" or "dmm049298"). */
pages?: string;
}
export interface BuildLiveOpts {
/** Citation style. "ieee" produces numbered [1] [2] [3] entries in
* the Word bibliography. "apa" produces author-date entries
* (Liu, Saavedra, & Perrimon, 2022). "vancouver" is the
* standard medical/scientific style with numbered entries. */
style?: "ieee" | "apa" | "vancouver";
/** XSL file to use (overrides the style default). */
styleXsl?: string;
/** Style name (overrides the style default). */
styleName?: string;
/** Style version (overrides the style default). */
styleVersion?: string;
}
const STYLE_DEFAULTS: Record, { xsl: string; name: string; version: string }> = {
// v0.7.6: use a CUSTOM superscript XSL (bundled in data/) that wraps the
// IEEE Citation template in , so live citations render as superscript
// [N] after Word regenerates the field (F9). Built-in IEEE renders plain.
// buildWordLive auto-installs the XSL to %APPDATA%\Microsoft\Bibliography\Style
// if missing, so this works on any machine with Word.
ieee: { xsl: "\\IEEE2006SuperscriptOfficeOnline.xsl", name: "IEEE Superscript", version: "2006" },
apa: { xsl: "\\APASixthEditionOfficeOnline.xsl", name: "APA", version: "6" },
// Vancouver maps to the superscript IEEE variant (Word ships no Vancouver XSL).
vancouver: { xsl: "\\IEEE2006SuperscriptOfficeOnline.xsl", name: "IEEE Superscript", version: "2006" },
};
/**
* Build the b:Sources XML body from a list of sources.
* Exported for testing.
*/
export function buildSourcesXml(sources: WordLiveBuilderSource[]): string {
const sourceNodes = sources.map((s) => {
const id = `Ref${s.id}`;
const tag = escapeXml(s.tag || id);
const sourceType = escapeXml(s.sourceType ?? "JournalArticle");
const title = escapeXml(s.title);
const year = s.year ? escapeXml(s.year) : "";
const journal = s.journal ? `${escapeXml(s.journal)}` : "";
const doi = s.doi ? `${escapeXml(s.doi)}` : "";
const url = s.url ? `${escapeXml(s.url)}` : "";
const volume = s.volume ? `${escapeXml(s.volume)}` : "";
const issue = s.issue ? `${escapeXml(s.issue)}` : "";
const pages = s.pages ? `${escapeXml(s.pages)}` : "";
const authors = (s.authors ?? []).map((a) => {
const last = escapeXml(a.family);
const first = a.given ? `${escapeXml(a.given)}` : "";
return `${last}${first}`;
}).join("");
const authorBlock = authors
? `${authors}`
: "";
// HIGH-1 fix (M4 audit): the GUID is now derived from the source
// content (DOI or title) so two papers with the same id do not
// collide in Word's Source Manager. Previously the GUID was
// `{1-0000-...}` which was the same for every paper that used
// id=1. Stable across re-runs of the same paper; differs across papers.
const guidSource = s.doi ?? s.title ?? id;
return `${tag}${sourceType}${stableGuid(guidSource)}${authorBlock}${title}${journal}${year}${volume}${issue}${pages}${doi}${url}${s.id}`;
}).join("");
return sourceNodes;
}
/**
* Escape XML special characters. Exported for testing.
* HIGH-3 fix (M4 audit): also strip XML-illegal control characters
* (anything outside #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD]
* | [#x10000-#x10FFFF]). Real input from CrossRef is clean but
* downstream consumers (or a misbehaving LLM) can introduce \x00 etc.
*/
export function escapeXml(s: string): string {
return s
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
/**
* Derive a stable Word GUID from a string (typically DOI or title).
* HIGH-1 fix (M4 audit): the previous `{id}-0000-...` placeholder
* caused cross-document collisions in Word's Source Manager. We now
* hash the content so two papers with the same source id do not
* collide. The hash is deterministic, so re-runs of the same paper
* produce the same GUID (Word's "Update from Master List" still
* works). The 8-hex-prefix is fine for a Word GUID; the rest is
* padded zeros.
*/
export function stableGuid(input: string): string {
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
}
const hex = Math.abs(hash).toString(16).toUpperCase().padStart(8, "0").slice(0, 8);
return `{${hex}-0000-0000-0000-000000000000}`;
}
/**
* Build the customXml/item1.xml content (the b:Sources source list).
* Exported for testing.
*/
export function buildItem1Xml(sources: WordLiveBuilderSource[], opts: BuildLiveOpts = {}): string {
const style = opts.style ?? "ieee";
const defaults = STYLE_DEFAULTS[style];
const xsl = escapeXml(opts.styleXsl ?? defaults.xsl);
const name = escapeXml(opts.styleName ?? defaults.name);
const version = escapeXml(opts.styleVersion ?? defaults.version);
const body = buildSourcesXml(sources);
return `${body}`;
}
/**
* Build the customXml/itemProps1.xml content (the datastoreItem + schemaRef).
* Exported for testing.
*/
export function buildItemProps1Xml(guid: string = "{E4020969-16DB-42B6-89B5-299465DA5302}"): string {
return ``;
}
/**
* Build the customXml/_rels/item1.xml.rels content.
* Exported for testing.
*/
export function buildItem1RelsXml(): string {
return ``;
}
/**
* Build the CITATION field XML for a given [N] reference.
* Returns a sequence of elements wrapped in a that Word
* recognises as a managed citation. The visible text is the literal
* "[N]" — Word rewrites it on F9 per the active citation style.
*/
function buildCitationSdt(refId: number, visibleText: string): string {
const tag = `Ref${refId}`;
// Use a stable ID derived from refId. Word only requires uniqueness
// within the document.
const sdtId = String(100000 + refId);
// v0.7.6: the visible (cached) result run uses the "Citation" character
// style, which we define with superscript in styles.xml (patchStylesXml).
// Citations render as superscript [N] both before F9 (cached result) and
// after F9 (Word regenerates via the style). To switch to plain brackets,
// the user modifies the Citation style in Word — no code change needed.
return ` CITATION ${tag} \\l 1033 ${escapeXml(visibleText)}`;
}
/**
* Build the BIBLIOGRAPHY SDT — the outer docPartGallery SDT that holds
* the "Bibliography" heading paragraph, wrapping the inner
* SDT that contains the actual field.
*
* Returned as a sequence of paragraphs (the heading + the
* bibliography field) that go at the end of the body.
*
* v0.7.6 hostile-audit: cachedRefs is the STATIC reference list rendered
* as the field's cached result (between `separate` and `end`). Word
* regenerates this on F9 from the b:Sources; non-Word apps (LibreOffice,
* Google Docs, Pages) that cannot evaluate the BIBLIOGRAPHY field display
* the cached list instead of an empty "(Update Field to render)". This is
* the "fallback when Word is not present" path — the document is never
* left with an invisible bibliography.
*/
function buildBibliographySdt(cachedRefs: string[] = []): string {
// Build the cached field-result runs. Empty → placeholder so the field
// is never blank in non-Word apps.
let cachedRuns: string;
if (cachedRefs.length === 0) {
cachedRuns = `(Update Field to render)`;
} else {
cachedRuns = cachedRefs
.map((ref, i) => {
const br = i < cachedRefs.length - 1 ? `` : "";
return `${escapeXml(ref)}${br}`;
})
.join("");
}
return `References BIBLIOGRAPHY ${cachedRuns}`;
}
/**
* Rewrite word/document.xml: replace every `[N]` with a
* CITATION SDT, and append a BIBLIOGRAPHY SDT at the end of the body
* (just before ``). Returns the modified document.xml.
*/
export function rewriteDocumentXml(
docXml: string,
originalToPositional?: Map,
cachedRefs?: string[],
): string {
// CRIT-1 fix (M4 audit): the rPr block containing is now MANDATORY (the previous `?` made
// the whole rPr block optional, which let the regex match plain
// [N] runs and turn them into CITATION SDTs).
// We still allow other elements inside (e.g. ,
// ).
let out = docXml;
const supRunRe = /]*>(?:[^<]|<(?!w:t\b)[^<])*(?:[^<]|<(?!w:t\b)[^<])*<\/w:rPr>\s*]*>\[(\d+)\]<\/w:t>\s*<\/w:r>/g;
out = out.replace(supRunRe, (_m, n) => {
const orig = parseInt(n, 10);
// If we have a mapping (e.g. [4] -> positional 2), use the
// positional id. Word's CITATION field CITATION Ref
// resolves to b:Source Ref. This is how we get
// auto-renumbering: the user can delete any citation and Word
// will renumber the rest in body order.
const pos = originalToPositional?.get(orig) ?? orig;
return buildCitationSdt(pos, `[${orig}]`);
});
// CRIT-2 fix (M4 audit): only append the BIBLIOGRAPHY SDT if one
// is not already present. Without this guard, every call to
// rewriteDocumentXml would append another BIBLIOGRAPHY SDT, so
// running finalizeDoc({live: true}) twice would produce a
// document with TWO bibliography sections.
if (!out.includes("")) {
out = out.replace("", buildBibliographySdt(cachedRefs) + "");
}
return out;
}
/**
* Patch [Content_Types].xml to add the itemProps1.xml Override.
* The default "xml" extension already covers item1.xml — no Override
* needed for it.
*/
export function patchContentTypesXml(ctXml: string, itemPropsTarget: string = "/customXml/itemProps1.xml"): string {
const override = ``;
// Insert before the closing . If the Override is already
// present, leave it alone.
if (ctXml.includes(itemPropsTarget)) return ctXml;
return ctXml.replace("", override + "");
}
/**
* Patch word/_rels/document.xml.rels to add a customXml relationship
* pointing at customXml/item1.xml. The first unused rId number is
* computed by scanning existing relationships.
*/
export function patchDocumentRelsXml(relsXml: string): string {
// Find the highest existing rId number (e.g. rId1, rId2, ...).
const rids = [...relsXml.matchAll(/Id="rId(\d+)"/g)].map((m) => parseInt(m[1], 10));
const nextRid = (rids.length > 0 ? Math.max(...rids) : 0) + 1;
const rel = ``;
return relsXml.replace("", rel + "");
}
/**
* Patch word/styles.xml to ensure a "Citation" character style exists with
* superscript formatting. v0.7.6: live citations render as superscript [N]
* both in the cached field result and after Word regenerates the field
* (F9), because the regenerated runs inherit this style. The user can
* modify the style in Word (Modify Style → uncheck Superscript) to switch
* to plain brackets. Exported for testing.
*/
export function patchStylesXml(stylesXml: string): string {
const SUP_RPR = ``;
// 1. A Citation style already exists — make sure it has superscript.
if (/]*w:styleId="Citation"/.test(stylesXml)) {
const block = stylesXml.match(/]*w:styleId="Citation"[\s\S]*?<\/w:style>/);
if (block && /]*w:styleId="Citation"[\s\S]*?)(<\/w:style>)/,
(_full, head, close) =>
//.test(head)
? head.replace(//, '') + close
: head + SUP_RPR + close,
);
}
// 2. No Citation style — add one before .
const newStyle = `${SUP_RPR}`;
if (/<\/w:styles>/.test(stylesXml)) {
return stylesXml.replace(/<\/w:styles>/, newStyle + "");
}
return `${newStyle}`;
}
/**
* Patch word/settings.xml to set . v0.7.6:
* Word then refreshes ALL fields (CITATION + BIBLIOGRAPHY) when the document
* is opened, so citations and the bibliography renumber automatically after
* the user adds/deletes a citation and reopens the file — no manual F9.
* Exported for testing.
*/
export function patchSettingsXml(settingsXml: string): string {
if (//, '');
}
const open = settingsXml.match(/]*>/);
if (open) {
return settingsXml.replace(open[0], open[0] + '');
}
return ``;
}
/**
* Process a .docx file in place: read it, inject the citation system,
* write it back. Returns nothing (the file is mutated in place).
*/
export function buildWordLive(
docxPath: string,
sources: WordLiveBuilderSource[],
opts: BuildLiveOpts & { originalToPositional?: Map; cachedBibliography?: string[]; installStyleXsl?: boolean } = {},
): void {
// v0.7.6: install the custom superscript XSL to Word's style folder ONLY when
// the caller explicitly opts in (opts.installStyleXsl === true). We never
// write to %APPDATA% silently — installing files on the user's machine
// without consent is unacceptable. The CLI prints install instructions when
// the style is missing instead of auto-installing.
if (opts.installStyleXsl) installSuperscriptStyle();
const zip = new AdmZip(docxPath);
// 1. customXml/item1.xml
zip.addFile("customXml/item1.xml", Buffer.from(buildItem1Xml(sources, opts), "utf-8"));
// 2. customXml/itemProps1.xml
zip.addFile("customXml/itemProps1.xml", Buffer.from(buildItemProps1Xml(), "utf-8"));
// 3. customXml/_rels/item1.xml.rels
zip.addFile("customXml/_rels/item1.xml.rels", Buffer.from(buildItem1RelsXml(), "utf-8"));
// 4. word/_rels/document.xml.rels
const relsEntry = zip.getEntry("word/_rels/document.xml.rels");
if (!relsEntry) throw new Error(`word/_rels/document.xml.rels not found in ${docxPath}`);
const rels = relsEntry.getData().toString("utf-8");
zip.updateFile(relsEntry, Buffer.from(patchDocumentRelsXml(rels), "utf-8"));
// 5. [Content_Types].xml
const ctEntry = zip.getEntry("[Content_Types].xml");
if (!ctEntry) throw new Error(`[Content_Types].xml not found in ${docxPath}`);
const ct = ctEntry.getData().toString("utf-8");
zip.updateFile(ctEntry, Buffer.from(patchContentTypesXml(ct), "utf-8"));
// 6. word/document.xml
const docEntry = zip.getEntry("word/document.xml");
if (!docEntry) throw new Error(`word/document.xml not found in ${docxPath}`);
const doc = docEntry.getData().toString("utf-8");
zip.updateFile(docEntry, Buffer.from(rewriteDocumentXml(doc, opts.originalToPositional, opts.cachedBibliography), "utf-8"));
// 7. word/styles.xml — Citation character style with superscript (v0.7.6).
const stylesEntry = zip.getEntry("word/styles.xml");
if (stylesEntry) {
const st = stylesEntry.getData().toString("utf-8");
zip.updateFile(stylesEntry, Buffer.from(patchStylesXml(st), "utf-8"));
} else {
zip.addFile("word/styles.xml", Buffer.from(patchStylesXml(""), "utf-8"));
}
// 8. word/settings.xml — NOT patched. v0.7.6 originally set to auto-update fields on open, but Word's citation/
// bibliography fields are EXCLUDED from that auto-update (only TOC/date/
// filename fields refresh). The flag caused an annoying "update fields?"
// popup on every open with ZERO citation benefit, so it was removed.
// Citations renumber on Ctrl+A → F9 (or the ribbon button). The cached
// bibliography text is always shown until then.
// Write back.
zip.writeZip(docxPath);
}