Skip to content

Bug: PageReference omits the separate field character, so MS Word shows "1" instead of the resolved page number (even after "Update Fields") #3480

Description

@VadymTeterin

Bug: PageReference omits the separate field character, so MS Word shows "1" instead of the resolved page number (even after "Update Fields")

Package: docx
Version: 9.6.1 (reproduced also on 9.7.1)
Node: v22.22.2

Description

PageReference builds a composite PAGEREF field as:

begin -> instrText("PAGEREF <id>") -> end

all packed into a single <w:r>. Per the OOXML spec, a composite field
should be:

begin -> instrText -> separate -> <cached result run(s)> -> end

The separate field character (w:fldCharType="separate") is what tells
Word "everything after this point, up to end, is the last-computed cached
result — display this until you recompute the field". Without it, Word does
not have a well-formed field to update in place, and both of these happen in
practice:

  • If the user declines "update fields on open" (or updateFields isn't set),
    the field displays nothing — no cached text to fall back on.
  • If the user does update fields (either via the "This document contains
    fields..." prompt, or Ctrl+AF9), Word does not reliably resolve the
    correct page number — in our testing every PAGEREF field in a 28-entry
    table of contents displayed 1.

Notably, LibreOffice does not require separate to compute the field
correctly
— it recalculates PAGEREF correctly regardless, which is why
this is easy to miss if you only test with LibreOffice/soffice conversion
and not real Microsoft Word.

By contrast, PageNumber.CURRENT (used for PAGE fields, e.g. in footers)
does insert createSeparate() internally (see the children handling in
Run's constructor, the case PageNumber.CURRENT: branch), and that works
correctly in Word. PageReference doesn't follow the same pattern.

Minimal reproduction

import {
  Document,
  Packer,
  Paragraph,
  TextRun,
  PageReference,
  Bookmark,
  PageBreak,
} from "docx";
import * as fs from "fs";

const doc = new Document({
  features: { updateFields: true },
  sections: [
    {
      children: [
        new Paragraph({
          children: [new TextRun("See page: "), new PageReference("target")],
        }),
        new Paragraph({ children: [new PageBreak()] }),
        new Paragraph({ children: [new PageBreak()] }),
        new Paragraph({
          children: [new Bookmark({ id: "target", children: [new TextRun("Here")] })],
        }),
      ],
    },
  ],
});

Packer.toBuffer(doc).then((buf) => fs.writeFileSync("out.docx", buf));

Open out.docx in Microsoft Word, accept "Update Fields", then also try
Ctrl+AF9. Expected page number is 3; Word displays 1.

Inspecting word/document.xml shows the malformed single-run field:

<w:r>
  <w:fldChar w:fldCharType="begin"/>
  <w:instrText xml:space="preserve">PAGEREF target</w:instrText>
  <w:fldChar w:fldCharType="end"/>
</w:r>

Expected behavior

<w:r><w:fldChar w:fldCharType="begin"/></w:r>
<w:r><w:instrText xml:space="preserve"> PAGEREF target \h </w:instrText></w:r>
<w:r><w:fldChar w:fldCharType="separate"/></w:r>
<w:r><w:t>1</w:t></w:r>
<w:r><w:fldChar w:fldCharType="end"/></w:r>

(The cached text can be a placeholder like "1" — Word will overwrite it on
update. What matters is the separate marker being present.)

Suggested fix

In PageReference's constructor, follow the same pattern already used for
PageNumber.CURRENT inside Run: insert a createSeparate() field
character between the instruction and end, and (ideally) a cached
TextRun placeholder in between:

class PageReference extends Run {
  constructor(bookmarkId: string, options: IRunOptions = {}) {
    super({
      children: [
        createBegin(true),
        new PageReferenceFieldInstruction(bookmarkId, options),
        createSeparate(),
        new TextRun({ text: "1", ...options }),
        createEnd(),
      ],
    });
  }
}

Workaround used in the meantime

We built the field manually using the library's exported low-level
primitives (Run, XmlComponent, XmlAttributeComponent) to insert the
missing separate character and a cached result run:

class FldCharAttrs extends XmlAttributeComponent {
  constructor(type: string) {
    super({ type });
    this.xmlKeys = { type: "w:fldCharType" };
  }
}
class RawFldChar extends XmlComponent {
  constructor(type: string) {
    super("w:fldChar");
    this.root.push(new FldCharAttrs(type));
  }
}
class PreserveSpaceAttr extends XmlAttributeComponent {
  constructor() {
    super({ space: "preserve" });
    this.xmlKeys = { space: "xml:space" };
  }
}
class RawInstrText extends XmlComponent {
  constructor(text: string) {
    super("w:instrText");
    this.root.push(new PreserveSpaceAttr());
    this.root.push(text);
  }
}

function pageRefField(bookmarkId: string, runOpts = {}) {
  return [
    new Run({
      children: [new RawFldChar("begin"), new RawInstrText(` PAGEREF ${bookmarkId} \\h `), new RawFldChar("separate")],
    }),
    new TextRun({ text: "1", ...runOpts }),
    new Run({ children: [new RawFldChar("end")] }),
  ];
}

This produces a schema-valid, Word-correct field and resolves correctly
after Ctrl+AF9 (or on open, with features.updateFields: true,
subject to Word's usual "fields update before final pagination on first
open" timing quirk, which is a separate Word behavior and not part of this
report).

Happy to open a PR with this fix if useful — thanks again for maintaining
this library!

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions