Extract and reuse a style guide
Read a DOCX's theme, document defaults, and styles into an editable guide, write the edits back, and apply the result to new drafts as a template.
A firm's own documents already define its house style: the theme, the document defaults, and the named styles. This guide reads them into an editable style guide, writes the guide's edits back, saves the result as a template package, and applies that template to a draft.
Every step uses the Document API. Nothing in it unzips the DOCX or parses XML.
Run the complete workflow
The example runs server-side with the Node SDK. Put the firm's document next to it as firm-document.docx and a draft as draft.docx:
import { writeFile } from 'node:fs/promises';
import path from 'node:path';
import { SuperDocClient } from '@superdoc/sdk';
// Text from a heading in the firm's document. Its run has no direct
// formatting, so everything query.match reports for it comes from styles.
const SAMPLE_TEXT = 'Master Services Agreement';
const client = new SuperDocClient({
user: { name: 'Style guide builder', email: '[email protected]' },
// Allow a slow first start of the document host, for example on a cold server.
startupTimeoutMs: 30_000,
});
try {
await client.connect();
// 1. Extract an editable guide from the firm's document.
const firm = await client.open({ doc: './firm-document.docx' });
try {
const guide = await extractGuide(firm);
await writeFile('./style-guide.json', JSON.stringify(guide, null, 2));
// 2. Edit the guide. In an application, a person does this in your UI.
editGuide(guide);
// 3. Write the edits back and save the result as a template package.
await writeGuide(firm, guide);
await firm.save({ out: './style-guide-template.docx', force: true });
} finally {
await firm.close({ discard: true });
}
// 4. Preview the template on a draft, then apply it.
const draft = await client.open({ doc: './draft.docx' });
try {
/** @type {{ kind: 'path', path: string }} */
const source = { kind: 'path', path: path.resolve('./style-guide-template.docx') };
// The SDK throws when an operation fails, so each result here is a success.
const preview = await draft.templates.apply({ source, dryRun: true });
console.log('Would apply:', preview.appliedScopes.map((entry) => entry.scope));
console.log('Skipped:', preview.skippedScopes);
console.log('Never applied:', preview.unsupportedItems);
console.log('Warnings:', preview.warnings);
await draft.templates.apply({ source });
await draft.save({ out: './draft.styled.docx', force: true });
} finally {
await draft.close({ discard: true });
}
} finally {
await client.dispose();
}
/**
* Read the theme, document defaults, every paragraph and character style, and
* the effective formatting of one sample run.
*
* @param {any} doc
*/
async function extractGuide(doc) {
const { theme } = await doc.styles.getTheme({});
if (theme.source === 'fallback') {
console.warn('The document has no usable theme. Theme values are the Office defaults.');
}
// `props` and `runProps` keep theme references raw. `resolved` and
// `resolvedRun` follow basedOn, document defaults, and the theme.
// Provenance says which layer each resolved value came from.
const model = await doc.get({ options: { includeResolved: true, includeProvenance: true } });
const catalog = await doc.styles.getCatalog({ includePreview: true });
const styles = [];
for (const item of catalog.styles) {
const paragraphStyle = model.styles?.paragraph?.[item.id];
const characterStyle = model.styles?.character?.[item.id];
if (!paragraphStyle && !characterStyle) continue;
styles.push({
id: item.id,
name: item.name,
type: paragraphStyle ? 'paragraph' : 'character',
basedOn: item.basedOn,
next: item.next,
metadata: {
aliases: item.aliases,
locked: item.locked,
custom: item.custom,
priority: item.priority,
qFormat: item.qFormat,
hidden: item.hidden,
semiHidden: item.semiHidden,
unhideWhenUsed: item.unhideWhenUsed,
},
// styles.create cannot redefine half of a linked pair, such as Heading 1.
editable: item.link === null,
authored: paragraphStyle
? { paragraph: paragraphStyle.props, run: paragraphStyle.runProps }
: { run: characterStyle.props },
resolved: paragraphStyle
? { paragraph: paragraphStyle.resolved, run: paragraphStyle.resolvedRun }
: { run: characterStyle.resolved },
provenance: paragraphStyle
? { paragraph: paragraphStyle.provenance, run: paragraphStyle.runProvenance }
: { run: characterStyle.provenance },
// CSS-ready values with their origin: `theme` or `literal`.
preview: item.preview?.css,
previewSources: item.preview?.sources,
});
}
const match = await doc.query.match({
select: { type: 'text', pattern: SAMPLE_TEXT },
require: 'first',
});
const sample = match.items[0];
const sampleRun = sample?.matchKind === 'text' ? sample.blocks[0]?.runs[0] : undefined;
return {
theme: {
source: theme.source,
colors: theme.colorScheme,
fonts: theme.fontScheme,
colorMapping: theme.colorSchemeMapping,
},
defaults: {
run: model.docDefaults?.run,
paragraph: model.docDefaults?.paragraph,
resolvedRun: model.docDefaults?.resolvedRun,
},
styles,
sample: sampleRun
? { text: SAMPLE_TEXT, styleId: sampleRun.styleId, effective: sampleRun.styles.effective }
: null,
};
}
/**
* Stand-in for a person editing the guide.
*
* @param {any} guide
*/
function editGuide(guide) {
// Body text: 12 pt instead of the firm's default.
guide.defaults.run = { ...guide.defaults.run, fontSize: 12 };
guide.defaults.changed = true;
// Clause references: the primary accent color instead of a shaded secondary accent.
const clause = guide.styles.find((/** @type {any} */ style) => style.id === 'ClauseRef');
if (clause?.editable) {
clause.authored.run = { ...clause.authored.run, color: { model: 'theme', theme: 'accent1' } };
clause.changed = true;
}
// A new paragraph style for quoted language.
guide.styles.push({
id: 'GuideQuote',
name: 'Guide Quote',
type: 'paragraph',
basedOn: 'Normal',
next: null,
metadata: { custom: true, qFormat: true },
editable: true,
authored: {
paragraph: { spacing: { before: 6, after: 6 } },
run: { italic: true, color: { model: 'theme', theme: 'accent2' } },
},
changed: true,
});
}
/**
* Write changed guide entries back to the open document.
*
* @param {any} doc
* @param {any} guide
*/
async function writeGuide(doc, guide) {
if (guide.defaults.changed) {
await doc.styles.apply({
target: { scope: 'docDefaults', channel: 'run' },
patch: { fontSize: toHalfPoints(guide.defaults.run.fontSize) },
});
}
for (const style of guide.styles) {
if (!style.changed) continue;
if (!style.editable) {
console.warn(`Skipped ${style.id}: linked styles cannot be redefined yet.`);
continue;
}
// styles.create replaces the whole definition, so send every property to keep.
await doc.styles.create({
id: style.id,
name: style.name,
type: style.type,
basedOn: style.basedOn,
...(style.type === 'paragraph' ? { next: style.next } : {}),
...withoutNulls(style.metadata),
...(style.type === 'paragraph' ? { paragraph: toParagraphPatch(style.authored.paragraph) } : {}),
run: toRunPatch(style.authored.run),
conflictPolicy: 'replace',
});
}
}
// Reads report points and SDColorRef values. Style writes take the units
// Word stores: half-points for font size, twentieths of a point for spacing,
// and hex strings for theme tint and shade.
/** @param {any} run */
function toRunPatch(run = {}) {
/** @type {Record<string, unknown>} */
const patch = {};
for (const [key, value] of Object.entries(run)) {
if (key === 'bold' || key === 'italic') patch[key] = value;
else if (key === 'fontSize') patch.fontSize = toHalfPoints(value);
else if (key === 'color') patch.color = toColorPatch(value);
else if (key === 'fontFamily') patch.fontFamily = { ascii: value, hAnsi: value };
else if (key === 'fonts') patch.fontFamily = toFontPatch(value);
else throw new Error(`This example does not write run property "${key}".`);
}
return patch;
}
/** @param {any} paragraph */
function toParagraphPatch(paragraph = {}) {
/** @type {Record<string, unknown>} */
const patch = {};
for (const [key, value] of Object.entries(paragraph)) {
if (key === 'keepWithNext') patch.keepNext = value;
else if (key === 'spacing') {
const { before, after, ...rest } = value;
if (Object.keys(rest).length > 0) throw new Error('This example writes spacing before and after only.');
patch.spacing = withoutNulls({ before: toTwips(before), after: toTwips(after) });
} else throw new Error(`This example does not write paragraph property "${key}".`);
}
return patch;
}
/** @param {any} color */
function toColorPatch(color) {
if (color.model === 'rgb') return { val: color.value };
if (color.model === 'auto') return { val: 'auto' };
return withoutNulls({
themeColor: color.theme,
themeTint: toHexByte(color.tint),
themeShade: toHexByte(color.shade),
});
}
/** @param {any} fonts */
function toFontPatch(fonts) {
const { csTheme, ...rest } = fonts;
return withoutNulls({ ...rest, cstheme: csTheme });
}
/** @param {number} points */
function toHalfPoints(points) {
return Math.round(points * 2);
}
/** @param {number | undefined} points */
function toTwips(points) {
return points === undefined ? undefined : Math.round(points * 20);
}
/** @param {number | undefined} byte */
function toHexByte(byte) {
return byte === undefined ? undefined : byte.toString(16).toUpperCase().padStart(2, '0');
}
/** @param {Record<string, unknown>} value */
function withoutNulls(value) {
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined && entry !== null));
}
It writes three files:
| File | Contents |
|---|---|
style-guide.json | The extracted guide, before edits |
style-guide-template.docx | The firm's document with the guide edits applied |
draft.styled.docx | The draft with the template adopted. Its text is unchanged. |
The guide format is the example's own. Keep the fields your application needs.
Raw, resolved, and provenance values
doc.get() returns styles, theme, and docDefaults next to the body. Read options decide how much of each style you get:
| Field | Present with | Contents |
|---|---|---|
props, runProps | always | What the style itself sets. Theme references stay raw, such as asciiTheme: 'majorHAnsi' and { model: 'theme', theme: 'accent1' }. |
resolved, resolvedRun | includeResolved: true | The value after basedOn, document defaults, and the theme. A theme font becomes a family name and a theme color becomes RGB. |
provenance, runProvenance | includeProvenance: true | For each resolved value: source (style, docDefaults, ...), sourceRef, and the basedOn chain. |
Keep the raw value when the guide should stay tied to the theme, so a later theme change carries through. Show the resolved value to a person. Use provenance to tell a style's own setting from an inherited one.
docDefaults follows the same pattern: run and paragraph as authored, and resolvedRun and resolvedParagraph with includeResolved.
Theme fonts and colors
styles.getTheme() returns the theme on its own: the color scheme as six-digit hex values, the major and minor fonts, and the color mapping from word/settings.xml. It is the same theme doc.get() returns.
Check theme.source. When the document has no theme part or the part cannot be read, the result is the default Office theme, labeled fallback, and diagnostics says why. A guide built from a fallback theme describes Office defaults, not the firm.
styles.getCatalog({ includePreview: true }) adds CSS-ready values to each style. preview.sources says where fontFamily and color came from:
{
"css": { "fontFamily": "Georgia", "color": "#0B6E4F", "fontSize": "16pt" },
"sources": {
"fontFamily": { "origin": "theme", "themeFont": "majorHAnsi" },
"color": { "origin": "theme", "themeColor": "accent1" }
}
}A literal value reports { "origin": "literal" }. A theme color with a tint or shade carries tint or shade, a byte from 0 to 255. A value resolved against the fallback theme carries fallback: true.
Effective formatting of real text
The style definitions say what a style sets. query.match() says what a run of text actually renders with, after its paragraph style, character style, direct formatting, document defaults, and the theme:
const match = await doc.query.match({
select: { type: 'text', pattern: 'Master Services Agreement' },
require: 'first',
});
const run = match.items[0].blocks[0].runs[0];
run.styles.effective; // { bold: false, fontFamily: 'Georgia', color: '#0b6e4f', fontSizePt: 16, sources: { ... } }
run.styles.direct; // what the run sets itself: { bold: 'clear', italic: 'clear', ... }match.meta.effectiveResolved is true when the effective values went through the style cascade. Top-level values such as run.styles.fontFamily stay direct-only.
Write the edits back
Two operations write a guide back to a document:
styles.applychanges the document defaults. It takes a patch and changes only the keys you send.styles.createdefines or redefines a paragraph or character style. WithconflictPolicy: 'replace', the input replaces the whole definition: a property you leave out is removed. Send every property the style should keep, including its metadata.
Reads report points and SDColorRef values. Writes take the units Word stores:
| Value | Read as | Write as |
|---|---|---|
| Font size | points: 12 | half-points: 24 |
| Spacing and indents | points: 6 | twentieths of a point: 120 |
| RGB color | { model: 'rgb', value: '1F3864' } | { val: '1F3864' } |
| Theme color | { model: 'theme', theme: 'accent2', shade: 191 } | { themeColor: 'accent2', themeShade: 'BF' } |
| Theme font | fonts: { asciiTheme: 'majorHAnsi' } | fontFamily: { asciiTheme: 'majorHAnsi' } |
The example converts the properties it uses and throws on any other, so a property it does not know is never dropped without notice.
Apply the template
The example saves the edited firm document as the template. templates.apply adopts its styles, numbering, settings, theme, font table, headers and footers, and page setup into the draft. It never copies body content.
Preview first. A dry run returns the receipt the apply would return and leaves the draft unchanged. Check appliedScopes, skippedScopes, unsupportedItems, and warnings before applying. DOC_DEFAULTS_ADOPTED means the draft's document defaults were replaced. Apply a template covers each scope and receipt field.
After applying, read the draft the same way you read the firm's document. query.match() on a heading shows whether it now renders with the guide's font and color.
Limits
- Linked styles.
styles.createcannot redefine half of a linked pair. Word links Heading 1 to Heading 1 Char, so built-in headings cannot be edited through the guide yet. The catalog'slinkfield names the other half. The example marks those styles read-only. - Theme edits. No operation writes the theme. The template carries the firm document's theme unchanged.
- Table styles.
doc.get()reads table styles, including conditional formatting per region.styles.createwrites paragraph and character styles only. - Headers and footers. The template's headers and footers are adopted with the rest. Use a source document without them if drafts should keep their own.
- Applying again. Applying the same template a second time adds another copy of its headers, footers, and list definitions. Apply it once per draft.
- Shared rooms.
templates.applyis not available in a shared room. Apply the template before the draft joins a room. - Browser. In the browser editor, pass the template as base64. A
pathsource needs a file system.
See the reference for doc.get, styles.getTheme, styles.getCatalog, query.match, styles.apply, styles.create, and templates.apply.