@@ -135,6 +135,162 @@ def render_markdown(dossier: dict) -> str:
135135 return "\n " .join (lines )
136136
137137
138+ _HTML_CSS = """
139+ body { font-family: Georgia, serif; max-width: 960px; margin: 2rem auto; padding: 0 1.5rem; color: #1a1a1a; }
140+ h1 { border-bottom: 2px solid #333; padding-bottom: .4rem; }
141+ h2 { margin-top: 2rem; color: #2c2c2c; }
142+ h3 { margin-top: 1.2rem; color: #444; }
143+ .meta { color: #666; font-style: italic; margin-bottom: 1.5rem; }
144+ .profiles { list-style: none; padding: 0; }
145+ .profiles li { margin: .3rem 0; }
146+ .warn { color: #c0392b; font-style: italic; }
147+ table { border-collapse: collapse; width: 100%; margin: .8rem 0; font-size: .92rem; }
148+ th { background: #2c3e50; color: #fff; padding: .45rem .7rem; text-align: left; }
149+ td { padding: .35rem .7rem; border-bottom: 1px solid #ddd; }
150+ tr:nth-child(even) td { background: #f7f7f7; }
151+ .line { font-family: monospace; font-size: .85rem; }
152+ .wp-hi { background: #c8f7c5 !important; }
153+ .wp-mid { background: #fef9c3 !important; }
154+ .wp-lo { background: #fcd6d6 !important; }
155+ .overview td:first-child { font-weight: bold; }
156+ .section-white { border-left: 4px solid #f0c040; padding-left: .8rem; }
157+ .section-black { border-left: 4px solid #444; padding-left: .8rem; }
158+ @media print { .player-section { page-break-after: always; } }
159+ """
160+
161+
162+ def render_html (dossier : dict ) -> str :
163+ player = dossier ["player" ]
164+ body = _html_player_section (
165+ player , dossier ["stats" ], dossier ["openings" ],
166+ dossier ["profiles" ], dossier ["generated" ]
167+ )
168+ return (
169+ f"<!doctype html><html lang='en'><head>"
170+ f"<meta charset='utf-8'><title>Dossier: { _esc (player )} </title>"
171+ f"<style>{ _HTML_CSS } </style></head><body>"
172+ + body + "</body></html>"
173+ )
174+
175+
176+ def render_html_combined (dossiers : list [dict ]) -> str :
177+ def _nav_item (d ):
178+ pid = _slug_id (d ["player" ])
179+ return f"<li><a href='#{ pid } '>{ _esc (d ['player' ])} </a></li>"
180+
181+ nav_links = "" .join (_nav_item (d ) for d in dossiers )
182+ nav = f"<nav><h2>Players</h2><ul>{ nav_links } </ul></nav><hr>"
183+
184+ sections = "" .join (
185+ _html_player_section (
186+ d ["player" ], d ["stats" ], d ["openings" ], d ["profiles" ], d ["generated" ],
187+ anchor = _slug_id (d ["player" ])
188+ )
189+ for d in dossiers
190+ )
191+ return (
192+ "<!doctype html><html lang='en'><head>"
193+ "<meta charset='utf-8'><title>Tournament Dossiers</title>"
194+ f"<style>{ _HTML_CSS } </style></head><body>"
195+ + nav + sections + "</body></html>"
196+ )
197+
198+
199+ def _slug_id (name : str ) -> str :
200+ return re .sub (r"[^a-z0-9]+" , "_" , name .lower ()).strip ("_" )
201+
202+
203+ def _esc (s : str ) -> str :
204+ return s .replace ("&" , "&" ).replace ("<" , "<" ).replace (">" , ">" )
205+
206+
207+ def _html_player_section (player , stats , openings , profiles , generated , anchor = None ) -> str :
208+ aid = f" id='{ anchor } '" if anchor else ""
209+ ov = stats ["overall" ]
210+
211+ def wp_class (pct ):
212+ if pct >= 55 : return "wp-hi"
213+ if pct >= 40 : return "wp-mid"
214+ return "wp-lo"
215+
216+ # --- profiles ---
217+ prof_html = ""
218+ if profiles :
219+ items = []
220+ for p in profiles :
221+ site = "Lichess" if "lichess" in p .get ("url" , "" ) else "chess.com"
222+ ratings = ", " .join (f"{ k .capitalize ()} : { v } " for k , v in p .get ("ratings" , {}).items ())
223+ title = f"{ p ['title' ]} " if p .get ("title" ) else ""
224+ warn = " <span class='warn'>⚠ low-confidence match</span>" if p .get ("confidence" ) == "low" else ""
225+ rat_str = f" — { _esc (ratings )} " if ratings else ""
226+ items .append (
227+ f"<li><strong>{ site } </strong>: "
228+ f"<a href='{ p ['url' ]} '>{ _esc (title )} { _esc (p ['display_name' ])} </a>"
229+ f"{ rat_str } { warn } </li>"
230+ )
231+ prof_html = f"<h2>Online Profiles</h2><ul class='profiles'>{ '' .join (items )} </ul>"
232+
233+ # --- overview table ---
234+ aw , ab = stats ["as_white" ], stats ["as_black" ]
235+ overview = (
236+ "<h2>Overview</h2>"
237+ "<table class='overview'>"
238+ "<tr><th></th><th>White</th><th>Black</th><th>Overall</th></tr>"
239+ f"<tr><td>Games</td><td>{ aw ['count' ]} </td><td>{ ab ['count' ]} </td><td>{ stats ['total' ]} </td></tr>"
240+ f"<tr><td>Wins</td><td>{ aw ['wins' ]} </td><td>{ ab ['wins' ]} </td><td>{ ov ['wins' ]} </td></tr>"
241+ f"<tr><td>Draws</td><td>{ aw ['draws' ]} </td><td>{ ab ['draws' ]} </td><td>{ ov ['draws' ]} </td></tr>"
242+ f"<tr><td>Losses</td><td>{ aw ['losses' ]} </td><td>{ ab ['losses' ]} </td><td>{ ov ['losses' ]} </td></tr>"
243+ f"<tr><td>Win %</td>"
244+ f"<td class='{ wp_class (aw ['win_pct' ])} '>{ aw ['win_pct' ]} %</td>"
245+ f"<td class='{ wp_class (ab ['win_pct' ])} '>{ ab ['win_pct' ]} %</td>"
246+ f"<td class='{ wp_class (ov ['win_pct' ])} '>{ ov ['win_pct' ]} %</td>"
247+ "</tr></table>"
248+ f"<p><strong>Average game length:</strong> { stats ['avg_length' ]} half-moves</p>"
249+ )
250+
251+ # --- as white ---
252+ white_body = (
253+ _html_opening_table (openings ["as_white" ], wp_class )
254+ if openings ["as_white" ] else "<p><em>No games found as White.</em></p>"
255+ )
256+ as_white = f"<div class='section-white'><h2>As White</h2>{ white_body } </div>"
257+
258+ # --- as black ---
259+ black_parts = []
260+ if stats ["vs_e4" ]:
261+ black_parts .append (f"<h3>vs 1. e4</h3>{ _html_opening_table (stats ['vs_e4' ], wp_class )} " )
262+ if stats ["vs_d4" ]:
263+ black_parts .append (f"<h3>vs 1. d4</h3>{ _html_opening_table (stats ['vs_d4' ], wp_class )} " )
264+ if openings ["as_black" ]:
265+ black_parts .append (
266+ f"<h3>All openings as Black</h3>{ _html_opening_table (openings ['as_black' ], wp_class )} "
267+ )
268+ if not black_parts :
269+ black_parts .append ("<p><em>No games found as Black.</em></p>" )
270+ as_black = f"<div class='section-black'><h2>As Black</h2>{ '' .join (black_parts )} </div>"
271+
272+ return (
273+ f"<section class='player-section'{ aid } >"
274+ f"<h1>Dossier: { _esc (player )} </h1>"
275+ f"<p class='meta'>Generated { _esc (generated )} · { stats ['total' ]} games analysed</p>"
276+ + prof_html + overview + as_white + as_black
277+ + "</section>"
278+ )
279+
280+
281+ def _html_opening_table (rows : list [dict ], wp_class ) -> str :
282+ header = "<tr><th>Opening</th><th>Games</th><th>W</th><th>D</th><th>L</th><th>Win%</th></tr>"
283+ def _row (r ):
284+ cls = wp_class (r ["win_pct" ])
285+ return (
286+ f"<tr><td class='line'>{ _esc (r ['line' ])} </td>"
287+ f"<td>{ r ['count' ]} </td><td>{ r ['wins' ]} </td>"
288+ f"<td>{ r ['draws' ]} </td><td>{ r ['losses' ]} </td>"
289+ f"<td class='{ cls } '>{ r ['win_pct' ]} %</td></tr>"
290+ )
291+ return f"<table>{ header } { '' .join (_row (r ) for r in rows )} </table>"
292+
293+
138294def render_json (dossier : dict ) -> str :
139295 return json .dumps (dossier , indent = 2 , ensure_ascii = False )
140296
@@ -200,7 +356,7 @@ def main() -> None:
200356 help = "Opening depth in half-moves (default: 6)" )
201357 parser .add_argument ("--top" , type = int , default = 8 ,
202358 help = "Top N opening lines per colour (default: 8)" )
203- parser .add_argument ("--output" , choices = ["markdown" , "json" ], default = "markdown " )
359+ parser .add_argument ("--output" , choices = ["markdown" , "html" , " json" ], default = "html " )
204360 args = parser .parse_args ()
205361
206362 pgn_strings : list [str ] = []
@@ -231,6 +387,8 @@ def main() -> None:
231387
232388 if args .output == "json" :
233389 print (render_json (dossier ))
390+ elif args .output == "html" :
391+ print (render_html (dossier ))
234392 else :
235393 print (render_markdown (dossier ))
236394
0 commit comments