# Academic DOCX Table

> Builds publication-quality Word (.docx) regression tables for strategy/management journals (SMJ, JMS, AMJ, ASQ style) using python-docx. Covers SMJ manuscript conventions (double-space body, APA headings, tables at end), standard model progression (M1=controls, M2=IV+controls, M3+=moderator+IV+interaction+controls), compact β/[p]/(SE) cell format, academic top-bottom borders, landscape section breaks for wide tables, merged moderator rows, FE as "Yes", VIF reporting, and correlation+descriptive statistics tables (numbered lower-triangle with Mean/SD rows). Use when building or reformatting any regression, correlation, or descriptive statistics table in Word.

- Skill: `riclyme/academic-docx-table` (Agent Skill)
- Install (CLI): `npx skillmds@latest add riclyme/academic-docx-table`
- Raw SKILL.md: https://api.skillmd.com/api/skills/riclyme/academic-docx-table/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: riclyme (https://skillmd.com/u/riclyme)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/riclyme/academic-docx-table

---


# `/academic-docx-table` — Publication-Quality Word Regression Tables (SMJ/JMS/AMJ)

Encodes the full Word table formatting convention refined over many iterations for the BG Divestment project. Applies to Table 2 (main), TABLE A1–A4 (appendix), and any future regression output tables.

---

## Report presentation rules (updated 2026-09-12)

Apply these rules consistently to every report produced with this skill, including main documents, supplements, appendices, and standalone tables. They govern presentation only; preserve the existing data, models, hypotheses, and significance-star convention.

1. **One font size:** use Times New Roman **12 pt** for titles, headings, body, all table cells (coefficient, p-value, and SE alike), table notes, captions, headers, footers, and page numbers. Keep permitted bold emphasis without changing font size; table cells and table notes use upright text as specified below. Use single line spacing in tables with enough row height; do not constrain 12 pt text to the old exact 9/10 pt line height. If content does not fit, wrap labels, widen columns, use landscape, split column panels, or continue on another page with repeated headers. Do not shrink text to fit.
2. **Three-decimal rounding:** display continuous statistics (coefficients, SEs, confidence limits, correlations, means/SDs, fit statistics, and reported p-values) with exactly three decimals, rounded from the unrounded source using `Decimal(str(x)).quantize(Decimal("0.001"), rounding=ROUND_HALF_UP)`. Do not truncate, double-round an already shortened display, or use binary-float half-even formatting as the rounding step. Normalize negative zero to `0.000`. Keep observation counts, years, model/table/page numbers, and other integer identifiers as integers. Preserve full precision in data/audit exports.
3. **Numeric p-value display:** show a leading zero and three decimals, e.g. `[0.001]`, `[0.000]`, or italic `p = 0.038` in prose. Do not replace a reported p-value with an inequality or threshold. A displayed `0.000` is rounded to three decimals and never means an exact zero. Compute stars from the unrounded p-value using the project's existing thresholds; never infer stars from displayed p-values. In a star legend use words such as “p below 0.050,” keeping the established thresholds unchanged. Comparison operators inside code remain necessary and are not a display violation. If only a threshold statement is available, retrieve the precise saved p-value instead of inventing one.

4. **Blank means no value:** leave missing, unavailable, not-applicable, and not-in-this-model cells genuinely empty. Do not insert dash fillers (`—`, `–`, `-`), `N/A`, `NA`, a dot, a fabricated zero, whitespace padding, or empty `[]` / `()`. Treat `None` and `NaN` as blank display values. In a partly available coefficient cell, leave only the missing component's paragraph empty; an entirely absent entry has one empty paragraph and no text. A genuine numerical zero still displays `0.000`, and a real negative number retains its minus sign. This is display handling only: never replace analytic missing values with zeros or alter the source data. Keep absence reasons in the internal audit. A failed estimation or export must retain its diagnostic and must not be disguised as a successful model with blank output.
5. **No process notes in reports:** do not add outward-facing notes about audit/review success, reruns, version locks, source packages, generation steps, or rounding procedures. Keep those in internal audit records. Retain only concise statistical definitions needed to understand a table, such as estimator, outcome, SE treatment, significance-star convention, and sample definition. Do not delete these necessary definitions or turn internal workflow instructions into report prose.
6. **Upright table text:** all table cells and table notes use roman (not italic) text, including p-values, coefficients, SEs, headers, and labels. Bold emphasis remains allowed. This table-only rule does not remove the existing italic-statistic convention from non-table body prose.
7. **Hypothesis identifiers after variable labels:** append the verified current manuscript identifier once, directly after each corresponding focal estimated term's label, for example `Focal predictor (H1)` or `Focal predictor × moderator (H2)` only when that is the manuscript's actual mapping. For a moderation hypothesis, tag the interaction row, not the moderator's lower-order/control row. Read the current manuscript and project instructions (such as AGENTS.md) for the mapping; generic examples are not a universal numbering scheme. Never derive H numbers from column order or statistical significance, and never invent, reorder, or silently remap hypotheses.

Reusable display helper (keep raw values separate):

```python
from decimal import Decimal, ROUND_HALF_UP

def format_3(value):
    """Display missing values as blank and round real numbers without changing data."""
    if value is None:
        return ""
    number = Decimal(str(value))
    if number.is_nan():
        return ""
    if not number.is_finite():
        raise ValueError("Invalid infinite statistic; retain the estimation/export diagnostic")
    rounded = number.quantize(Decimal("0.001"), rounding=ROUND_HALF_UP)
    if rounded == 0:
        rounded = abs(rounded)
    return format(rounded, ".3f")

assert format_3(None) == ""
assert format_3(float("nan")) == ""
assert format_3(0) == "0.000"
assert format_3("-0.1525") == "-0.153"
assert format_3("0.6993") == "0.699"
assert format_3("0.2119") == "0.212"
assert format_3("0.0006") == "0.001"
assert format_3("0.0004") == "0.000"
assert format_3("0.1525") == "0.153"
assert format_3("-0.0004") == "0.000"
```

Before delivery, inspect rendered pages and the saved document's effective fonts, including table notes and page-number fields. Confirm the three-decimal displays against the full-precision source and check the star legend independently of rounding. Correct imported styles/direct formatting that retain a different size or theme font.

## PART 1 — SMJ Manuscript Formatting Conventions

### Document-level: double-space body text, Times New Roman 12pt

```python
style = doc.styles['Normal']
style.font.name = 'Times New Roman'
style.font.size = Pt(12)
style.paragraph_format.line_spacing_rule = WD_LINE_SPACING.DOUBLE
style.paragraph_format.space_after = Pt(0)
```

Section headings follow APA level hierarchy:
- **Level 1** (Method, Results, Discussion): centered, bold, Title Case
- **Level 2** (Sample, Measures, etc.): left-aligned, bold, Title Case
- **Level 3** (sub-sections): left-aligned, bold italic, Title Case, period, run-in text

```python
def heading1(doc, text):
    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    r = p.add_run(text)
    r.bold = True
    r.font.size = Pt(12)
    p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.DOUBLE

def heading2(doc, text):
    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.LEFT
    r = p.add_run(text)
    r.bold = True
    r.font.size = Pt(12)
    p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.DOUBLE

def heading3(doc, text):
    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.LEFT
    r = p.add_run(text + ".")
    r.bold = True
    r.italic = True
    r.font.size = Pt(12)
    p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.DOUBLE
```

### Tables go at the END of the manuscript (after references)

SMJ and most top management journals require all tables and figures at the end, each on its own page, after the reference list. Order: References → Tables (in order) → Figures (in order).

Structure of each table page:
1. Page break
2. Table title — immediately above the table, NO blank line between title and table
3. The table itself
4. Table notes below the table

```python
# Each table starts on a new page
doc.add_page_break()

# Title flush to table — no blank paragraph between them
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
r = p.add_run("Table 2. ")
r.bold = True
r.font.size = Pt(12)
r2 = p.add_run("Logistic Regression Results Predicting Divestiture")
r2.bold = False
r2.font.size = Pt(12)
p.paragraph_format.space_after = Pt(0)   # NO gap before table
p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE

# Table immediately follows — no vsp(), no add_paragraph()
tbl = doc.add_table(...)
```

Title format: **"Table N."** (bold) + title text (not bold), on one line. No period after the title. Sentence case for the title text.

---

## PART 2 — Standard Model Progression

Every regression table follows this fixed column structure:

| Column | Label | Contents |
|--------|-------|----------|
| M1 | Model 1 | **Controls only** — no focal IV, no moderators, no interactions |
| M2 | Model 2 | **Focal IV + all controls** — tests H1 (main effect) |
| M3 | Model 3 | **Moderator 1 + IV + Interaction 1 + all controls** — tests H2 |
| M4 | Model 4 | **Moderator 2 + IV + Interaction 2 + all controls** — tests H3 |
| M5 | Model 5 | **Moderator 3 + IV + Interaction 3 + all controls** — tests H4 |

Rules:
- Each model adds ONE new moderator + its interaction. Do NOT include previous moderators (to avoid collinearity confounds in reporting).
- Variables that are moderators in one model appear as controls (using their control-variable Stata name) in other models — use the **merged row** pattern (Part 3 below) to show them only once per row.
- M1 is always the baseline. Report it even if nothing is significant.

### Fixed effects: report "Yes" when included; otherwise leave the cell blank

Never report year FE or industry FE coefficients. Instead, add footer rows. For an effect not included or not applicable, use an empty string; keep any unavailable-status diagnostic in the internal audit:

```python
FOOTER = [
    ("Observations",        ["11,322", "11,322", "11,322", "11,322", "11,322"]),
    ("Year fixed effects",  ["Yes",    "Yes",    "Yes",    "Yes",    "Yes"   ]),
    ("Industry fixed effects",["Yes",  "Yes",    "Yes",    "Yes",    "Yes"   ]),
    ("Log-likelihood",      ["-2341.000",  "-2298.000",  "-2287.000",  "-2301.000",  "-2284.000" ]),
]
# M1 has no IV, so Pseudo R² or LL should increase monotonically through M2–M5
```

### VIF reporting

Report VIF for ONE model only — the most complete model (e.g., M5), **without** year FE and industry FE (adding FE inflates VIF artificially).

```stata
* In Stata — run the full model without FEs, then vif
reg depvar iv moderator interaction controls   // no i.year i.nic_two
estat vif
* Report: mean VIF < 10 (ideally < 5). If any single VIF > 10, flag it.
```

In the paper text (not a table): "Mean VIF = X.XXX (max = X.XXX), well below the threshold of 10, indicating no multicollinearity concern."

No separate VIF table needed unless a reviewer explicitly requests one.

---

## Cell format: compact 3-paragraph style

Each available coefficient cell contains exactly 3 paragraphs: coefficient, p-value, and SE. Missing components have empty paragraphs, without placeholder symbols or brackets. An entirely absent entry is one truly empty paragraph:

```python
def ct(cell, b=None, se=None, p=None, bold=False, star_fn=None):
    """Write available coefficient / [p] / (SE); leave missing parts truly blank."""
    cell.text = ""
    b_text, se_text, p_text = format_3(b), format_3(se), format_3(p)
    if not any((b_text, se_text, p_text)):
        return  # Word retains one empty paragraph, with no filler text.

    stars = ""
    if b_text and p_text:
        stars = star_fn(p) if star_fn else ("***" if p < 0.01 else "**" if p < 0.05 else "*" if p < 0.10 else "")
    lines = [b_text + stars, "[" + p_text + "]" if p_text else "",
             "(" + se_text + ")" if se_text else ""]
    for index, text in enumerate(lines):
        para = cell.paragraphs[0] if index == 0 else cell.add_paragraph()
        if text:
            run = para.add_run(text)
            run.bold = bool(bold and index == 0)
            run.italic = False
            run.font.name = "Times New Roman"
            run.font.size = Pt(12)
        para.paragraph_format.space_before = Pt(0)
        para.paragraph_format.space_after = Pt(0)
        para.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
```

Label cells (left column):
```python
def ct_label(cell, text, bold=False, indent=False):
    cell.text = ""
    p = cell.paragraphs[0]
    if text:
        r = p.add_run(("  " if indent else "") + text)
        r.bold = bold
        r.italic = False
        r.font.name = "Times New Roman"
        r.font.size = Pt(12)
    p.paragraph_format.space_before = Pt(0)
    p.paragraph_format.space_after  = Pt(0)
    p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
```

## Border style (academic top-bottom only)

No internal lines. Thick top + thin under header + thick bottom:

```python
from docx.oxml.ns import qn
from docx.oxml   import OxmlElement

def clear_table_borders(tbl):
    """Remove all default table borders."""
    tblPr = tbl._tbl.tblPr
    for tag in ['tblBorders']:
        el = tblPr.find(qn(f'w:{tag}'))
        if el is not None:
            tblPr.remove(el)

def set_cell_border(cell, **kwargs):
    """Set individual cell borders. kwargs: top=, bottom=, left=, right= each = {'sz': N, 'val': 'single'}"""
    tc   = cell._tc
    tcPr = tc.get_or_add_tcPr()
    tcBorders = OxmlElement('w:tcBorders')
    for edge, spec in kwargs.items():
        el = OxmlElement(f'w:{edge}')
        el.set(qn('w:val'),   spec.get('val',   'single'))
        el.set(qn('w:sz'),    str(spec.get('sz', 4)))
        el.set(qn('w:space'), '0')
        el.set(qn('w:color'), '000000')
        tcBorders.append(el)
    tcPr.append(tcBorders)

# Usage — apply after clear_table_borders():
# Top row (thick top + thin bottom):
for cell in tbl.rows[0].cells:
    set_cell_border(cell,
        top=    {'sz': 12, 'val': 'single'},
        bottom= {'sz': 4,  'val': 'single'})

# Last row (thick bottom only):
for cell in tbl.rows[-1].cells:
    set_cell_border(cell,
        bottom= {'sz': 12, 'val': 'single'})
```

## Landscape section (for wide tables)

Wide tables (5+ model columns) need landscape orientation. Apply via XML, not through the Word UI:

```python
from docx.oxml.ns import qn
from docx.oxml   import OxmlElement
from docx         import Document
from docx.shared  import Inches, Pt

def add_landscape_section(doc):
    """Add a landscape section break for the next content."""
    sectPr = OxmlElement('w:sectPr')
    pgSz   = OxmlElement('w:pgSz')
    pgSz.set(qn('w:w'),      '15840')   # 11 inches in twips
    pgSz.set(qn('w:h'),      '12240')   # 8.5 inches in twips
    pgSz.set(qn('w:orient'), 'landscape')
    pgMar  = OxmlElement('w:pgMar')
    pgMar.set(qn('w:top'),    '720')
    pgMar.set(qn('w:right'),  '720')
    pgMar.set(qn('w:bottom'), '720')
    pgMar.set(qn('w:left'),   '720')
    sectPr.append(pgSz)
    sectPr.append(pgMar)
    # Attach to last paragraph before the table
    last_para = doc.paragraphs[-1]
    last_para._p.get_or_add_pPr().append(sectPr)
```

## Merged moderator rows (eliminates duplicate variable display)

When BG size is BOTH a moderator (in model M3) and a control (in M1, M2, M4, M5), use ONE row with a list-type key. Each entry in the list gives the DATA key for that column:

```python
ROWS = [
    # (label, key, is_bold_hint)
    ("Board centrality (H1)",          'c_norm_eigen',                                               True),
    # BG size: M1=control log_bg_aff, M2=control, M3=moderator c_size, M4=control, M5=control
    ("BG size",                        ['log_bg_aff','log_bg_aff','c_size','log_bg_aff','log_bg_aff'], False),
    ("Board centrality × BG size (H2)",'c_size_int',                                                 True),
    # BG div: M1-M2=control, M3=control, M4=moderator c_div, M5=control
    ("BG diversification",             ['bg_div_unrelated','bg_div_unrelated','bg_div_unrelated','c_div','bg_div_unrelated'], False),
    ...
]

# Rendering loop — use isinstance to guard separator and bold logic:
CTRL_KEYS = {'log_firm_sales', 'roa', 'leverage', ...}
BOLD_KEYS  = {'c_norm_eigen', 'c_size_int', 'c_div_int', 'c_age_int'}

sep_done = False
for label_text, key, _ in ROWS:
    # Control variable separator — fires only on first string key in CTRL_KEYS
    if isinstance(key, str) and key in CTRL_KEYS and not sep_done:
        sep_row = tbl.add_row()
        sep_row.cells[0].text = "Control variables"
        sep_done = True

    is_bold = isinstance(key, str) and key in BOLD_KEYS

    if isinstance(key, list):
        vals = [DATA[key[j]][j] for j in range(len(key))]
    else:
        vals = DATA[key]   # list of (b, se, p) per column
```

**Rule**: Never list the same construct twice (once as moderator, once as control). Reviewers will flag it as an error.

## Single-spaced appendix helpers

```python
from docx.enum.text import WD_LINE_SPACING

def set_single_spacing(style):
    style.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE

def vsp(doc):
    """Thin 3pt vertical spacer — replaces blank doc.add_paragraph()."""
    p = doc.add_paragraph()
    p.paragraph_format.space_before = Pt(0)
    p.paragraph_format.space_after  = Pt(3)
    p.paragraph_format.line_spacing = Pt(3)

# In appendix: replace ALL standalone doc.add_paragraph() calls with vsp(doc)
# Use WD_LINE_SPACING.SINGLE everywhere (not DOUBLE) for body, headings, titles
```

## Left-aligned table and title (appendix convention)

```python
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.enum.text  import WD_ALIGN_PARAGRAPH

tbl.alignment = WD_TABLE_ALIGNMENT.LEFT

def left_title(doc, text, bold=False):
    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.LEFT
    r = p.add_run(text)
    r.bold = bold
    r.font.size = Pt(12)
    p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
    p.paragraph_format.space_before = Pt(0)
    p.paragraph_format.space_after  = Pt(2)
```

## Star notation convention

Preserve the project's established star thresholds. The generic examples below apply only if that is the existing convention; pass the original project's star function when it differs. Rounding never changes star assignment.

| Symbol | Threshold |
|--------|-----------|
| ***    | p below 0.010 |
| **     | p below 0.050 |
| *      | p below 0.100 |

For this generic convention, add footnote: `* p below 0.100, ** p below 0.050, *** p below 0.010. Standard errors in parentheses.`

## Full script skeleton

```python
from docx               import Document
from docx.shared        import Inches, Pt, RGBColor
from docx.enum.text     import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
from docx.enum.table    import WD_TABLE_ALIGNMENT
from docx.oxml.ns       import qn
from docx.oxml          import OxmlElement

doc = Document()

# Set default style to single-spaced 12pt
style = doc.styles['Normal']
style.font.name = 'Times New Roman'
style.font.size = Pt(12)
style.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE

# Add landscape section if needed
add_landscape_section(doc)

# Add table title (left-aligned)
left_title(doc, "Table X. [Table Title]", bold=True)
left_title(doc, "[subtitle or note]", bold=False)
vsp(doc)

# Build table
n_cols = 1 + N_MODELS   # label col + model cols
tbl = doc.add_table(rows=1 + len(ROWS) + len(FOOTER), cols=n_cols)
tbl.alignment = WD_TABLE_ALIGNMENT.LEFT
clear_table_borders(tbl)

# Header row
hdr = tbl.rows[0]
hdr.cells[0].text = ""
for j, label in enumerate(MODEL_LABELS):
    hdr.cells[j+1].text = label

# Data rows
for i, (label_text, key, _) in enumerate(ROWS):
    row = tbl.rows[i + 1]
    ct_label(row.cells[0], label_text, bold=(isinstance(key,str) and key in BOLD_KEYS))
    if isinstance(key, list):
        vals = [DATA[key[j]][j] for j in range(N_MODELS)]
    else:
        vals = DATA[key]
    for j, record in enumerate(vals):
        b, se, p = (None, None, None) if record is None else record
        ct(row.cells[j+1], b, se, p)

# Apply borders
for cell in tbl.rows[0].cells:
    set_cell_border(cell, top={'sz':12,'val':'single'}, bottom={'sz':4,'val':'single'})
for cell in tbl.rows[-1].cells:
    set_cell_border(cell, bottom={'sz':12,'val':'single'})

doc.save("OUTPUT.docx")
```

---

## PART 3 — Wide Tables: Landscape Section Break + Correlation / Descriptive Statistics

### When to use landscape

Any table with more than ~6 columns (correlation matrices, descriptive stats with many variables) must use landscape orientation with a **section break** that fully isolates it from the portrait main text.

```python
def insert_landscape_section(doc):
    """
    Insert a continuous section break BEFORE the current content, switching to landscape.
    Call this before adding the table title. Then call insert_portrait_section() after
    the table to return to portrait.
    """
    # Add a paragraph to host the section break
    p = doc.add_paragraph()
    pPr = p._p.get_or_add_pPr()
    sectPr = OxmlElement('w:sectPr')
    pgSz = OxmlElement('w:pgSz')
    pgSz.set(qn('w:w'),      '15840')  # 11 inches
    pgSz.set(qn('w:h'),      '12240')  # 8.5 inches
    pgSz.set(qn('w:orient'), 'landscape')
    pgMar = OxmlElement('w:pgMar')
    pgMar.set(qn('w:top'),    '720')   # 0.5 inch margins — maximize table width
    pgMar.set(qn('w:right'),  '720')
    pgMar.set(qn('w:bottom'), '720')
    pgMar.set(qn('w:left'),   '720')
    sectPr.append(pgSz)
    sectPr.append(pgMar)
    pPr.append(sectPr)

def insert_portrait_section(doc):
    """Return to portrait after the landscape table."""
    p = doc.add_paragraph()
    pPr = p._p.get_or_add_pPr()
    sectPr = OxmlElement('w:sectPr')
    pgSz = OxmlElement('w:pgSz')
    pgSz.set(qn('w:w'), '12240')   # 8.5 inches
    pgSz.set(qn('w:h'), '15840')   # 11 inches
    pgMar = OxmlElement('w:pgMar')
    pgMar.set(qn('w:top'),    '1440')  # standard 1-inch margins
    pgMar.set(qn('w:right'),  '1440')
    pgMar.set(qn('w:bottom'), '1440')
    pgMar.set(qn('w:left'),   '1440')
    sectPr.append(pgSz)
    sectPr.append(pgMar)
    pPr.append(sectPr)

# Usage:
insert_landscape_section(doc)  # switch to landscape
# ... add table title and table ...
insert_portrait_section(doc)   # return to portrait
```

### Correlation + Descriptive Statistics table format

**Exact format from the screenshot:**

- Title: **"Table 2a."** (bold) + rest of title (normal) — all one paragraph, space_after=0
- Column headers: "Variables" | (1) | (2) | (3) | ... | (N) — centered, 12pt
- Variable rows: "(1) Divestiture dummy" | 1.000 | [correlations] — lower triangle only, upper blank
- Stars: follow the existing project convention. The three-level code and legend below are illustrative and apply only when they match that convention; retain the project's original thresholds and evaluate them using unrounded p-values.
- Bottom rows: "Mean" and "SD" — same table, no separator
- Note: upright, 12pt, below table: "Note: Obs. = N,NNN. *** p below 0.010, ** p below 0.050, * p below 0.100."
- Font: 12pt throughout; split wide matrices into labeled continuation panels if needed
- Table width: AUTO-fit to page (use `tbl.style = 'Table Grid'` then clear borders)

```python
import numpy as np
from scipy import stats

def make_corr_desc_table(doc, df, var_names, var_labels, title, note_obs):
    """
    Build a correlation + descriptive statistics table.
    
    Args:
        df:          pandas DataFrame with data
        var_names:   list of column names in df (in order)
        var_labels:  list of display labels (same order)
        title:       e.g. "Table 2a. Descriptive statistics and correlation table (DV: divestiture dummy)"
        note_obs:    e.g. "11,368"
    """
    n_vars = len(var_names)

    # --- Compute correlations and p-values ---
    corr_matrix = np.zeros((n_vars, n_vars))
    pval_matrix = np.ones((n_vars, n_vars))
    for i in range(n_vars):
        for j in range(n_vars):
            if i == j:
                corr_matrix[i, j] = 1.0
                pval_matrix[i, j] = 0.0
            elif i > j:
                r, p = stats.pearsonr(df[var_names[i]].dropna(), df[var_names[j]].dropna())
                corr_matrix[i, j] = r
                pval_matrix[i, j] = p

    means = [df[v].mean() for v in var_names]
    sds   = [df[v].std()  for v in var_names]

    # --- Table title (bold number + normal text, no gap before table) ---
    p_title = doc.add_paragraph()
    p_title.alignment = WD_ALIGN_PARAGRAPH.LEFT
    # Split "Table 2a." from the rest
    bold_part, rest = title.split('. ', 1) if '. ' in title else (title, '')
    r1 = p_title.add_run(bold_part + '. ')
    r1.bold = True
    r1.font.size = Pt(12)
    if rest:
        r2 = p_title.add_run(rest)
        r2.bold = False
        r2.font.size = Pt(12)
    p_title.paragraph_format.space_after  = Pt(0)   # title glues to table
    p_title.paragraph_format.space_before = Pt(0)
    p_title.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE

    # --- Table: n_vars rows + Mean + SD, cols = label + n_vars numbers ---
    n_rows = n_vars + 2   # +2 for Mean, SD
    n_cols = 1 + n_vars
    tbl = doc.add_table(rows=n_rows + 1, cols=n_cols)  # +1 for header
    tbl.alignment = WD_TABLE_ALIGNMENT.LEFT
    clear_table_borders(tbl)

    FONT_SIZE = Pt(12)

    def cell_text(cell, text, bold=False, align=WD_ALIGN_PARAGRAPH.CENTER):
        cell.text = ''
        p = cell.paragraphs[0]
        p.alignment = align
        if text:
            r = p.add_run(text)
            r.font.size = FONT_SIZE
            r.font.name = "Times New Roman"
            r.bold = bold
            r.italic = False
        p.paragraph_format.space_before = Pt(0)
        p.paragraph_format.space_after  = Pt(0)
        p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE

    # Header row
    hdr = tbl.rows[0]
    cell_text(hdr.cells[0], 'Variables', bold=False, align=WD_ALIGN_PARAGRAPH.LEFT)
    for j in range(n_vars):
        cell_text(hdr.cells[j + 1], f'({j+1})')

    # Variable rows — lower triangle only
    def fmt_corr(r, p):
        number = format_3(r)
        if not number:
            return ""
        stars = ""
        if format_3(p):
            stars = '***' if p < 0.01 else '**' if p < 0.05 else '*' if p < 0.10 else ''
        return number + stars

    for i in range(n_vars):
        row = tbl.rows[i + 1]
        cell_text(row.cells[0], f'({i+1}) {var_labels[i]}', align=WD_ALIGN_PARAGRAPH.LEFT)
        for j in range(n_vars):
            if j > i:
                cell_text(row.cells[j + 1], '')          # upper triangle blank
            elif j == i:
                cell_text(row.cells[j + 1], '1.000')      # diagonal
            else:
                val = fmt_corr(corr_matrix[i, j], pval_matrix[i, j])
                cell_text(row.cells[j + 1], val)

    # Mean row
    mean_row = tbl.rows[n_vars + 1]
    cell_text(mean_row.cells[0], 'Mean', align=WD_ALIGN_PARAGRAPH.LEFT)
    for j in range(n_vars):
        cell_text(mean_row.cells[j + 1], format_3(means[j]))

    # SD row
    sd_row = tbl.rows[n_vars + 2]
    cell_text(sd_row.cells[0], 'SD', align=WD_ALIGN_PARAGRAPH.LEFT)
    for j in range(n_vars):
        cell_text(sd_row.cells[j + 1], format_3(sds[j]))

    # Borders
    for cell in tbl.rows[0].cells:
        set_cell_border(cell, top={'sz':12,'val':'single'}, bottom={'sz':4,'val':'single'})
    for cell in tbl.rows[-1].cells:
        set_cell_border(cell, bottom={'sz':12,'val':'single'})

    # Note below table
    p_note = doc.add_paragraph()
    r_note = p_note.add_run(f'Note: Obs. = {note_obs}. *** p below 0.010, ** p below 0.050, * p below 0.100.')
    r_note.italic = False
    r_note.font.size = Pt(12)
    p_note.paragraph_format.space_before = Pt(2)
    p_note.paragraph_format.space_after  = Pt(0)
    p_note.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
```

### Full workflow for a wide correlation table

```python
# In your make_tables.py script:

# 1. Switch to landscape (isolates from portrait body)
insert_landscape_section(doc)

# 2. Build Table 2a (binary DV sample)
make_corr_desc_table(
    doc, df_binary, var_names, var_labels,
    title="Table 2a. Descriptive statistics and correlation table (DV: divestiture dummy)",
    note_obs="11,368"
)

doc.add_page_break()

# 3. Build Table 2b (count DV sample) — same landscape section
make_corr_desc_table(
    doc, df_count, var_names_count, var_labels_count,
    title="Table 2b. Descriptive statistics and correlation table (DV: count of divestitures)",
    note_obs="12,085"
)

# 4. Return to portrait for the next section
insert_portrait_section(doc)
```

### Key sizing rules for wide tables

- Font: **12pt** throughout (header + data + Mean/SD + notes)
- Line spacing: **single**, with sufficient row height for 12pt text
- Margins: **0.5 inch** on all sides in landscape (set in `insert_landscape_section`)
- Column width: let Word auto-fit — do NOT set manual column widths for correlation tables
- If still overflowing: wrap labels, widen columns, or split into labeled panels/continuation pages with repeated headers; retain 12pt text

---

## PART 4 — Inline Statistical Reporting: Italic Notation + Subscripts

### The format (from screenshot)

```
(β = 0.040; RSE = 0.020; p = 0.010).
```

Rules (APA 7th edition, SMJ convention):
- **Italic**: Greek letters (β, α, γ), and single-letter roman statistics (p, t, F, r, z, N when = sample size used as a variable)
- **Roman (not italic)**: multi-letter abbreviations (RSE, SE, SD, VIF, IRR), numbers, equals signs, semicolons, parentheses
- **Subscripts**: retain a nominal 12pt font size while positioning via `font.subscript = True` — e.g., β₁, χ²(df)
- Separator: semicolons (`;`), not commas
- Whole expression in parentheses; period goes AFTER the closing parenthesis (outside)

### Unicode Greek letters (copy-paste ready)

| Symbol | Unicode | Usage |
|--------|---------|-------|
| β | `β` | regression coefficient |
| α | `α` | significance level / Cronbach's alpha |
| γ | `γ` | coefficient in structural models |
| χ | `χ` | chi-square (write χ²) |
| σ | `σ` | standard deviation (in formulas) |
| μ | `μ` | population mean (in formulas) |
| Δ | `Δ` | delta / change |

### python-docx helper: `inline_stat()`

Appends a formatted inline stat report to an existing non-table body paragraph. Omit missing statistics instead of printing empty labels or parentheses; table cells and table notes use upright text.

```python
def inline_stat(para, stats_list, terminal_period=True):
    """
    Append a formatted inline stat report to `para`.

    Args:
        para:        a docx Paragraph object (already has preceding text)
        stats_list:  list of (symbol, value, italic_symbol) tuples, e.g.:
                     [('β', '0.040', True),   # β = 0.040
                      ('RSE',    '0.020', False),   # RSE = 0.020
                      ('p',      '0.010', True)]    # p = 0.010
        terminal_period: add period after closing parenthesis (default True)

    Example output appended to para:  (β = 0.040; RSE = 0.020; p = 0.010).
    """
    STAT_SIZE = Pt(12)   # match surrounding body text size

    def run(text, italic=False, subscript=False, superscript=False):
        r = para.add_run(text)
        r.italic      = italic
        r.font.size   = STAT_SIZE
        if subscript:
            r.font.subscript   = True
        if superscript:
            r.font.superscript = True
        return r

    available = []
    for symbol, value, is_italic in stats_list:
        text = format_3(value)
        if text:
            available.append((symbol, str(value) if symbol in {"N", "df"} else text, is_italic))
    if not available:
        return
    run(' (')
    for i, (symbol, text, is_italic) in enumerate(available):
        if i > 0:
            run('; ')
        run(symbol, italic=is_italic)
        run(' = ')
        run(text, italic=False)
    run(')')
    if terminal_period:
        run('.')


# ── Usage examples ──────────────────────────────────────────────────────────────

# Basic: (β = 0.040; RSE = 0.020; p = 0.010).
p = doc.add_paragraph('Board centrality is positively related to divestiture')
inline_stat(p, [
    ('β', '0.040', True),
    ('RSE',    '0.020', False),
    ('p',      '0.010', True),
])

# With chi-square and df subscript:
# χ²(1) = 0.023, p = 0.879
p2 = doc.add_paragraph('Wu-Hausman endogeneity test: ')
run_chi = p2.add_run('χ')
run_chi.italic = True
run_chi.font.size = Pt(12)
run_sup = p2.add_run('2')
run_sup.font.superscript = True
run_sup.font.size = Pt(12)
run_df = p2.add_run('(1)')
run_df.font.size = Pt(12)
inline_stat(p2, [
    ('',  '0.023', False),   # value only after the χ²(1)
    ('p', '0.879', True),
], terminal_period=False)
```

### Subscript and superscript rules

```python
# Subscript: β₁  →  β + subscript "1"
r_beta = para.add_run('β')
r_beta.italic = True
r_sub = para.add_run('1')
r_sub.font.subscript = True
r_sub.font.size = Pt(12)   # same nominal size as body

# Superscript: χ²  →  χ + superscript "2"
r_chi = para.add_run('χ')
r_chi.italic = True
r_sup = para.add_run('2')
r_sup.font.superscript = True
r_sup.font.size = Pt(12)

# Degrees of freedom in parentheses after superscript: F(2, 11989)
# Write as plain text — no sub/superscript needed
para.add_run('F(2, 11989) = 111.960')
# Then italicize the F only:
r_F = para.add_run('F')
r_F.italic = True
para.add_run('(2, 11989) = 111.960')
```

### Standard inline reporting phrases (copy templates)

```python
# Logit coefficient in body text
"Board centrality significantly increases divestiture likelihood"
inline_stat(p, [('β','0.043',True), ('SE','0.018',False), ('p','0.017',True)])

# NBreg IRR in body text  
"The incidence rate ratio is 1.080"
inline_stat(p, [('IRR','1.080',False), ('p','0.003',True)])

# IV first-stage F-stat
# Illustrative unrounded p only; use the actual p from saved output for real results.
"The instruments are jointly significant"
inline_stat(p, [('F','111.960',True), ('p','0.0004',True)])

# Endogeneity test
"The Wu-Hausman test is consistent with exogeneity"
# write χ²(1) manually (see above), then:
inline_stat(p, [('p','0.879',True)], terminal_period=True)
```

### What NOT to italicize (common mistakes)

| Wrong | Right | Reason |
|-------|-------|--------|
| *SE* | SE | Multi-letter abbreviation → roman |
| *RSE* | RSE | Multi-letter abbreviation → roman |
| *VIF* | VIF | Abbreviation → roman |
| *IRR* | IRR | Abbreviation → roman |
| *SD* | SD | Abbreviation → roman |
| *df* | *df* | Exception: df IS italic in APA 7 |
| β (roman) | *β* | Greek letters always italic |
| p (roman) | *p* | Single roman letter → italic |

---

## PART 5 — Appendix Reporting Rule: Always Full Model, Never Summarized

**Rule**: Every robustness check table in the appendix must report the **complete model** — every variable, every coefficient, SE, and p-value. Never abbreviate with notes like "controls included" or show only the focal variable row.

This matches the same compact β/[p]/(SE) format used in the main text Table 2.

### What full model means

Every appendix table must include ALL of:
- Focal IV (e.g., board centrality)
- Moderators + interaction terms (where applicable)
- All control variables (firm-level, BG-level, industry-level)
- Fixed effects footer rows ("Year fixed effects: Yes", "Industry fixed effects: Yes")
- N, log-likelihood or pseudo-R², and any model-specific fit stats

### What is NOT allowed

```
# WRONG — never do this in appendix tables:
"Board centrality   0.043***"
"Controls           Included"   ← not acceptable
"Fixed effects      Yes"
```

```
# WRONG — never summarize:
"Results are consistent with Table 2. Full results available upon request."
```

### Why

Reviewers at SMJ/JMS/AMJ routinely check appendix robustness tables in detail. A summarized appendix signals that the author is hiding something or didn't actually run the full model. Full reporting also allows readers to check multicollinearity, sign reversals in controls, and sample size differences across specifications.

### Applies to all appendix table types

- NBreg / Poisson count model robustness (TABLE A1)
- Alternative IV specifications (TABLE A3 first-stage, TABLE A4 IV-probit)
- Alternative DV definitions
- Subsample splits
- Any other robustness check

### Code reminder

Use the same `ROWS` list structure as the main table — including all control variables — and the same `ct()` / `ct_label()` cell functions. Do not create a shorter ROWS list for appendix tables.

---

## PART 6 — Manuscript Structure & Section-by-Section Writing Conventions

The full-manuscript skeleton for SMJ/JMS/AMJ empirical papers. Section order, required elements per section, and phrasing templates. Use this whenever drafting or restructuring a manuscript in Word.

### Page 1: Title Page

Title, Abstract, and Keywords all fit on **one page**. Page break immediately after Keywords.

- Paper title: **centered, ALL CAPS, bold**
- "**ABSTRACT**" heading: centered, bold
- Abstract body: double-spaced, no first-line indent, **150 words max** (aim for 130–150)
- "**Keywords:**" (bold) followed by **≤ 6 terms**, lowercase, comma-separated
- Page break before Introduction

#### Abstract content rules (strict)

The abstract has exactly four jobs — in this order:

| # | Job | What to write |
|---|-----|---------------|
| 1 | **What we study** | Topic, setting, phenomenon (1–2 sentences) |
| 2 | **Theory** | The overarching theoretical lens and the core argument (1–2 sentences) |
| 3 | **Most important finding** | The single most striking empirical result — in plain language, no coefficients (1 sentence) |
| 4 | **Contribution** | Theory contribution first; briefly note empirical contribution if space allows (1–2 sentences) |

#### What the abstract must NOT contain

- **No citations** — not even one (e.g., "Drawing on Hambrick & Mason, 1984" → forbidden)
- **No hypothesis labels** — never write "H1", "Hypothesis 2", "consistent with H3" etc.
- **No specific coefficients or p-values** — say "positively associated", not "β = 0.040, p = 0.010"
- **No discussion of robustness checks or methodology details** — save for Methods
- **No sub-clause listing of moderators** — pick the most theoretically interesting finding; do not enumerate all hypotheses

#### What to emphasize

- The **theory contribution** is always the headline — what conceptual advance does this paper make?
- The **overarching theory** (e.g., "attention-based view", "network embeddedness") should be named explicitly
- One crisp finding that a reader will remember — the "punchline"

```python
# Title page — all three elements on one page, then page break
p_title = doc.add_paragraph()
p_title.alignment = WD_ALIGN_PARAGRAPH.CENTER
r = p_title.add_run("WHEN DO BUSINESS GROUP AFFILIATES DIVEST?")
r.bold = True
r.font.size = Pt(12)
p_title.paragraph_format.line_spacing_rule = WD_LINE_SPACING.DOUBLE

heading1(doc, "ABSTRACT")

# Abstract body — 130-150 words, no citations, no H1/H2 labels
p_abs = doc.add_paragraph(
    "We examine when business group affiliates engage in divestitures — "
    "a strategic renewal option that group-specific exit barriers typically suppress. "
    "Drawing on the attention-based view, we argue that an affiliate's position in the "
    "group's board interlock network shapes how much attention ultimate owners direct "
    "toward it, which in turn determines the affiliate's access to internal capital "
    "and its inertial commitment to the group. More central affiliates face higher "
    "exit barriers and are therefore less likely to divest. Analyzing a panel of "
    "1,964 affiliated firms across 456 business groups in India (2003–2021), "
    "we find strong support for this argument. The moderating role of group size "
    "and diversification further clarifies when network position matters most. "
    "Our findings extend the attention-based view to intra-group network

…(truncated)
