
Docx Generator
- 719 installs
- 133 repo stars
- Updated February 24, 2026
- jwynia/agent-skills
docx-generator is a document automation skill that analyzes DOCX templates and generates or populates Word documents with structured content for developers who need programmatic DOCX extraction and AI-driven document fil
About
docx-generator is an agent skill from jwynia/agent-skills backed by a Deno analyze-template.ts script that reads DOCX files with JSZip (npm:jszip@3.10.1) and JSR std libraries. It extracts paragraphs, tables, headers, footers, and placeholders from Word templates, emitting JSON structure for replacement planning before generating or populating new documents. The workflow uses deno run --allow-read scripts/analyze-template.ts with options for verbose and pretty JSON output. Developers reach for it when building report generators, mail-merge style tools, or agent pipelines that must inspect .docx layout programmatically rather than manually editing Word. It suits Deno-based automation where filesystem read access to template files is available.
- Extracts paragraphs, tables, headers, footers and styled text runs from DOCX files
- Identifies and maps placeholders for automated content replacement
- Outputs structured JSON for analysis and template planning
- Supports verbose and pretty-print modes for debugging
- Deno-based CLI compatible with agent-driven document workflows
Docx Generator by the numbers
- 719 all-time installs (skills.sh)
- +2 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #329 of 1,879 Documentation skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jwynia/agent-skills --skill docx-generatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 719 |
|---|---|
| repo stars | ★ 133 |
| Security audit | 2 / 3 scanners passed |
| Last updated | February 24, 2026 |
| Repository | jwynia/agent-skills ↗ |
How do you extract structure from DOCX templates programmatically?
Analyze DOCX templates, extract structured content like paragraphs, tables, headers and placeholders, then generate or populate new Word documents with AI-driven conten
Who is it for?
Developers building Deno or TypeScript pipelines that analyze .docx templates and auto-fill reports or contracts.
Skip if: Teams needing PDF generation, Google Docs APIs, or rich WYSIWYG Word editing without programmatic template parsing.
When should I use this skill?
A developer needs to parse DOCX structure, list placeholders, or generate Word documents from templates in code.
What you get
JSON template analysis, identified placeholders, and generated or populated Word documents from structured content.
- Template JSON analysis
- Populated DOCX output
By the numbers
- Uses JSZip npm:jszip@3.10.1 for DOCX unpacking
- Deno entry script: analyze-template.ts with --allow-read permission
Files
DOCX Generator
When to Use This Skill
Use this skill when:
- Creating Word documents programmatically from data or specifications
- Populating branded templates with dynamic content while preserving corporate styling
- Extracting text, tables, and structure from existing DOCX files for analysis
- Finding and replacing placeholder text like
{{TITLE}}or${author} - Automating document generation workflows (reports, contracts, letters)
Do NOT use this skill when:
- User wants to open/view documents (use native Word or viewer)
- Complex mail merge with data sources (use native Word mail merge)
- Working with older .doc format (DOCX only)
- PDF output is needed (use pdf-generator skill instead)
Prerequisites
- Deno installed (https://deno.land/)
- Input DOCX files for template-based operations
- JSON specification for scratch generation
Quick Start
Two Modes of Operation
1. Template Mode: Modify existing branded templates
- Analyze template to find placeholders
- Replace
{{PLACEHOLDERS}}with actual content
2. Scratch Mode: Create documents from nothing using JSON specifications
Instructions
Mode 1: Template-Based Generation
Step 1a: Analyze the Template
Extract text inventory to understand what can be replaced:
deno run --allow-read scripts/analyze-template.ts corporate-template.docx > inventory.jsonOutput (inventory.json):
{
"filename": "corporate-template.docx",
"paragraphCount": 25,
"tableCount": 2,
"imageCount": 1,
"paragraphs": [
{
"index": 0,
"style": "Title",
"fullText": "{{DOCUMENT_TITLE}}",
"runs": [
{ "text": "{{DOCUMENT_TITLE}}", "bold": true, "fontSize": 28 }
]
}
],
"placeholders": [
{ "tag": "{{DOCUMENT_TITLE}}", "location": "paragraph", "paragraphIndex": 0 },
{ "tag": "{{AUTHOR}}", "location": "footer-default", "paragraphIndex": 0 },
{ "tag": "${date}", "location": "table", "tableIndex": 0, "cellLocation": "R1C2" }
]
}Step 1b: Create Replacement Specification
Create replacements.json:
{
"textReplacements": [
{ "tag": "{{DOCUMENT_TITLE}}", "value": "Q4 2024 Financial Report" },
{ "tag": "{{AUTHOR}}", "value": "Finance Department" },
{ "tag": "${date}", "value": "December 15, 2024" },
{ "tag": "{{COMPANY}}", "value": "Acme Corporation" }
],
"includeHeaders": true,
"includeFooters": true
}Step 1c: Generate Output
deno run --allow-read --allow-write scripts/generate-from-template.ts \
corporate-template.docx replacements.json output.docxMode 2: From-Scratch Generation
Step 2a: Create Specification
Create spec.json:
{
"title": "Quarterly Report",
"creator": "Finance Team",
"styles": {
"defaultFont": "Calibri",
"defaultFontSize": 11
},
"sections": [
{
"header": {
"paragraphs": [
{ "text": "Acme Corporation", "alignment": "right" }
]
},
"footer": {
"paragraphs": [
{ "text": "Confidential", "alignment": "center" }
]
},
"content": [
{
"text": "Q4 2024 Financial Report",
"heading": 1,
"alignment": "center"
},
{
"runs": [
{ "text": "Executive Summary: ", "bold": true },
{ "text": "This report provides an overview of our financial performance for Q4 2024." }
]
},
{ "pageBreak": true },
{
"text": "Revenue Breakdown",
"heading": 2
},
{
"rows": [
{
"cells": [
{ "content": [{ "text": "Category" }], "shading": "DDDDDD" },
{ "content": [{ "text": "Amount" }], "shading": "DDDDDD" },
{ "content": [{ "text": "Change" }], "shading": "DDDDDD" }
],
"isHeader": true
},
{
"cells": [
{ "content": [{ "text": "Product Sales" }] },
{ "content": [{ "text": "$1,250,000" }] },
{ "content": [{ "text": "+15%" }] }
]
},
{
"cells": [
{ "content": [{ "text": "Services" }] },
{ "content": [{ "text": "$750,000" }] },
{ "content": [{ "text": "+8%" }] }
]
}
],
"width": 100,
"borders": true
}
]
}
]
}Step 2b: Generate Document
deno run --allow-read --allow-write scripts/generate-scratch.ts spec.json output.docxExamples
Example 1: Contract Generation
Scenario: Generate contracts from a branded template.
Steps:
# 1. Analyze template for replaceable content
deno run --allow-read scripts/analyze-template.ts contract-template.docx --pretty
# 2. Create replacements.json with client data
# 3. Generate contract
deno run --allow-read --allow-write scripts/generate-from-template.ts \
contract-template.docx replacements.json acme-contract.docxExample 2: Report with Tables
Scenario: Generate a data report with tables and formatting.
spec.json:
{
"title": "Sales Report",
"sections": [{
"content": [
{ "text": "Monthly Sales Report", "heading": 1 },
{ "text": "January 2025", "heading": 2 },
{
"runs": [
{ "text": "Total Sales: ", "bold": true },
{ "text": "$125,000", "color": "2E7D32" }
]
}
]
}]
}Example 3: Letter with Headers/Footers
Scenario: Create a formal letter with letterhead.
spec.json:
{
"sections": [{
"header": {
"paragraphs": [
{ "text": "ACME CORPORATION", "alignment": "center", "runs": [{"text": "ACME CORPORATION", "bold": true, "fontSize": 16}] },
{ "text": "123 Business Ave, City, ST 12345", "alignment": "center" }
]
},
"content": [
{ "text": "December 15, 2024", "alignment": "right" },
{ "text": "" },
{ "text": "Dear Valued Customer," },
{ "text": "" },
{ "text": "Thank you for your continued business..." },
{ "text": "" },
{ "text": "Sincerely," },
{ "text": "John Smith" },
{ "runs": [{ "text": "CEO", "italic": true }] }
],
"footer": {
"paragraphs": [
{ "text": "www.acme.com | contact@acme.com", "alignment": "center" }
]
}
}]
}Script Reference
| Script | Purpose | Permissions |
|---|---|---|
analyze-template.ts | Extract text, tables, placeholders from DOCX | --allow-read |
generate-from-template.ts | Replace placeholders in templates | --allow-read --allow-write |
generate-scratch.ts | Create DOCX from JSON specification | --allow-read --allow-write |
Specification Reference
Paragraph Options
| Property | Type | Description |
|---|---|---|
text | string | Simple text content |
runs | array | Formatted text runs (for mixed formatting) |
heading | 1-6 | Heading level |
alignment | string | left, center, right, justify |
bullet | boolean | Bulleted list item |
numbering | boolean | Numbered list item |
spacing | object | before, after, line spacing |
indent | object | left, right, firstLine indentation |
pageBreakBefore | boolean | Insert page break before paragraph |
Text Run Options
| Property | Type | Description |
|---|---|---|
text | string | Text content |
bold | boolean | Bold formatting |
italic | boolean | Italic formatting |
underline | boolean | Underline formatting |
strike | boolean | Strikethrough |
fontSize | number | Font size in points |
font | string | Font family name |
color | string | Text color (hex, no #) |
highlight | string | Highlight color |
superScript | boolean | Superscript |
subScript | boolean | Subscript |
Table Options
| Property | Type | Description |
|---|---|---|
rows | array | Array of row specifications |
width | number | Table width as percentage |
borders | boolean | Show table borders |
Hyperlink Options
| Property | Type | Description |
|---|---|---|
text | string | Link text |
url | string | Target URL |
bold | boolean | Bold formatting |
italic | boolean | Italic formatting |
Common Issues and Solutions
Issue: Placeholders not being replaced
Symptoms: Output DOCX still contains {{PLACEHOLDER}} tags.
Solution: 1. Run analyze-template.ts to verify exact tag text 2. Tags may be split across XML runs - the script consolidates these automatically 3. Ensure includeHeaders and includeFooters are true if placeholders are there
Issue: Formatting lost after replacement
Symptoms: Replaced text doesn't match original formatting.
Solution:
- Text replacement preserves the formatting of the original placeholder
- Ensure placeholder is formatted the way you want the final text to appear
Issue: Images not appearing
Symptoms: Image elements are blank in output.
Solution: 1. Use paths relative to the spec.json file location 2. Verify image file exists and is readable 3. Check supported formats: PNG, JPEG, GIF
Issue: Table cell content incorrect
Symptoms: Table cells have wrong content or formatting.
Solution:
- Each cell's
contentmust be an array of paragraph specifications - Use
shadingfor background color,verticalAlignfor alignment
Limitations
- DOCX only: Does not support legacy .doc format
- No track changes: Cannot add or process track changes
- No comments: Cannot add document comments
- No macros: Cannot include VBA macros
- Basic numbering: Limited support for complex numbering schemes
- Text run splitting: Word may split text across XML elements; script handles common cases
Related Skills
- pptx-generator: For creating PowerPoint presentations
- xlsx-generator: For creating Excel spreadsheets
- pdf-generator: For creating PDF documents
#!/usr/bin/env -S deno run --allow-read
/**
* analyze-template.ts - Extract text content and structure from DOCX files
*
* Extracts paragraphs, tables, headers, footers, and placeholders from Word documents
* for template analysis and content replacement planning.
*
* Usage:
* deno run --allow-read scripts/analyze-template.ts <input.docx> [options]
*
* Options:
* -h, --help Show help
* -v, --verbose Enable verbose output
* --pretty Pretty-print JSON output
*
* Permissions:
* --allow-read: Read DOCX file
*/
import { parseArgs } from "jsr:@std/cli@1.0.9/parse-args";
import { basename } from "jsr:@std/path@1.0.8";
import JSZip from "npm:jszip@3.10.1";
import { DOMParser } from "npm:@xmldom/xmldom@0.9.6";
// === Types ===
export interface TextRun {
text: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
fontSize?: number;
fontFamily?: string;
color?: string;
}
export interface ParagraphInfo {
index: number;
style?: string;
alignment?: "left" | "center" | "right" | "justify";
runs: TextRun[];
fullText: string;
isListItem: boolean;
listLevel?: number;
}
export interface TableCellInfo {
row: number;
col: number;
text: string;
paragraphs: ParagraphInfo[];
}
export interface TableInfo {
index: number;
rows: number;
cols: number;
cells: TableCellInfo[];
}
export interface HeaderFooterInfo {
type: "header" | "footer";
section: "default" | "first" | "even";
paragraphs: ParagraphInfo[];
}
export interface PlaceholderInfo {
tag: string;
location: string;
paragraphIndex?: number;
tableIndex?: number;
cellLocation?: string;
}
export interface DocumentInventory {
filename: string;
paragraphCount: number;
tableCount: number;
imageCount: number;
paragraphs: ParagraphInfo[];
tables: TableInfo[];
headersFooters: HeaderFooterInfo[];
placeholders: PlaceholderInfo[];
styles: string[];
}
interface ParsedArgs {
help: boolean;
verbose: boolean;
pretty: boolean;
_: (string | number)[];
}
// === Constants ===
const VERSION = "1.0.0";
const SCRIPT_NAME = "analyze-template";
const NS = {
w: "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
r: "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
};
// Placeholder pattern: {{PLACEHOLDER}} or ${placeholder}
const PLACEHOLDER_REGEX = /\{\{([^}]+)\}\}|\$\{([^}]+)\}/g;
// === Help Text ===
function printHelp(): void {
console.log(`
${SCRIPT_NAME} v${VERSION} - Extract text content from DOCX templates
Usage:
deno run --allow-read scripts/${SCRIPT_NAME}.ts <input.docx> [options]
Arguments:
<input.docx> Path to the Word document to analyze
Options:
-h, --help Show this help message
-v, --verbose Enable verbose output (to stderr)
--pretty Pretty-print JSON output (default: compact)
Examples:
# Analyze document
deno run --allow-read scripts/${SCRIPT_NAME}.ts template.docx > inventory.json
# With verbose output
deno run --allow-read scripts/${SCRIPT_NAME}.ts template.docx -v --pretty
`);
}
// === Utility Functions ===
// deno-lint-ignore no-explicit-any
function getElementsByTagNameNS(parent: any, ns: string, localName: string): any[] {
const elements = parent.getElementsByTagNameNS(ns, localName);
// deno-lint-ignore no-explicit-any
return Array.from(elements) as any[];
}
// deno-lint-ignore no-explicit-any
function getTextContent(element: any): string {
const textElements = getElementsByTagNameNS(element, NS.w, "t");
return textElements.map((t) => t.textContent || "").join("");
}
// === Parsing Functions ===
// deno-lint-ignore no-explicit-any
function parseTextRun(rElement: any): TextRun {
const run: TextRun = {
text: getTextContent(rElement),
};
const rPr = getElementsByTagNameNS(rElement, NS.w, "rPr")[0];
if (rPr) {
// Bold
if (getElementsByTagNameNS(rPr, NS.w, "b").length > 0) {
run.bold = true;
}
// Italic
if (getElementsByTagNameNS(rPr, NS.w, "i").length > 0) {
run.italic = true;
}
// Underline
if (getElementsByTagNameNS(rPr, NS.w, "u").length > 0) {
run.underline = true;
}
// Font size
const sz = getElementsByTagNameNS(rPr, NS.w, "sz")[0];
if (sz) {
const val = sz.getAttribute("w:val");
if (val) run.fontSize = parseInt(val, 10) / 2; // Half-points to points
}
// Font family
const rFonts = getElementsByTagNameNS(rPr, NS.w, "rFonts")[0];
if (rFonts) {
run.fontFamily = rFonts.getAttribute("w:ascii") || rFonts.getAttribute("w:hAnsi");
}
// Color
const color = getElementsByTagNameNS(rPr, NS.w, "color")[0];
if (color) {
run.color = color.getAttribute("w:val");
}
}
return run;
}
// deno-lint-ignore no-explicit-any
function parseParagraph(pElement: any, index: number): ParagraphInfo {
const runs = getElementsByTagNameNS(pElement, NS.w, "r").map(parseTextRun);
const fullText = runs.map((r) => r.text).join("");
const para: ParagraphInfo = {
index,
runs,
fullText,
isListItem: false,
};
const pPr = getElementsByTagNameNS(pElement, NS.w, "pPr")[0];
if (pPr) {
// Style
const pStyle = getElementsByTagNameNS(pPr, NS.w, "pStyle")[0];
if (pStyle) {
para.style = pStyle.getAttribute("w:val");
}
// Alignment
const jc = getElementsByTagNameNS(pPr, NS.w, "jc")[0];
if (jc) {
const val = jc.getAttribute("w:val");
if (val === "left" || val === "center" || val === "right" || val === "both") {
para.alignment = val === "both" ? "justify" : val;
}
}
// List item
const numPr = getElementsByTagNameNS(pPr, NS.w, "numPr")[0];
if (numPr) {
para.isListItem = true;
const ilvl = getElementsByTagNameNS(numPr, NS.w, "ilvl")[0];
if (ilvl) {
para.listLevel = parseInt(ilvl.getAttribute("w:val") || "0", 10);
}
}
}
return para;
}
// deno-lint-ignore no-explicit-any
function parseTable(tblElement: any, index: number): TableInfo {
const rows = getElementsByTagNameNS(tblElement, NS.w, "tr");
const cells: TableCellInfo[] = [];
let maxCols = 0;
rows.forEach((row, rowIndex) => {
const tcs = getElementsByTagNameNS(row, NS.w, "tc");
maxCols = Math.max(maxCols, tcs.length);
tcs.forEach((tc, colIndex) => {
const paragraphs = getElementsByTagNameNS(tc, NS.w, "p").map((p, i) =>
parseParagraph(p, i)
);
cells.push({
row: rowIndex,
col: colIndex,
text: paragraphs.map((p) => p.fullText).join("\n"),
paragraphs,
});
});
});
return {
index,
rows: rows.length,
cols: maxCols,
cells,
};
}
function findPlaceholders(
text: string,
location: string,
paragraphIndex?: number,
tableIndex?: number,
cellLocation?: string
): PlaceholderInfo[] {
const placeholders: PlaceholderInfo[] = [];
let match;
while ((match = PLACEHOLDER_REGEX.exec(text)) !== null) {
placeholders.push({
tag: match[0],
location,
paragraphIndex,
tableIndex,
cellLocation,
});
}
return placeholders;
}
// deno-lint-ignore no-explicit-any
async function parseDocumentPart(
zip: JSZip,
partPath: string,
type: "header" | "footer",
section: "default" | "first" | "even"
): Promise<HeaderFooterInfo | null> {
const file = zip.file(partPath);
if (!file) return null;
const xml = await file.async("string");
const parser = new DOMParser();
const doc = parser.parseFromString(xml, "text/xml");
const paragraphs = getElementsByTagNameNS(doc, NS.w, "p").map((p, i) =>
parseParagraph(p, i)
);
return {
type,
section,
paragraphs,
};
}
// === Core Logic ===
export async function analyzeDocument(
docxPath: string,
options: { verbose?: boolean } = {}
): Promise<DocumentInventory> {
const { verbose = false } = options;
// Read the DOCX file
const data = await Deno.readFile(docxPath);
const zip = await JSZip.loadAsync(data);
const filename = basename(docxPath);
// Parse main document
const documentFile = zip.file("word/document.xml");
if (!documentFile) {
throw new Error("Invalid DOCX: missing word/document.xml");
}
const documentXml = await documentFile.async("string");
const parser = new DOMParser();
const doc = parser.parseFromString(documentXml, "text/xml");
// Parse paragraphs
const bodyParagraphs = getElementsByTagNameNS(doc, NS.w, "p");
const paragraphs: ParagraphInfo[] = [];
const allPlaceholders: PlaceholderInfo[] = [];
let paragraphIndex = 0;
for (const p of bodyParagraphs) {
// Skip paragraphs inside tables (they're handled separately)
// deno-lint-ignore no-explicit-any
let parent = (p as any).parentNode;
let inTable = false;
while (parent) {
if (parent.localName === "tc") {
inTable = true;
break;
}
parent = parent.parentNode;
}
if (!inTable) {
const para = parseParagraph(p, paragraphIndex);
paragraphs.push(para);
// Find placeholders
const placeholders = findPlaceholders(
para.fullText,
"paragraph",
paragraphIndex
);
allPlaceholders.push(...placeholders);
paragraphIndex++;
}
}
if (verbose) {
console.error(`Found ${paragraphs.length} paragraphs`);
}
// Parse tables
const tableElements = getElementsByTagNameNS(doc, NS.w, "tbl");
const tables = tableElements.map((tbl, i) => {
const table = parseTable(tbl, i);
// Find placeholders in table cells
for (const cell of table.cells) {
const placeholders = findPlaceholders(
cell.text,
"table",
undefined,
i,
`R${cell.row + 1}C${cell.col + 1}`
);
allPlaceholders.push(...placeholders);
}
return table;
});
if (verbose) {
console.error(`Found ${tables.length} tables`);
}
// Count images
let imageCount = 0;
zip.forEach((path) => {
if (path.startsWith("word/media/")) {
imageCount++;
}
});
if (verbose) {
console.error(`Found ${imageCount} images`);
}
// Parse headers and footers
const headersFooters: HeaderFooterInfo[] = [];
const headerFooterFiles = [
{ path: "word/header1.xml", type: "header" as const, section: "default" as const },
{ path: "word/header2.xml", type: "header" as const, section: "first" as const },
{ path: "word/header3.xml", type: "header" as const, section: "even" as const },
{ path: "word/footer1.xml", type: "footer" as const, section: "default" as const },
{ path: "word/footer2.xml", type: "footer" as const, section: "first" as const },
{ path: "word/footer3.xml", type: "footer" as const, section: "even" as const },
];
for (const { path, type, section } of headerFooterFiles) {
const hf = await parseDocumentPart(zip, path, type, section);
if (hf && hf.paragraphs.length > 0) {
headersFooters.push(hf);
// Find placeholders in headers/footers
for (const para of hf.paragraphs) {
const placeholders = findPlaceholders(
para.fullText,
`${type}-${section}`,
para.index
);
allPlaceholders.push(...placeholders);
}
}
}
// Collect unique styles
const styles = new Set<string>();
for (const para of paragraphs) {
if (para.style) styles.add(para.style);
}
if (verbose) {
console.error(`Found ${allPlaceholders.length} placeholders`);
}
return {
filename,
paragraphCount: paragraphs.length,
tableCount: tables.length,
imageCount,
paragraphs,
tables,
headersFooters,
placeholders: allPlaceholders,
styles: Array.from(styles),
};
}
// === Main CLI Handler ===
async function main(args: string[]): Promise<void> {
const parsed = parseArgs(args, {
boolean: ["help", "verbose", "pretty"],
alias: { help: "h", verbose: "v" },
default: { verbose: false, pretty: false },
}) as ParsedArgs;
if (parsed.help) {
printHelp();
Deno.exit(0);
}
const positionalArgs = parsed._.map(String);
if (positionalArgs.length === 0) {
console.error("Error: No input file provided\n");
printHelp();
Deno.exit(1);
}
const inputPath = positionalArgs[0];
try {
const inventory = await analyzeDocument(inputPath, {
verbose: parsed.verbose,
});
const output = parsed.pretty
? JSON.stringify(inventory, null, 2)
: JSON.stringify(inventory);
console.log(output);
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error)
);
Deno.exit(1);
}
}
// === Entry Point ===
if (import.meta.main) {
main(Deno.args);
}
#!/usr/bin/env -S deno run --allow-read --allow-write
/**
* generate-from-template.ts - Generate DOCX from existing templates
*
* Modifies existing Word templates using placeholder replacement.
* Finds and replaces tagged content (e.g., {{TITLE}}, ${author}) in
* paragraphs, tables, headers, and footers.
*
* Usage:
* deno run --allow-read --allow-write scripts/generate-from-template.ts <template.docx> <spec.json> <output.docx>
*
* Options:
* -h, --help Show help
* -v, --verbose Enable verbose output
*
* Permissions:
* --allow-read: Read template and specification files
* --allow-write: Write output DOCX file
*/
import { parseArgs } from "jsr:@std/cli@1.0.9/parse-args";
import { basename, dirname, resolve } from "jsr:@std/path@1.0.8";
import JSZip from "npm:jszip@3.10.1";
import { DOMParser, XMLSerializer } from "npm:@xmldom/xmldom@0.9.6";
// === Types ===
export interface TextReplacement {
/** The tag to find and replace (e.g., "{{TITLE}}" or "${author}") */
tag: string;
/** The replacement text */
value: string;
}
export interface ImageReplacement {
/** The placeholder tag for the image (e.g., "{{LOGO}}") */
tag: string;
/** Path to the image file (relative to spec file) */
imagePath: string;
/** Width in pixels */
width: number;
/** Height in pixels */
height: number;
}
export interface TemplateSpec {
/** Text replacements to apply */
textReplacements?: TextReplacement[];
/** Image replacements (replaces placeholder text with image) */
imageReplacements?: ImageReplacement[];
/** Whether to apply replacements in headers */
includeHeaders?: boolean;
/** Whether to apply replacements in footers */
includeFooters?: boolean;
}
interface ParsedArgs {
help: boolean;
verbose: boolean;
_: (string | number)[];
}
// === Constants ===
const VERSION = "1.0.0";
const SCRIPT_NAME = "generate-from-template";
// XML Namespaces
const NS = {
w: "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
r: "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
rel: "http://schemas.openxmlformats.org/package/2006/relationships",
wp: "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
a: "http://schemas.openxmlformats.org/drawingml/2006/main",
pic: "http://schemas.openxmlformats.org/drawingml/2006/picture",
};
// Placeholder patterns: {{PLACEHOLDER}} or ${placeholder}
const PLACEHOLDER_PATTERNS = [
/\{\{([^}]+)\}\}/g, // {{TAG}}
/\$\{([^}]+)\}/g, // ${tag}
];
// === Help Text ===
function printHelp(): void {
console.log(`
${SCRIPT_NAME} v${VERSION} - Generate DOCX from existing templates
Usage:
deno run --allow-read --allow-write scripts/${SCRIPT_NAME}.ts <template.docx> <spec.json> <output.docx>
Arguments:
<template.docx> Path to the template Word document
<spec.json> Path to JSON specification for replacements
<output.docx> Path for output Word document
Options:
-h, --help Show this help message
-v, --verbose Enable verbose output
Specification Format:
{
"textReplacements": [
{ "tag": "{{TITLE}}", "value": "Quarterly Report" },
{ "tag": "{{DATE}}", "value": "December 2024" },
{ "tag": "\${author}", "value": "John Smith" }
],
"includeHeaders": true,
"includeFooters": true
}
Examples:
# Replace text in template
deno run --allow-read --allow-write scripts/${SCRIPT_NAME}.ts \\
template.docx replacements.json output.docx
# With verbose output
deno run --allow-read --allow-write scripts/${SCRIPT_NAME}.ts \\
template.docx replacements.json output.docx -v
`);
}
// === Utility Functions ===
// deno-lint-ignore no-explicit-any
function getElementsByTagNameNS(parent: any, ns: string, localName: string): any[] {
const elements = parent.getElementsByTagNameNS(ns, localName);
// deno-lint-ignore no-explicit-any
return Array.from(elements) as any[];
}
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// === Text Replacement ===
interface ReplacementResult {
modified: boolean;
replacementCount: number;
}
function replaceTextInXml(
xmlContent: string,
replacements: TextReplacement[],
verbose: boolean = false
): { content: string; result: ReplacementResult } {
const parser = new DOMParser();
const doc = parser.parseFromString(xmlContent, "text/xml");
let totalReplacements = 0;
let modified = false;
// Find all text elements
const textElements = getElementsByTagNameNS(doc, NS.w, "t");
// deno-lint-ignore no-explicit-any
for (const textEl of textElements as any[]) {
let text = textEl.textContent || "";
const originalText = text;
for (const replacement of replacements) {
// The tag should match exactly as provided
const tag = replacement.tag;
if (text.includes(tag)) {
const regex = new RegExp(escapeRegExp(tag), "g");
const matches = text.match(regex);
if (matches) {
totalReplacements += matches.length;
}
text = text.replace(regex, replacement.value);
}
}
if (text !== originalText) {
textEl.textContent = text;
modified = true;
}
}
const serializer = new XMLSerializer();
return {
content: serializer.serializeToString(doc),
result: { modified, replacementCount: totalReplacements },
};
}
/**
* Handle split placeholders across multiple text runs.
* Word often splits text like "{{TITLE}}" into multiple runs like "<w:t>{{</w:t><w:t>TITLE</w:t><w:t>}}</w:t>"
* This function consolidates runs within a paragraph before replacement.
*/
function consolidateTextRuns(xmlContent: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(xmlContent, "text/xml");
// Process each paragraph
const paragraphs = getElementsByTagNameNS(doc, NS.w, "p");
// deno-lint-ignore no-explicit-any
for (const para of paragraphs as any[]) {
const runs = getElementsByTagNameNS(para, NS.w, "r");
// Collect all text content and check for split placeholders
let fullText = "";
// deno-lint-ignore no-explicit-any
const textNodes: any[] = [];
// deno-lint-ignore no-explicit-any
for (const run of runs as any[]) {
const tElements = getElementsByTagNameNS(run, NS.w, "t");
for (const t of tElements) {
fullText += t.textContent || "";
textNodes.push(t);
}
}
// Check if there are placeholders in the combined text that might be split
const hasSplitPlaceholder = (
(fullText.includes("{{") && fullText.includes("}}")) ||
(fullText.includes("${") && fullText.includes("}"))
);
// Only consolidate if we have split placeholders and multiple text nodes
if (hasSplitPlaceholder && textNodes.length > 1) {
// Put all text in the first node and clear others
if (textNodes.length > 0) {
textNodes[0].textContent = fullText;
for (let i = 1; i < textNodes.length; i++) {
textNodes[i].textContent = "";
}
}
}
}
const serializer = new XMLSerializer();
return serializer.serializeToString(doc);
}
// === Core Logic ===
export async function generateFromTemplate(
templatePath: string,
spec: TemplateSpec,
outputPath: string,
options: { verbose?: boolean; specDir?: string } = {}
): Promise<void> {
const { verbose = false } = options;
// Read template
const templateData = await Deno.readFile(templatePath);
const zip = await JSZip.loadAsync(templateData);
if (verbose) {
console.error(`Loaded template: ${basename(templatePath)}`);
}
const replacements = spec.textReplacements || [];
const includeHeaders = spec.includeHeaders !== false;
const includeFooters = spec.includeFooters !== false;
if (verbose) {
console.error(`Processing ${replacements.length} text replacements`);
console.error(`Include headers: ${includeHeaders}, Include footers: ${includeFooters}`);
}
let totalReplacements = 0;
// Process main document
const documentFile = zip.file("word/document.xml");
if (documentFile) {
let documentXml = await documentFile.async("string");
// Consolidate split text runs
documentXml = consolidateTextRuns(documentXml);
// Apply replacements
const { content, result } = replaceTextInXml(documentXml, replacements, verbose);
zip.file("word/document.xml", content);
totalReplacements += result.replacementCount;
if (verbose && result.modified) {
console.error(`Document: ${result.replacementCount} replacements`);
}
}
// Process headers
if (includeHeaders) {
const headerFiles = ["word/header1.xml", "word/header2.xml", "word/header3.xml"];
for (const headerPath of headerFiles) {
const headerFile = zip.file(headerPath);
if (headerFile) {
let headerXml = await headerFile.async("string");
headerXml = consolidateTextRuns(headerXml);
const { content, result } = replaceTextInXml(headerXml, replacements, verbose);
zip.file(headerPath, content);
totalReplacements += result.replacementCount;
if (verbose && result.modified) {
console.error(`${headerPath}: ${result.replacementCount} replacements`);
}
}
}
}
// Process footers
if (includeFooters) {
const footerFiles = ["word/footer1.xml", "word/footer2.xml", "word/footer3.xml"];
for (const footerPath of footerFiles) {
const footerFile = zip.file(footerPath);
if (footerFile) {
let footerXml = await footerFile.async("string");
footerXml = consolidateTextRuns(footerXml);
const { content, result } = replaceTextInXml(footerXml, replacements, verbose);
zip.file(footerPath, content);
totalReplacements += result.replacementCount;
if (verbose && result.modified) {
console.error(`${footerPath}: ${result.replacementCount} replacements`);
}
}
}
}
if (verbose) {
console.error(`Total replacements: ${totalReplacements}`);
}
// Write output file
const outputData = await zip.generateAsync({
type: "uint8array",
compression: "DEFLATE",
compressionOptions: { level: 6 },
});
await Deno.writeFile(outputPath, outputData);
if (verbose) {
console.error(`Wrote ${outputPath}`);
}
}
// === Main CLI Handler ===
async function main(args: string[]): Promise<void> {
const parsed = parseArgs(args, {
boolean: ["help", "verbose"],
alias: { help: "h", verbose: "v" },
default: { verbose: false },
}) as ParsedArgs;
if (parsed.help) {
printHelp();
Deno.exit(0);
}
const positionalArgs = parsed._.map(String);
if (positionalArgs.length < 3) {
console.error(
"Error: template.docx, spec.json, and output.docx are required\n"
);
printHelp();
Deno.exit(1);
}
const templatePath = positionalArgs[0];
const specPath = positionalArgs[1];
const outputPath = positionalArgs[2];
try {
// Read specification
const specText = await Deno.readTextFile(specPath);
const spec = JSON.parse(specText) as TemplateSpec;
const specDir = dirname(resolve(specPath));
await generateFromTemplate(templatePath, spec, outputPath, {
verbose: parsed.verbose,
specDir,
});
console.log(`Created: ${outputPath}`);
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error)
);
Deno.exit(1);
}
}
// === Entry Point ===
if (import.meta.main) {
main(Deno.args);
}
#!/usr/bin/env -S deno run --allow-read --allow-write
/**
* generate-scratch.ts - Create DOCX from scratch using JSON specification
*
* Creates Word documents programmatically from a JSON specification
* using the docx library. Supports paragraphs, tables, images, headers, and footers.
*
* Usage:
* deno run --allow-read --allow-write scripts/generate-scratch.ts <spec.json> <output.docx>
*
* Options:
* -h, --help Show help
* -v, --verbose Enable verbose output
*
* Permissions:
* --allow-read: Read specification file and image assets
* --allow-write: Write output DOCX file
*/
import { parseArgs } from "jsr:@std/cli@1.0.9/parse-args";
import { dirname, resolve } from "jsr:@std/path@1.0.8";
import {
Document,
Packer,
Paragraph,
TextRun,
Table,
TableRow,
TableCell,
WidthType,
AlignmentType,
HeadingLevel,
BorderStyle,
Header,
Footer,
PageBreak,
ImageRun,
ExternalHyperlink,
// deno-lint-ignore no-explicit-any
} from "npm:docx@9.0.2" as any;
// === Types ===
export interface TextRunSpec {
text: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
strike?: boolean;
fontSize?: number; // points
font?: string;
color?: string; // hex without #
highlight?: string;
superScript?: boolean;
subScript?: boolean;
}
export interface HyperlinkSpec {
text: string;
url: string;
bold?: boolean;
italic?: boolean;
}
export interface ImageSpec {
path: string;
width: number; // pixels
height: number; // pixels
altText?: string;
}
export interface ParagraphSpec {
text?: string;
runs?: (TextRunSpec | HyperlinkSpec | ImageSpec)[];
heading?: 1 | 2 | 3 | 4 | 5 | 6;
alignment?: "left" | "center" | "right" | "justify";
bullet?: boolean;
numbering?: boolean;
spacing?: {
before?: number;
after?: number;
line?: number;
};
indent?: {
left?: number;
right?: number;
firstLine?: number;
};
pageBreakBefore?: boolean;
}
export interface TableCellSpec {
content: ParagraphSpec[];
width?: number; // percentage or points
rowSpan?: number;
colSpan?: number;
shading?: string; // hex color
verticalAlign?: "top" | "center" | "bottom";
}
export interface TableRowSpec {
cells: TableCellSpec[];
isHeader?: boolean;
}
export interface TableSpec {
rows: TableRowSpec[];
width?: number; // percentage
borders?: boolean;
}
export interface HeaderFooterSpec {
paragraphs: ParagraphSpec[];
}
export interface SectionSpec {
content: (ParagraphSpec | TableSpec | { pageBreak: true })[];
header?: HeaderFooterSpec;
footer?: HeaderFooterSpec;
}
export interface DocumentSpec {
title?: string;
subject?: string;
creator?: string;
description?: string;
styles?: {
defaultFont?: string;
defaultFontSize?: number;
};
sections: SectionSpec[];
}
interface ParsedArgs {
help: boolean;
verbose: boolean;
_: (string | number)[];
}
// === Constants ===
const VERSION = "1.0.0";
const SCRIPT_NAME = "generate-scratch";
// === Help Text ===
function printHelp(): void {
console.log(`
${SCRIPT_NAME} v${VERSION} - Create DOCX from scratch using JSON specification
Usage:
deno run --allow-read --allow-write scripts/${SCRIPT_NAME}.ts <spec.json> <output.docx>
Arguments:
<spec.json> Path to JSON specification file
<output.docx> Path for output Word document
Options:
-h, --help Show this help message
-v, --verbose Enable verbose output
Specification Format:
{
"title": "Document Title",
"creator": "Author Name",
"sections": [
{
"content": [
{
"text": "Hello World",
"heading": 1
},
{
"runs": [
{ "text": "Bold text", "bold": true },
{ "text": " and ", "italic": true },
{ "text": "normal text" }
]
}
]
}
]
}
Examples:
# Generate document
deno run --allow-read --allow-write scripts/${SCRIPT_NAME}.ts spec.json output.docx
# With verbose output
deno run --allow-read --allow-write scripts/${SCRIPT_NAME}.ts spec.json output.docx -v
`);
}
// === Conversion Helpers ===
function getAlignment(align?: string): typeof AlignmentType[keyof typeof AlignmentType] | undefined {
switch (align) {
case "left": return AlignmentType.LEFT;
case "center": return AlignmentType.CENTER;
case "right": return AlignmentType.RIGHT;
case "justify": return AlignmentType.JUSTIFIED;
default: return undefined;
}
}
function getHeadingLevel(level?: number): typeof HeadingLevel[keyof typeof HeadingLevel] | undefined {
switch (level) {
case 1: return HeadingLevel.HEADING_1;
case 2: return HeadingLevel.HEADING_2;
case 3: return HeadingLevel.HEADING_3;
case 4: return HeadingLevel.HEADING_4;
case 5: return HeadingLevel.HEADING_5;
case 6: return HeadingLevel.HEADING_6;
default: return undefined;
}
}
// === Element Builders ===
function buildTextRun(spec: TextRunSpec): typeof TextRun {
return new TextRun({
text: spec.text,
bold: spec.bold,
italics: spec.italic,
underline: spec.underline ? {} : undefined,
strike: spec.strike,
size: spec.fontSize ? spec.fontSize * 2 : undefined, // points to half-points
font: spec.font,
color: spec.color,
highlight: spec.highlight,
superScript: spec.superScript,
subScript: spec.subScript,
});
}
async function buildImageRun(spec: ImageSpec, specDir: string): Promise<typeof ImageRun> {
const imagePath = resolve(specDir, spec.path);
const imageData = await Deno.readFile(imagePath);
return new ImageRun({
data: imageData,
transformation: {
width: spec.width,
height: spec.height,
},
altText: spec.altText ? { title: spec.altText, description: spec.altText } : undefined,
});
}
function buildHyperlink(spec: HyperlinkSpec): typeof ExternalHyperlink {
return new ExternalHyperlink({
link: spec.url,
children: [
new TextRun({
text: spec.text,
bold: spec.bold,
italics: spec.italic,
style: "Hyperlink",
}),
],
});
}
function isHyperlink(spec: TextRunSpec | HyperlinkSpec | ImageSpec): spec is HyperlinkSpec {
return "url" in spec;
}
function isImage(spec: TextRunSpec | HyperlinkSpec | ImageSpec): spec is ImageSpec {
return "path" in spec && "width" in spec;
}
// deno-lint-ignore no-explicit-any
async function buildParagraph(spec: ParagraphSpec, specDir: string): Promise<any> {
// deno-lint-ignore no-explicit-any
const children: any[] = [];
if (spec.pageBreakBefore) {
children.push(new PageBreak());
}
if (spec.text) {
children.push(new TextRun(spec.text));
}
if (spec.runs) {
for (const run of spec.runs) {
if (isImage(run)) {
children.push(await buildImageRun(run, specDir));
} else if (isHyperlink(run)) {
children.push(buildHyperlink(run));
} else {
children.push(buildTextRun(run));
}
}
}
return new Paragraph({
children,
heading: getHeadingLevel(spec.heading),
alignment: getAlignment(spec.alignment),
bullet: spec.bullet ? { level: 0 } : undefined,
numbering: spec.numbering ? { reference: "default-numbering", level: 0 } : undefined,
spacing: spec.spacing ? {
before: spec.spacing.before,
after: spec.spacing.after,
line: spec.spacing.line,
} : undefined,
indent: spec.indent ? {
left: spec.indent.left,
right: spec.indent.right,
firstLine: spec.indent.firstLine,
} : undefined,
});
}
// deno-lint-ignore no-explicit-any
async function buildTableCell(spec: TableCellSpec, specDir: string): Promise<any> {
const paragraphs = await Promise.all(
spec.content.map((p) => buildParagraph(p, specDir))
);
return new TableCell({
children: paragraphs,
width: spec.width ? { size: spec.width, type: WidthType.PERCENTAGE } : undefined,
rowSpan: spec.rowSpan,
columnSpan: spec.colSpan,
shading: spec.shading ? { fill: spec.shading } : undefined,
verticalAlign: spec.verticalAlign === "center" ? "center" :
spec.verticalAlign === "bottom" ? "bottom" : undefined,
});
}
// deno-lint-ignore no-explicit-any
async function buildTable(spec: TableSpec, specDir: string): Promise<any> {
const rows = await Promise.all(
spec.rows.map(async (rowSpec) => {
const cells = await Promise.all(
rowSpec.cells.map((c) => buildTableCell(c, specDir))
);
return new TableRow({
children: cells,
tableHeader: rowSpec.isHeader,
});
})
);
return new Table({
rows,
width: spec.width ? { size: spec.width, type: WidthType.PERCENTAGE } : undefined,
borders: spec.borders === false ? {
top: { style: BorderStyle.NONE },
bottom: { style: BorderStyle.NONE },
left: { style: BorderStyle.NONE },
right: { style: BorderStyle.NONE },
insideHorizontal: { style: BorderStyle.NONE },
insideVertical: { style: BorderStyle.NONE },
} : undefined,
});
}
function isTable(item: ParagraphSpec | TableSpec | { pageBreak: true }): item is TableSpec {
return "rows" in item;
}
function isPageBreak(item: ParagraphSpec | TableSpec | { pageBreak: true }): item is { pageBreak: true } {
return "pageBreak" in item;
}
// deno-lint-ignore no-explicit-any
async function buildHeaderFooter(spec: HeaderFooterSpec, specDir: string): Promise<any[]> {
return Promise.all(spec.paragraphs.map((p) => buildParagraph(p, specDir)));
}
// === Core Logic ===
export async function generateFromSpec(
spec: DocumentSpec,
outputPath: string,
options: { verbose?: boolean; specDir?: string } = {}
): Promise<void> {
const { verbose = false, specDir = "." } = options;
if (verbose) {
console.error(`Creating document with ${spec.sections.length} section(s)`);
}
// Build sections
// deno-lint-ignore no-explicit-any
const sections: any[] = [];
for (let i = 0; i < spec.sections.length; i++) {
const sectionSpec = spec.sections[i];
// deno-lint-ignore no-explicit-any
const children: any[] = [];
if (verbose) {
console.error(`Processing section ${i + 1}: ${sectionSpec.content.length} items`);
}
for (const item of sectionSpec.content) {
if (isPageBreak(item)) {
children.push(new Paragraph({ children: [new PageBreak()] }));
} else if (isTable(item)) {
children.push(await buildTable(item, specDir));
} else {
children.push(await buildParagraph(item, specDir));
}
}
// deno-lint-ignore no-explicit-any
const section: any = { children };
// Add header
if (sectionSpec.header) {
section.headers = {
default: new Header({
children: await buildHeaderFooter(sectionSpec.header, specDir),
}),
};
}
// Add footer
if (sectionSpec.footer) {
section.footers = {
default: new Footer({
children: await buildHeaderFooter(sectionSpec.footer, specDir),
}),
};
}
sections.push(section);
}
// Create document
const doc = new Document({
title: spec.title,
subject: spec.subject,
creator: spec.creator,
description: spec.description,
styles: spec.styles ? {
default: {
document: {
run: {
font: spec.styles.defaultFont,
size: spec.styles.defaultFontSize ? spec.styles.defaultFontSize * 2 : undefined,
},
},
},
} : undefined,
sections,
});
// Write output
const buffer = await Packer.toBuffer(doc);
await Deno.writeFile(outputPath, new Uint8Array(buffer));
if (verbose) {
console.error(`Wrote ${outputPath}`);
}
}
// === Main CLI Handler ===
async function main(args: string[]): Promise<void> {
const parsed = parseArgs(args, {
boolean: ["help", "verbose"],
alias: { help: "h", verbose: "v" },
default: { verbose: false },
}) as ParsedArgs;
if (parsed.help) {
printHelp();
Deno.exit(0);
}
const positionalArgs = parsed._.map(String);
if (positionalArgs.length < 2) {
console.error("Error: Both spec.json and output.docx are required\n");
printHelp();
Deno.exit(1);
}
const specPath = positionalArgs[0];
const outputPath = positionalArgs[1];
try {
const specText = await Deno.readTextFile(specPath);
const spec = JSON.parse(specText) as DocumentSpec;
const specDir = dirname(resolve(specPath));
await generateFromSpec(spec, outputPath, {
verbose: parsed.verbose,
specDir,
});
console.log(`Created: ${outputPath}`);
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error)
);
Deno.exit(1);
}
}
// === Entry Point ===
if (import.meta.main) {
main(Deno.args);
}
Related skills
How it compares
Use docx-generator for Deno-based DOCX template parsing; choose PDF or HTML report skills when deliverables are not Word documents.
FAQ
How does docx-generator analyze a Word template?
docx-generator runs Deno analyze-template.ts with --allow-read on an input .docx, using JSZip 3.10.1 to unpack the file and extract paragraphs, tables, headers, footers, and placeholders as JSON for replacement planning.
What runtime does docx-generator require?
docx-generator expects Deno with read permissions (deno run --allow-read). It depends on JSR @std/cli and @std/path plus npm:jszip for parsing Office Open XML inside .docx archives.
Is Docx Generator safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.