Skip to content

Commit beb6f20

Browse files
LEANDERANTONYclaude
andcommitted
fix(exporters): DOCX role line + date tab-stop; unify all 5 themes to one font
From the operator's PDF-vs-DOCX export audit across all 5 themes. Two real DOCX bugs found + fixed, plus a typography unification. DOCX BUG 1 — missing role/headline line. The HTML/PDF header builders render `artifact.target_role` as an uppercase muted line between the name and contact (`.resume-classic-role`, added 2026-05-19) but `_docx_add_resume_header` was missed — a JD-tailored résumé showed its role on the PDF and not the DOCX. Added the role paragraph to the DOCX header builder (omitted when target_role is "" — a name-only header stays standard, same as the PDF). DOCX BUG 2 — dates not flush-right. The role/education date rows use a right-aligned tab stop, positioned as `7.1 - 2 * margin`. But 7.1 was ALREADY the content width (8.5in Letter - 2x0.7in margins), so the margins were subtracted twice and every date landed at 5.70in instead of 7.1in — ~1.4in short of the right margin. Added an explicit `_DOCX_LETTER_WIDTH_INCHES = 8.5` and corrected the tab stop to `letter_width - 2*margin = 7.1in`. TYPOGRAPHY — unified all 5 themes to one font family (Arial / Helvetica sans). professional_neutral was all-Georgia serif; classic_ats + creative_warm had Georgia headings; modern_blue + architect_mono were already all-Arial. Changed the body/h1/prose font fields (HTML + DOCX) on the three serif-carrying themes. Themes now differentiate by colour, paper, and header treatment only — not typeface. Cover letters were covered automatically (they read the same ThemeSpec `prose_font_family` / `docx_*_font` fields), so résumé + cover letter are now a uniform matched set on font family. Font SIZES left per-document on purpose — only the family unified. Registry note + the stale "Georgia / serif" comments across `src/exporters.py` updated to match. Verification: 32/32 exporter tests pass; renderer-fidelity runner OVERALL PASS; all 5 themes re-rendered (PDF) and visually checked; DOCX structurally re-audited (role line present, tab stops 7.1in, every cover-letter + résumé run = Arial). `test_export_docx_bytes_*` paragraph indices bumped for the new role line; the two font-assertion tests inverted to expect Arial-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b70fb6d commit beb6f20

3 files changed

Lines changed: 190 additions & 79 deletions

File tree

docs/DEVLOG.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2749,3 +2749,58 @@ architect_mono — all single-column, all ATS-safe) and to explicitly
27492749
tell the assistant there is no two-column option today so it answers
27502750
honestly if asked. Re-baked into both registry JSONs; byte-mirror
27512751
tests green (33/33 prompts + registry).
2752+
2753+
### Export audit: 2 DOCX bugs fixed + typography unified to one font
2754+
2755+
Operator asked for a side-by-side PDF-vs-DOCX comparison of the
2756+
résumé export across all 5 themes "to ensure DOCX is working as
2757+
intended." Generated both formats for every theme and audited the
2758+
DOCX XML structure directly (Word COM rendering was too flaky under
2759+
the harness; structural XML inspection is more precise anyway).
2760+
Content fidelity was perfect (all 5: every section, 0 empty
2761+
paragraphs, correct theme colors) — but the audit caught two real
2762+
PDF-vs-DOCX divergences:
2763+
2764+
1. **Missing role/headline line in the DOCX.** The HTML/PDF header
2765+
builders render `artifact.target_role` as an uppercase muted line
2766+
between the name and contact (`.resume-classic-role`, added
2767+
2026-05-19) — but `_docx_add_resume_header` was missed in that
2768+
change, so a JD-tailored résumé showed its role on the PDF and
2769+
not the DOCX. Added the role paragraph to the DOCX header builder
2770+
(omitted when target_role is "", same as the PDF).
2771+
2772+
2. **Dates not flush-right in the DOCX.** The role/education date
2773+
rows use a right-aligned tab stop. The position was computed as
2774+
`7.1 - 2 * margin` — but 7.1 was ALREADY the content width
2775+
(8.5in Letter − 2×0.7in margins), so the margins were subtracted
2776+
twice and every date landed at 5.70in instead of 7.1in — ~1.4in
2777+
short of the right margin. Added an explicit
2778+
`_DOCX_LETTER_WIDTH_INCHES = 8.5` and corrected the tab stop to
2779+
`letter_width − 2×margin = 7.1in`.
2780+
2781+
Then — separate operator request, same export surface — **unified
2782+
the typography**: all 5 themes now use ONE font family
2783+
(Arial / Helvetica sans), the `modern_blue` family the operator
2784+
liked. Previously professional_neutral was all-Georgia serif, and
2785+
classic_ats + creative_warm had Georgia headings. Changed the
2786+
`body/h1/prose` font fields (HTML + DOCX) on those three themes;
2787+
modern_blue + architect_mono were already all-Arial. Themes now
2788+
differentiate by COLOUR, PAPER, and HEADER TREATMENT only — not
2789+
typeface. Cover letters were covered automatically (they read the
2790+
same ThemeSpec `prose_font_family` / `docx_*_font` fields), so
2791+
résumé + cover letter are now a uniform matched set on font family.
2792+
Font SIZES deliberately left per-document (résumé body ~10.5pt,
2793+
cover letter body 11.4pt) — only the family was unified.
2794+
2795+
Stale "Georgia / serif" comments across `src/exporters.py` updated
2796+
to match. Tests: `test_export_docx_bytes_renders_full_resume_*`
2797+
paragraph indices bumped for the new role line; the two
2798+
font-assertion tests (classic_ats / professional_neutral) inverted
2799+
to expect Arial-only. 32/32 exporter tests + renderer-fidelity
2800+
runner (OVERALL PASS) green.
2801+
2802+
Noted but NOT fixed (pre-existing, unrelated):
2803+
`test_export_docx_bytes_unknown_theme_falls_back_to_classic_ats`
2804+
compares raw timestamped `.docx` ZIP bytes — flaky across a
2805+
DOS-timestamp second boundary. Should compare extracted content,
2806+
not raw bytes; worth a separate fix.

src/exporters.py

Lines changed: 113 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -166,11 +166,10 @@ def cover_letter_palette(self) -> dict:
166166
"header_border_width": f"{self.header_border_px}px",
167167
"header_rule_color": self.header_rule_color,
168168
# The letter is all prose, so it follows the theme's PROSE
169-
# font (not body). For classic_ats / professional_neutral
170-
# prose IS Georgia → byte-identical to the old hardcoded
171-
# value (zero regression). For sans themes (modern_blue)
172-
# the letter now matches its résumé instead of clashing as
173-
# a lone serif document — the "matched set" guarantee.
169+
# font (not body). Since the 2026-05-21 typography
170+
# unification every theme's prose font is the shared Arial
171+
# sans family, so the cover letter always matches its
172+
# résumé — the "matched set" guarantee.
174173
"body_font_family": self.prose_font_family,
175174
}
176175

@@ -186,10 +185,19 @@ def docx_palette(self) -> dict:
186185
}
187186

188187

189-
# The registry. EXISTING two themes reproduce the pre-refactor literal
190-
# maps byte-for-byte (verified via golden snapshots). NEW themes are
191-
# added here only — the three derived maps + every renderer pick them
192-
# up automatically, so resume + cover letter + DOCX can never drift.
188+
# The registry. NEW themes are added here only — the three derived
189+
# maps + every renderer pick them up automatically, so resume + cover
190+
# letter + DOCX can never drift.
191+
#
192+
# TYPOGRAPHY (operator decision 2026-05-21): all five themes share ONE
193+
# font family — Arial / Helvetica sans-serif (h1, body, prose; Arial
194+
# for the DOCX OOXML names). Themes used to mix serif/sans for
195+
# identity (professional_neutral was all-Georgia, classic_ats +
196+
# creative_warm had serif headings); that was unified to a single sans
197+
# family per the operator's request. Themes now differentiate by
198+
# COLOR, PAPER, and HEADER TREATMENT only — not by typeface. Any new
199+
# theme MUST use the same Arial sans family unless that decision is
200+
# revisited.
193201
_THEME_SPECS: dict[str, "ThemeSpec"] = {
194202
"classic_ats": ThemeSpec(
195203
key="classic_ats",
@@ -202,13 +210,15 @@ def docx_palette(self) -> dict:
202210
surface="#fffdfa",
203211
accent_soft="rgba(143, 104, 69, 0.10)",
204212
cover_strong_color="#17100b",
213+
# Unified Arial/Helvetica sans family across all themes
214+
# (operator decision 2026-05-21) — see the registry note above.
205215
body_font_family="Arial, Helvetica, sans-serif",
206-
h1_font_family='Georgia, "Times New Roman", serif',
207-
prose_font_family='Georgia, "Times New Roman", serif',
216+
h1_font_family="Arial, Helvetica, sans-serif",
217+
prose_font_family="Arial, Helvetica, sans-serif",
208218
prose_line_height="1.55",
209219
docx_body_font="Arial",
210-
docx_heading_font="Georgia",
211-
docx_prose_font="Georgia",
220+
docx_heading_font="Arial",
221+
docx_prose_font="Arial",
212222
header_border_px=3,
213223
layout="single_column",
214224
),
@@ -223,24 +233,25 @@ def docx_palette(self) -> dict:
223233
surface="#ffffff",
224234
accent_soft="rgba(0, 0, 0, 0.04)",
225235
cover_strong_color="#0a0a0a",
226-
body_font_family='Georgia, "Times New Roman", serif',
227-
h1_font_family='Georgia, "Times New Roman", serif',
228-
prose_font_family='Georgia, "Times New Roman", serif',
236+
# Unified Arial/Helvetica sans family across all themes
237+
# (operator decision 2026-05-21) — see the registry note above.
238+
body_font_family="Arial, Helvetica, sans-serif",
239+
h1_font_family="Arial, Helvetica, sans-serif",
240+
prose_font_family="Arial, Helvetica, sans-serif",
229241
prose_line_height="1.55",
230-
docx_body_font="Georgia",
231-
docx_heading_font="Georgia",
232-
docx_prose_font="Georgia",
242+
docx_body_font="Arial",
243+
docx_heading_font="Arial",
244+
docx_prose_font="Arial",
233245
header_border_px=2,
234246
layout="single_column",
235247
),
236-
# NEW (Phase 2a) — modern all-sans + one deep professional blue.
237-
# Single-column → fully ATS-safe. Differs from the two originals
238-
# by being sans THROUGHOUT (classic_ats = serif headings/prose;
239-
# professional_neutral = all-serif) with a cool slate ink + blue
240-
# accent. Accent #1A56DB clears ~5.9:1 on white (safe for any
241-
# text). Fonts stay web-safe sans so WeasyPrint renders it
242-
# identically on the Linux render host (no missing-font fallback).
243-
# Audience: tech / product / data / ops.
248+
# NEW (Phase 2a) — modern, one deep professional blue.
249+
# Single-column → fully ATS-safe. Differentiated by a cool slate
250+
# ink + blue accent on a faint cool paper. Accent #1A56DB clears
251+
# ~5.9:1 on white (safe for any text). Fonts are the shared
252+
# Arial/Helvetica sans family (see registry note) — web-safe so
253+
# WeasyPrint renders identically on the Linux render host (no
254+
# missing-font fallback). Audience: tech / product / data / ops.
244255
"modern_blue": ThemeSpec(
245256
key="modern_blue",
246257
label="Modern Blue",
@@ -266,14 +277,13 @@ def docx_palette(self) -> dict:
266277
header_border_px=2,
267278
layout="single_column",
268279
),
269-
# NEW (Phase 2b) — modern editorial. Serif NAME (Georgia, h1 only)
270-
# for gravitas; everything else clean sans (scannable + ATS-safe);
271-
# emerald accent for creative energy without leaving professional.
272-
# Distinct from classic_ats (brown + serif prose, heavy cream) and
273-
# from modern_blue (cool blue, all-sans). Faint near-neutral warm
274-
# paper. Emerald #00a388 is the proven Awesome-CV accent; it only
275-
# colors headings/rules/labels (body stays ink) so contrast is
276-
# fine. Single-column → ATS-safe. Audience: marketing / comms /
280+
# NEW (Phase 2b) — modern editorial. Emerald accent for creative
281+
# energy without leaving professional; a soft warm "sand" header
282+
# band. Distinct from classic_ats (warm brown + cream paper) and
283+
# from modern_blue (cool blue). Faint near-neutral warm paper.
284+
# Emerald #00a388 is the proven Awesome-CV accent; it only colors
285+
# headings/rules/labels (body stays ink) so contrast is fine.
286+
# Single-column → ATS-safe. Audience: marketing / comms /
277287
# design-adjacent that still needs to pass ATS.
278288
"creative_warm": ThemeSpec(
279289
key="creative_warm",
@@ -295,26 +305,28 @@ def docx_palette(self) -> dict:
295305
# name/body divider reads as a deliberate anchor line rather
296306
# than the same bright emerald (operator request).
297307
header_rule_color="#0b7c5e",
298-
h1_font_family='Georgia, "Times New Roman", serif',
308+
# Unified Arial/Helvetica sans family across all themes
309+
# (operator decision 2026-05-21) — see the registry note above.
310+
h1_font_family="Arial, Helvetica, sans-serif",
299311
body_font_family="Arial, Helvetica, sans-serif",
300312
prose_font_family="Arial, Helvetica, sans-serif",
301313
prose_line_height="1.55",
302314
docx_body_font="Arial",
303-
docx_heading_font="Georgia",
315+
docx_heading_font="Arial",
304316
docx_prose_font="Arial",
305317
header_border_px=2,
306318
layout="single_column",
307319
),
308320
# NEW (Phase 2c) — architectural minimal. Near-monochrome: deep
309321
# cool near-black ink AND accent (no colour — the "design" is
310-
# typographic), one HAIRLINE rule (header_border_px=1), geometric
311-
# sans, airier prose line-height for generous whitespace. Crisp
312-
# pure white on purpose (deliberate contrast to modern_blue's
313-
# tint — minimalism reads as stark, not "designed paper").
314-
# Single-column → ATS-safe. Audience: architecture / design /
315-
# senior-eng "confident minimal". Distinct from professional_neutral
316-
# (which is the same monochrome idea but SERIF Georgia; this is
317-
# SANS + hairline + airy).
322+
# typographic), one HAIRLINE rule (header_border_px=1), airier
323+
# prose line-height for generous whitespace, and a solid ink
324+
# masthead band. Crisp pure white on purpose (deliberate contrast
325+
# to modern_blue's tint — minimalism reads as stark, not "designed
326+
# paper"). Single-column → ATS-safe. Audience: architecture /
327+
# design / senior-eng "confident minimal". Distinct from
328+
# professional_neutral (same monochrome idea, but architect_mono
329+
# adds the hairline rule + ink masthead + airier spacing).
318330
"architect_mono": ThemeSpec(
319331
key="architect_mono",
320332
label="Architect Mono",
@@ -511,8 +523,8 @@ def _build_cover_letter_html(text, title="Cover Letter", theme="classic_ats"):
511523
<style>
512524
/* Theme-keyed palette: classic_ats keeps the warm-brown letter
513525
feel; professional_neutral drops to pure black/white/gray for
514-
conservative recipients while keeping the same Georgia letter
515-
prose. */
526+
conservative recipients. Prose font is the shared Arial sans
527+
family across every theme (2026-05-21 unification). */
516528
:root {{
517529
--ink: {ink};
518530
--muted: {muted};
@@ -1280,10 +1292,10 @@ def _build_resume_html(text, title="Tailored Resume", theme="classic_ats", artif
12801292
<title>{title}</title>
12811293
<style>
12821294
@page {{ size: A4; margin: 0; }}
1283-
/* Theme-keyed palette: classic_ats ships warm-brown + Georgia
1284-
prose so the resume reads as a set with the cover letter;
1285-
professional_neutral collapses to pure black/white/gray with
1286-
all-Arial body for conservative recruiters / B&W printing. */
1295+
/* Theme-keyed palette: classic_ats ships warm-brown,
1296+
professional_neutral collapses to pure black/white/gray for
1297+
conservative recruiters / B&W printing. Typeface is the
1298+
shared Arial sans family for every theme (2026-05-21). */
12871299
:root {{
12881300
--ink: {ink};
12891301
--muted: {muted};
@@ -1296,9 +1308,11 @@ def _build_resume_html(text, title="Tailored Resume", theme="classic_ats", artif
12961308
* {{ box-sizing: border-box; }}
12971309
html, body {{ margin: 0; padding: 0; }}
12981310
body {{ font-family: {body_font_family}; color: var(--ink); background: var(--paper); font-size: 10.5pt; line-height: 1.5; }}
1299-
/* Editorial pairing: in classic_ats the prose-y parts (summary,
1300-
bullets) shift to Georgia so the resume harmonizes with the
1301-
cover letter; professional_neutral keeps Arial throughout. */
1311+
/* Prose-y parts (summary, bullets) use the theme's prose font.
1312+
Since the 2026-05-21 typography unification that is the same
1313+
shared Arial sans family as the body for every theme; the
1314+
rule is kept so a future theme could re-introduce a distinct
1315+
prose face without a renderer change. */
13021316
.resume-summary, .resume-bullet-list li {{ font-family: {prose_font_family}; line-height: {prose_line_height}; }}
13031317
/* min-height keeps short resumes filling a single A4 page;
13041318
overflow-x: hidden prevents the negative-margin h2 trick from
@@ -1784,6 +1798,10 @@ def _resolve_docx_palette(theme: str | None) -> dict[str, str]:
17841798
# Default page margins (in inches). Matches the ~18mm @page margin the
17851799
# WeasyPrint renderer uses for the classic_ats resume shell.
17861800
_DOCX_PAGE_MARGIN_INCHES = 0.7
1801+
# US Letter page width. The résumé DOCX is always Letter; the content
1802+
# column width = this minus both margins. Used to place the role-row
1803+
# RIGHT tab stop at the true right edge of the text column.
1804+
_DOCX_LETTER_WIDTH_INCHES = 8.5
17871805

17881806

17891807
def _docx_add_bottom_border(paragraph, *, color_hex: str, size_eighths_pt: int = 6):
@@ -1899,6 +1917,26 @@ def _docx_add_resume_header(document, artifact: TailoredResumeArtifact, *, palet
18991917
bold=True,
19001918
)
19011919

1920+
# Mode-aware headline / role line. The HTML+PDF builders render
1921+
# `artifact.target_role` as an uppercase muted line between the
1922+
# name and the contact block (`.resume-classic-role`, added
1923+
# 2026-05-19). The DOCX header builder was missed in that change —
1924+
# so a JD-tailored résumé showed its role on the PDF but not the
1925+
# DOCX. Render it here too (omitted entirely when target_role is
1926+
# "" — a name-only header stays standard; we never fabricate one).
1927+
headline = str(getattr(artifact, "target_role", "") or "").strip()
1928+
if headline:
1929+
role_paragraph = document.add_paragraph()
1930+
role_paragraph.alignment = _docx_alignment("left")
1931+
role_paragraph.paragraph_format.space_after = _docx_pt(2)
1932+
role_run = role_paragraph.add_run(headline.upper())
1933+
_docx_apply_run_font(
1934+
role_run,
1935+
family=palette["body_font"],
1936+
size_pt=10,
1937+
color_hex=palette["muted"],
1938+
)
1939+
19021940
contact_values = []
19031941
if artifact.header.location:
19041942
contact_values.append(artifact.header.location.strip())
@@ -1951,8 +1989,16 @@ def _docx_add_role_row(document, *, title: str, dates: str, palette: dict):
19511989
paragraph = document.add_paragraph()
19521990
paragraph.paragraph_format.space_before = _docx_pt(4)
19531991
paragraph.paragraph_format.space_after = _docx_pt(0)
1992+
# RIGHT tab stop at the right edge of the text column so the date
1993+
# sits flush-right (mirrors the PDF's flex `.resume-role-row`).
1994+
# Tab-stop positions are measured from the LEFT margin, so the
1995+
# right edge = content width = page width minus both margins.
1996+
# BUGFIX: this previously read `7.1 - 2 * margin` — but 7.1 was
1997+
# ALREADY the content width (8.5 - 2*0.7), so the margins were
1998+
# subtracted twice and every date landed 1.4in short of the
1999+
# margin instead of flush-right.
19542000
paragraph.paragraph_format.tab_stops.add_tab_stop(
1955-
_docx_inches(7.1 - 2 * _DOCX_PAGE_MARGIN_INCHES),
2001+
_docx_inches(_DOCX_LETTER_WIDTH_INCHES - 2 * _DOCX_PAGE_MARGIN_INCHES),
19562002
WD_TAB_ALIGNMENT.RIGHT,
19572003
)
19582004

@@ -2025,9 +2071,9 @@ def _docx_add_paragraph_text(document, text: str, *, palette: dict, font_key: st
20252071

20262072
def _docx_resume_summary_block(document, artifact: TailoredResumeArtifact, *, palette: dict):
20272073
_docx_resume_section_heading(document, "Summary", palette=palette)
2028-
# Prose summary uses the prose font (Georgia in both themes) so
2029-
# the headline reads more like a written paragraph than a body
2030-
# bullet, regardless of theme.
2074+
# Prose summary uses the theme's prose font — the shared Arial
2075+
# sans family across every theme since the 2026-05-21 typography
2076+
# unification.
20312077
_docx_add_paragraph_text(
20322078
document,
20332079
artifact.professional_summary or "No professional summary generated.",
@@ -2201,10 +2247,11 @@ def _build_resume_docx(artifact: TailoredResumeArtifact) -> bytes:
22012247
`artifact.section_order` and falls back to
22022248
`_DEFAULT_RESUME_SECTION_ORDER` for legacy callers.
22032249
2204-
Theme is read from `artifact.theme`; supported values are
2205-
`classic_ats` (warm-cream / brown accents, Arial body) and
2206-
`professional_neutral` (pure black / gray, Georgia body). Unknown
2207-
values fall back to `classic_ats`.
2250+
Theme is read from `artifact.theme`; supported values are the five
2251+
user-facing themes (`professional_neutral`, `classic_ats`,
2252+
`modern_blue`, `creative_warm`, `architect_mono`) — all sharing the
2253+
one Arial sans family, differentiated by colour / paper / header.
2254+
Unknown values fall back to `classic_ats`.
22082255
"""
22092256
from docx import Document
22102257

@@ -2266,11 +2313,10 @@ def _build_cover_letter_docx(artifact: CoverLetterArtifact) -> bytes:
22662313
`_split_cover_letter_title` so the heading + role-eyebrow read the
22672314
same way as the HTML render.
22682315
2269-
Theme is read from `artifact.theme`; both classic_ats and
2270-
professional_neutral use the prose font (Georgia) for the body
2271-
because the cover letter is letter-shaped prose in either palette.
2272-
The theme switch only changes ink / muted / accent / line colors
2273-
+ the small-caps eyebrow font (which is body_font).
2316+
Theme is read from `artifact.theme`; every theme uses the shared
2317+
Arial sans family (prose font) for the letter body — the cover
2318+
letter is letter-shaped prose in any palette. The theme switch
2319+
only changes ink / muted / accent / line colors.
22742320
"""
22752321
from docx import Document
22762322

0 commit comments

Comments
 (0)