html.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  1. """
  2. Module for formatting output data in HTML.
  3. """
  4. from __future__ import annotations
  5. from textwrap import dedent
  6. from typing import (
  7. Any,
  8. Final,
  9. Hashable,
  10. Iterable,
  11. Mapping,
  12. cast,
  13. )
  14. from pandas._config import get_option
  15. from pandas._libs import lib
  16. from pandas import (
  17. MultiIndex,
  18. option_context,
  19. )
  20. from pandas.io.common import is_url
  21. from pandas.io.formats.format import (
  22. DataFrameFormatter,
  23. get_level_lengths,
  24. )
  25. from pandas.io.formats.printing import pprint_thing
  26. class HTMLFormatter:
  27. """
  28. Internal class for formatting output data in html.
  29. This class is intended for shared functionality between
  30. DataFrame.to_html() and DataFrame._repr_html_().
  31. Any logic in common with other output formatting methods
  32. should ideally be inherited from classes in format.py
  33. and this class responsible for only producing html markup.
  34. """
  35. indent_delta: Final = 2
  36. def __init__(
  37. self,
  38. formatter: DataFrameFormatter,
  39. classes: str | list[str] | tuple[str, ...] | None = None,
  40. border: int | bool | None = None,
  41. table_id: str | None = None,
  42. render_links: bool = False,
  43. ) -> None:
  44. self.fmt = formatter
  45. self.classes = classes
  46. self.frame = self.fmt.frame
  47. self.columns = self.fmt.tr_frame.columns
  48. self.elements: list[str] = []
  49. self.bold_rows = self.fmt.bold_rows
  50. self.escape = self.fmt.escape
  51. self.show_dimensions = self.fmt.show_dimensions
  52. if border is None or border is True:
  53. border = cast(int, get_option("display.html.border"))
  54. elif not border:
  55. border = None
  56. self.border = border
  57. self.table_id = table_id
  58. self.render_links = render_links
  59. self.col_space = {
  60. column: f"{value}px" if isinstance(value, int) else value
  61. for column, value in self.fmt.col_space.items()
  62. }
  63. def to_string(self) -> str:
  64. lines = self.render()
  65. if any(isinstance(x, str) for x in lines):
  66. lines = [str(x) for x in lines]
  67. return "\n".join(lines)
  68. def render(self) -> list[str]:
  69. self._write_table()
  70. if self.should_show_dimensions:
  71. by = chr(215) # ×
  72. self.write(
  73. f"<p>{len(self.frame)} rows {by} {len(self.frame.columns)} columns</p>"
  74. )
  75. return self.elements
  76. @property
  77. def should_show_dimensions(self) -> bool:
  78. return self.fmt.should_show_dimensions
  79. @property
  80. def show_row_idx_names(self) -> bool:
  81. return self.fmt.show_row_idx_names
  82. @property
  83. def show_col_idx_names(self) -> bool:
  84. return self.fmt.show_col_idx_names
  85. @property
  86. def row_levels(self) -> int:
  87. if self.fmt.index:
  88. # showing (row) index
  89. return self.frame.index.nlevels
  90. elif self.show_col_idx_names:
  91. # see gh-22579
  92. # Column misalignment also occurs for
  93. # a standard index when the columns index is named.
  94. # If the row index is not displayed a column of
  95. # blank cells need to be included before the DataFrame values.
  96. return 1
  97. # not showing (row) index
  98. return 0
  99. def _get_columns_formatted_values(self) -> Iterable:
  100. return self.columns
  101. @property
  102. def is_truncated(self) -> bool:
  103. return self.fmt.is_truncated
  104. @property
  105. def ncols(self) -> int:
  106. return len(self.fmt.tr_frame.columns)
  107. def write(self, s: Any, indent: int = 0) -> None:
  108. rs = pprint_thing(s)
  109. self.elements.append(" " * indent + rs)
  110. def write_th(
  111. self, s: Any, header: bool = False, indent: int = 0, tags: str | None = None
  112. ) -> None:
  113. """
  114. Method for writing a formatted <th> cell.
  115. If col_space is set on the formatter then that is used for
  116. the value of min-width.
  117. Parameters
  118. ----------
  119. s : object
  120. The data to be written inside the cell.
  121. header : bool, default False
  122. Set to True if the <th> is for use inside <thead>. This will
  123. cause min-width to be set if there is one.
  124. indent : int, default 0
  125. The indentation level of the cell.
  126. tags : str, default None
  127. Tags to include in the cell.
  128. Returns
  129. -------
  130. A written <th> cell.
  131. """
  132. col_space = self.col_space.get(s, None)
  133. if header and col_space is not None:
  134. tags = tags or ""
  135. tags += f'style="min-width: {col_space};"'
  136. self._write_cell(s, kind="th", indent=indent, tags=tags)
  137. def write_td(self, s: Any, indent: int = 0, tags: str | None = None) -> None:
  138. self._write_cell(s, kind="td", indent=indent, tags=tags)
  139. def _write_cell(
  140. self, s: Any, kind: str = "td", indent: int = 0, tags: str | None = None
  141. ) -> None:
  142. if tags is not None:
  143. start_tag = f"<{kind} {tags}>"
  144. else:
  145. start_tag = f"<{kind}>"
  146. if self.escape:
  147. # escape & first to prevent double escaping of &
  148. esc = {"&": r"&amp;", "<": r"&lt;", ">": r"&gt;"}
  149. else:
  150. esc = {}
  151. rs = pprint_thing(s, escape_chars=esc).strip()
  152. if self.render_links and is_url(rs):
  153. rs_unescaped = pprint_thing(s, escape_chars={}).strip()
  154. start_tag += f'<a href="{rs_unescaped}" target="_blank">'
  155. end_a = "</a>"
  156. else:
  157. end_a = ""
  158. self.write(f"{start_tag}{rs}{end_a}</{kind}>", indent)
  159. def write_tr(
  160. self,
  161. line: Iterable,
  162. indent: int = 0,
  163. indent_delta: int = 0,
  164. header: bool = False,
  165. align: str | None = None,
  166. tags: dict[int, str] | None = None,
  167. nindex_levels: int = 0,
  168. ) -> None:
  169. if tags is None:
  170. tags = {}
  171. if align is None:
  172. self.write("<tr>", indent)
  173. else:
  174. self.write(f'<tr style="text-align: {align};">', indent)
  175. indent += indent_delta
  176. for i, s in enumerate(line):
  177. val_tag = tags.get(i, None)
  178. if header or (self.bold_rows and i < nindex_levels):
  179. self.write_th(s, indent=indent, header=header, tags=val_tag)
  180. else:
  181. self.write_td(s, indent, tags=val_tag)
  182. indent -= indent_delta
  183. self.write("</tr>", indent)
  184. def _write_table(self, indent: int = 0) -> None:
  185. _classes = ["dataframe"] # Default class.
  186. use_mathjax = get_option("display.html.use_mathjax")
  187. if not use_mathjax:
  188. _classes.append("tex2jax_ignore")
  189. if self.classes is not None:
  190. if isinstance(self.classes, str):
  191. self.classes = self.classes.split()
  192. if not isinstance(self.classes, (list, tuple)):
  193. raise TypeError(
  194. "classes must be a string, list, "
  195. f"or tuple, not {type(self.classes)}"
  196. )
  197. _classes.extend(self.classes)
  198. if self.table_id is None:
  199. id_section = ""
  200. else:
  201. id_section = f' id="{self.table_id}"'
  202. if self.border is None:
  203. border_attr = ""
  204. else:
  205. border_attr = f' border="{self.border}"'
  206. self.write(
  207. f'<table{border_attr} class="{" ".join(_classes)}"{id_section}>',
  208. indent,
  209. )
  210. if self.fmt.header or self.show_row_idx_names:
  211. self._write_header(indent + self.indent_delta)
  212. self._write_body(indent + self.indent_delta)
  213. self.write("</table>", indent)
  214. def _write_col_header(self, indent: int) -> None:
  215. row: list[Hashable]
  216. is_truncated_horizontally = self.fmt.is_truncated_horizontally
  217. if isinstance(self.columns, MultiIndex):
  218. template = 'colspan="{span:d}" halign="left"'
  219. sentinel: lib.NoDefault | bool
  220. if self.fmt.sparsify:
  221. # GH3547
  222. sentinel = lib.no_default
  223. else:
  224. sentinel = False
  225. levels = self.columns.format(sparsify=sentinel, adjoin=False, names=False)
  226. level_lengths = get_level_lengths(levels, sentinel)
  227. inner_lvl = len(level_lengths) - 1
  228. for lnum, (records, values) in enumerate(zip(level_lengths, levels)):
  229. if is_truncated_horizontally:
  230. # modify the header lines
  231. ins_col = self.fmt.tr_col_num
  232. if self.fmt.sparsify:
  233. recs_new = {}
  234. # Increment tags after ... col.
  235. for tag, span in list(records.items()):
  236. if tag >= ins_col:
  237. recs_new[tag + 1] = span
  238. elif tag + span > ins_col:
  239. recs_new[tag] = span + 1
  240. if lnum == inner_lvl:
  241. values = (
  242. values[:ins_col] + ("...",) + values[ins_col:]
  243. )
  244. else:
  245. # sparse col headers do not receive a ...
  246. values = (
  247. values[:ins_col]
  248. + (values[ins_col - 1],)
  249. + values[ins_col:]
  250. )
  251. else:
  252. recs_new[tag] = span
  253. # if ins_col lies between tags, all col headers
  254. # get ...
  255. if tag + span == ins_col:
  256. recs_new[ins_col] = 1
  257. values = values[:ins_col] + ("...",) + values[ins_col:]
  258. records = recs_new
  259. inner_lvl = len(level_lengths) - 1
  260. if lnum == inner_lvl:
  261. records[ins_col] = 1
  262. else:
  263. recs_new = {}
  264. for tag, span in list(records.items()):
  265. if tag >= ins_col:
  266. recs_new[tag + 1] = span
  267. else:
  268. recs_new[tag] = span
  269. recs_new[ins_col] = 1
  270. records = recs_new
  271. values = values[:ins_col] + ["..."] + values[ins_col:]
  272. # see gh-22579
  273. # Column Offset Bug with to_html(index=False) with
  274. # MultiIndex Columns and Index.
  275. # Initially fill row with blank cells before column names.
  276. # TODO: Refactor to remove code duplication with code
  277. # block below for standard columns index.
  278. row = [""] * (self.row_levels - 1)
  279. if self.fmt.index or self.show_col_idx_names:
  280. # see gh-22747
  281. # If to_html(index_names=False) do not show columns
  282. # index names.
  283. # TODO: Refactor to use _get_column_name_list from
  284. # DataFrameFormatter class and create a
  285. # _get_formatted_column_labels function for code
  286. # parity with DataFrameFormatter class.
  287. if self.fmt.show_index_names:
  288. name = self.columns.names[lnum]
  289. row.append(pprint_thing(name or ""))
  290. else:
  291. row.append("")
  292. tags = {}
  293. j = len(row)
  294. for i, v in enumerate(values):
  295. if i in records:
  296. if records[i] > 1:
  297. tags[j] = template.format(span=records[i])
  298. else:
  299. continue
  300. j += 1
  301. row.append(v)
  302. self.write_tr(row, indent, self.indent_delta, tags=tags, header=True)
  303. else:
  304. # see gh-22579
  305. # Column misalignment also occurs for
  306. # a standard index when the columns index is named.
  307. # Initially fill row with blank cells before column names.
  308. # TODO: Refactor to remove code duplication with code block
  309. # above for columns MultiIndex.
  310. row = [""] * (self.row_levels - 1)
  311. if self.fmt.index or self.show_col_idx_names:
  312. # see gh-22747
  313. # If to_html(index_names=False) do not show columns
  314. # index names.
  315. # TODO: Refactor to use _get_column_name_list from
  316. # DataFrameFormatter class.
  317. if self.fmt.show_index_names:
  318. row.append(self.columns.name or "")
  319. else:
  320. row.append("")
  321. row.extend(self._get_columns_formatted_values())
  322. align = self.fmt.justify
  323. if is_truncated_horizontally:
  324. ins_col = self.row_levels + self.fmt.tr_col_num
  325. row.insert(ins_col, "...")
  326. self.write_tr(row, indent, self.indent_delta, header=True, align=align)
  327. def _write_row_header(self, indent: int) -> None:
  328. is_truncated_horizontally = self.fmt.is_truncated_horizontally
  329. row = [x if x is not None else "" for x in self.frame.index.names] + [""] * (
  330. self.ncols + (1 if is_truncated_horizontally else 0)
  331. )
  332. self.write_tr(row, indent, self.indent_delta, header=True)
  333. def _write_header(self, indent: int) -> None:
  334. self.write("<thead>", indent)
  335. if self.fmt.header:
  336. self._write_col_header(indent + self.indent_delta)
  337. if self.show_row_idx_names:
  338. self._write_row_header(indent + self.indent_delta)
  339. self.write("</thead>", indent)
  340. def _get_formatted_values(self) -> dict[int, list[str]]:
  341. with option_context("display.max_colwidth", None):
  342. fmt_values = {i: self.fmt.format_col(i) for i in range(self.ncols)}
  343. return fmt_values
  344. def _write_body(self, indent: int) -> None:
  345. self.write("<tbody>", indent)
  346. fmt_values = self._get_formatted_values()
  347. # write values
  348. if self.fmt.index and isinstance(self.frame.index, MultiIndex):
  349. self._write_hierarchical_rows(fmt_values, indent + self.indent_delta)
  350. else:
  351. self._write_regular_rows(fmt_values, indent + self.indent_delta)
  352. self.write("</tbody>", indent)
  353. def _write_regular_rows(
  354. self, fmt_values: Mapping[int, list[str]], indent: int
  355. ) -> None:
  356. is_truncated_horizontally = self.fmt.is_truncated_horizontally
  357. is_truncated_vertically = self.fmt.is_truncated_vertically
  358. nrows = len(self.fmt.tr_frame)
  359. if self.fmt.index:
  360. fmt = self.fmt._get_formatter("__index__")
  361. if fmt is not None:
  362. index_values = self.fmt.tr_frame.index.map(fmt)
  363. else:
  364. index_values = self.fmt.tr_frame.index.format()
  365. row: list[str] = []
  366. for i in range(nrows):
  367. if is_truncated_vertically and i == (self.fmt.tr_row_num):
  368. str_sep_row = ["..."] * len(row)
  369. self.write_tr(
  370. str_sep_row,
  371. indent,
  372. self.indent_delta,
  373. tags=None,
  374. nindex_levels=self.row_levels,
  375. )
  376. row = []
  377. if self.fmt.index:
  378. row.append(index_values[i])
  379. # see gh-22579
  380. # Column misalignment also occurs for
  381. # a standard index when the columns index is named.
  382. # Add blank cell before data cells.
  383. elif self.show_col_idx_names:
  384. row.append("")
  385. row.extend(fmt_values[j][i] for j in range(self.ncols))
  386. if is_truncated_horizontally:
  387. dot_col_ix = self.fmt.tr_col_num + self.row_levels
  388. row.insert(dot_col_ix, "...")
  389. self.write_tr(
  390. row, indent, self.indent_delta, tags=None, nindex_levels=self.row_levels
  391. )
  392. def _write_hierarchical_rows(
  393. self, fmt_values: Mapping[int, list[str]], indent: int
  394. ) -> None:
  395. template = 'rowspan="{span}" valign="top"'
  396. is_truncated_horizontally = self.fmt.is_truncated_horizontally
  397. is_truncated_vertically = self.fmt.is_truncated_vertically
  398. frame = self.fmt.tr_frame
  399. nrows = len(frame)
  400. assert isinstance(frame.index, MultiIndex)
  401. idx_values = frame.index.format(sparsify=False, adjoin=False, names=False)
  402. idx_values = list(zip(*idx_values))
  403. if self.fmt.sparsify:
  404. # GH3547
  405. sentinel = lib.no_default
  406. levels = frame.index.format(sparsify=sentinel, adjoin=False, names=False)
  407. level_lengths = get_level_lengths(levels, sentinel)
  408. inner_lvl = len(level_lengths) - 1
  409. if is_truncated_vertically:
  410. # Insert ... row and adjust idx_values and
  411. # level_lengths to take this into account.
  412. ins_row = self.fmt.tr_row_num
  413. inserted = False
  414. for lnum, records in enumerate(level_lengths):
  415. rec_new = {}
  416. for tag, span in list(records.items()):
  417. if tag >= ins_row:
  418. rec_new[tag + 1] = span
  419. elif tag + span > ins_row:
  420. rec_new[tag] = span + 1
  421. # GH 14882 - Make sure insertion done once
  422. if not inserted:
  423. dot_row = list(idx_values[ins_row - 1])
  424. dot_row[-1] = "..."
  425. idx_values.insert(ins_row, tuple(dot_row))
  426. inserted = True
  427. else:
  428. dot_row = list(idx_values[ins_row])
  429. dot_row[inner_lvl - lnum] = "..."
  430. idx_values[ins_row] = tuple(dot_row)
  431. else:
  432. rec_new[tag] = span
  433. # If ins_row lies between tags, all cols idx cols
  434. # receive ...
  435. if tag + span == ins_row:
  436. rec_new[ins_row] = 1
  437. if lnum == 0:
  438. idx_values.insert(
  439. ins_row, tuple(["..."] * len(level_lengths))
  440. )
  441. # GH 14882 - Place ... in correct level
  442. elif inserted:
  443. dot_row = list(idx_values[ins_row])
  444. dot_row[inner_lvl - lnum] = "..."
  445. idx_values[ins_row] = tuple(dot_row)
  446. level_lengths[lnum] = rec_new
  447. level_lengths[inner_lvl][ins_row] = 1
  448. for ix_col in fmt_values:
  449. fmt_values[ix_col].insert(ins_row, "...")
  450. nrows += 1
  451. for i in range(nrows):
  452. row = []
  453. tags = {}
  454. sparse_offset = 0
  455. j = 0
  456. for records, v in zip(level_lengths, idx_values[i]):
  457. if i in records:
  458. if records[i] > 1:
  459. tags[j] = template.format(span=records[i])
  460. else:
  461. sparse_offset += 1
  462. continue
  463. j += 1
  464. row.append(v)
  465. row.extend(fmt_values[j][i] for j in range(self.ncols))
  466. if is_truncated_horizontally:
  467. row.insert(
  468. self.row_levels - sparse_offset + self.fmt.tr_col_num, "..."
  469. )
  470. self.write_tr(
  471. row,
  472. indent,
  473. self.indent_delta,
  474. tags=tags,
  475. nindex_levels=len(levels) - sparse_offset,
  476. )
  477. else:
  478. row = []
  479. for i in range(len(frame)):
  480. if is_truncated_vertically and i == (self.fmt.tr_row_num):
  481. str_sep_row = ["..."] * len(row)
  482. self.write_tr(
  483. str_sep_row,
  484. indent,
  485. self.indent_delta,
  486. tags=None,
  487. nindex_levels=self.row_levels,
  488. )
  489. idx_values = list(
  490. zip(*frame.index.format(sparsify=False, adjoin=False, names=False))
  491. )
  492. row = []
  493. row.extend(idx_values[i])
  494. row.extend(fmt_values[j][i] for j in range(self.ncols))
  495. if is_truncated_horizontally:
  496. row.insert(self.row_levels + self.fmt.tr_col_num, "...")
  497. self.write_tr(
  498. row,
  499. indent,
  500. self.indent_delta,
  501. tags=None,
  502. nindex_levels=frame.index.nlevels,
  503. )
  504. class NotebookFormatter(HTMLFormatter):
  505. """
  506. Internal class for formatting output data in html for display in Jupyter
  507. Notebooks. This class is intended for functionality specific to
  508. DataFrame._repr_html_() and DataFrame.to_html(notebook=True)
  509. """
  510. def _get_formatted_values(self) -> dict[int, list[str]]:
  511. return {i: self.fmt.format_col(i) for i in range(self.ncols)}
  512. def _get_columns_formatted_values(self) -> list[str]:
  513. return self.columns.format()
  514. def write_style(self) -> None:
  515. # We use the "scoped" attribute here so that the desired
  516. # style properties for the data frame are not then applied
  517. # throughout the entire notebook.
  518. template_first = """\
  519. <style scoped>"""
  520. template_last = """\
  521. </style>"""
  522. template_select = """\
  523. .dataframe %s {
  524. %s: %s;
  525. }"""
  526. element_props = [
  527. ("tbody tr th:only-of-type", "vertical-align", "middle"),
  528. ("tbody tr th", "vertical-align", "top"),
  529. ]
  530. if isinstance(self.columns, MultiIndex):
  531. element_props.append(("thead tr th", "text-align", "left"))
  532. if self.show_row_idx_names:
  533. element_props.append(
  534. ("thead tr:last-of-type th", "text-align", "right")
  535. )
  536. else:
  537. element_props.append(("thead th", "text-align", "right"))
  538. template_mid = "\n\n".join(map(lambda t: template_select % t, element_props))
  539. template = dedent("\n".join((template_first, template_mid, template_last)))
  540. self.write(template)
  541. def render(self) -> list[str]:
  542. self.write("<div>")
  543. self.write_style()
  544. super().render()
  545. self.write("</div>")
  546. return self.elements