// 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); }