/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.
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.
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 to0.000. Keep observation counts, years, model/table/page numbers, and other integer identifiers as integers. Preserve full precision in data/audit exports.Numeric p-value display: show a leading zero and three decimals, e.g.
[0.001],[0.000], or italicp = 0.038in prose. Do not replace a reported p-value with an inequality or threshold. A displayed0.000is 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.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[]/(). TreatNoneandNaNas 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 displays0.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.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.
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.
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)orFocal 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):
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
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
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:
- Page break
- Table title — immediately above the table, NO blank line between title and table
- The table itself
- Table notes below the table
# 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:
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).
* 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:
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):
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:
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:
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:
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
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)
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
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.
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)
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
# 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.
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
# 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)
# 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"
# 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)