test_to_excel.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. """Tests formatting as writer-agnostic ExcelCells
  2. ExcelFormatter is tested implicitly in pandas/tests/io/excel
  3. """
  4. import string
  5. import pytest
  6. from pandas.errors import CSSWarning
  7. import pandas.util._test_decorators as td
  8. import pandas._testing as tm
  9. from pandas.io.formats.excel import (
  10. CssExcelCell,
  11. CSSToExcelConverter,
  12. )
  13. @pytest.mark.parametrize(
  14. "css,expected",
  15. [
  16. # FONT
  17. # - name
  18. ("font-family: foo,bar", {"font": {"name": "foo"}}),
  19. ('font-family: "foo bar",baz', {"font": {"name": "foo bar"}}),
  20. ("font-family: foo,\nbar", {"font": {"name": "foo"}}),
  21. ("font-family: foo, bar, baz", {"font": {"name": "foo"}}),
  22. ("font-family: bar, foo", {"font": {"name": "bar"}}),
  23. ("font-family: 'foo bar', baz", {"font": {"name": "foo bar"}}),
  24. ("font-family: 'foo \\'bar', baz", {"font": {"name": "foo 'bar"}}),
  25. ('font-family: "foo \\"bar", baz', {"font": {"name": 'foo "bar'}}),
  26. ('font-family: "foo ,bar", baz', {"font": {"name": "foo ,bar"}}),
  27. # - family
  28. ("font-family: serif", {"font": {"name": "serif", "family": 1}}),
  29. ("font-family: Serif", {"font": {"name": "serif", "family": 1}}),
  30. ("font-family: roman, serif", {"font": {"name": "roman", "family": 1}}),
  31. ("font-family: roman, sans-serif", {"font": {"name": "roman", "family": 2}}),
  32. ("font-family: roman, sans serif", {"font": {"name": "roman"}}),
  33. ("font-family: roman, sansserif", {"font": {"name": "roman"}}),
  34. ("font-family: roman, cursive", {"font": {"name": "roman", "family": 4}}),
  35. ("font-family: roman, fantasy", {"font": {"name": "roman", "family": 5}}),
  36. # - size
  37. ("font-size: 1em", {"font": {"size": 12}}),
  38. ("font-size: xx-small", {"font": {"size": 6}}),
  39. ("font-size: x-small", {"font": {"size": 7.5}}),
  40. ("font-size: small", {"font": {"size": 9.6}}),
  41. ("font-size: medium", {"font": {"size": 12}}),
  42. ("font-size: large", {"font": {"size": 13.5}}),
  43. ("font-size: x-large", {"font": {"size": 18}}),
  44. ("font-size: xx-large", {"font": {"size": 24}}),
  45. ("font-size: 50%", {"font": {"size": 6}}),
  46. # - bold
  47. ("font-weight: 100", {"font": {"bold": False}}),
  48. ("font-weight: 200", {"font": {"bold": False}}),
  49. ("font-weight: 300", {"font": {"bold": False}}),
  50. ("font-weight: 400", {"font": {"bold": False}}),
  51. ("font-weight: normal", {"font": {"bold": False}}),
  52. ("font-weight: lighter", {"font": {"bold": False}}),
  53. ("font-weight: bold", {"font": {"bold": True}}),
  54. ("font-weight: bolder", {"font": {"bold": True}}),
  55. ("font-weight: 700", {"font": {"bold": True}}),
  56. ("font-weight: 800", {"font": {"bold": True}}),
  57. ("font-weight: 900", {"font": {"bold": True}}),
  58. # - italic
  59. ("font-style: italic", {"font": {"italic": True}}),
  60. ("font-style: oblique", {"font": {"italic": True}}),
  61. # - underline
  62. ("text-decoration: underline", {"font": {"underline": "single"}}),
  63. ("text-decoration: overline", {}),
  64. ("text-decoration: none", {}),
  65. # - strike
  66. ("text-decoration: line-through", {"font": {"strike": True}}),
  67. (
  68. "text-decoration: underline line-through",
  69. {"font": {"strike": True, "underline": "single"}},
  70. ),
  71. (
  72. "text-decoration: underline; text-decoration: line-through",
  73. {"font": {"strike": True}},
  74. ),
  75. # - color
  76. ("color: red", {"font": {"color": "FF0000"}}),
  77. ("color: #ff0000", {"font": {"color": "FF0000"}}),
  78. ("color: #f0a", {"font": {"color": "FF00AA"}}),
  79. # - shadow
  80. ("text-shadow: none", {"font": {"shadow": False}}),
  81. ("text-shadow: 0px -0em 0px #CCC", {"font": {"shadow": False}}),
  82. ("text-shadow: 0px -0em 0px #999", {"font": {"shadow": False}}),
  83. ("text-shadow: 0px -0em 0px", {"font": {"shadow": False}}),
  84. ("text-shadow: 2px -0em 0px #CCC", {"font": {"shadow": True}}),
  85. ("text-shadow: 0px -2em 0px #CCC", {"font": {"shadow": True}}),
  86. ("text-shadow: 0px -0em 2px #CCC", {"font": {"shadow": True}}),
  87. ("text-shadow: 0px -0em 2px", {"font": {"shadow": True}}),
  88. ("text-shadow: 0px -2em", {"font": {"shadow": True}}),
  89. # FILL
  90. # - color, fillType
  91. (
  92. "background-color: red",
  93. {"fill": {"fgColor": "FF0000", "patternType": "solid"}},
  94. ),
  95. (
  96. "background-color: #ff0000",
  97. {"fill": {"fgColor": "FF0000", "patternType": "solid"}},
  98. ),
  99. (
  100. "background-color: #f0a",
  101. {"fill": {"fgColor": "FF00AA", "patternType": "solid"}},
  102. ),
  103. # BORDER
  104. # - style
  105. (
  106. "border-style: solid",
  107. {
  108. "border": {
  109. "top": {"style": "medium"},
  110. "bottom": {"style": "medium"},
  111. "left": {"style": "medium"},
  112. "right": {"style": "medium"},
  113. }
  114. },
  115. ),
  116. (
  117. "border-style: solid; border-width: thin",
  118. {
  119. "border": {
  120. "top": {"style": "thin"},
  121. "bottom": {"style": "thin"},
  122. "left": {"style": "thin"},
  123. "right": {"style": "thin"},
  124. }
  125. },
  126. ),
  127. (
  128. "border-top-style: solid; border-top-width: thin",
  129. {"border": {"top": {"style": "thin"}}},
  130. ),
  131. (
  132. "border-top-style: solid; border-top-width: 1pt",
  133. {"border": {"top": {"style": "thin"}}},
  134. ),
  135. ("border-top-style: solid", {"border": {"top": {"style": "medium"}}}),
  136. (
  137. "border-top-style: solid; border-top-width: medium",
  138. {"border": {"top": {"style": "medium"}}},
  139. ),
  140. (
  141. "border-top-style: solid; border-top-width: 2pt",
  142. {"border": {"top": {"style": "medium"}}},
  143. ),
  144. (
  145. "border-top-style: solid; border-top-width: thick",
  146. {"border": {"top": {"style": "thick"}}},
  147. ),
  148. (
  149. "border-top-style: solid; border-top-width: 4pt",
  150. {"border": {"top": {"style": "thick"}}},
  151. ),
  152. (
  153. "border-top-style: dotted",
  154. {"border": {"top": {"style": "mediumDashDotDot"}}},
  155. ),
  156. (
  157. "border-top-style: dotted; border-top-width: thin",
  158. {"border": {"top": {"style": "dotted"}}},
  159. ),
  160. ("border-top-style: dashed", {"border": {"top": {"style": "mediumDashed"}}}),
  161. (
  162. "border-top-style: dashed; border-top-width: thin",
  163. {"border": {"top": {"style": "dashed"}}},
  164. ),
  165. ("border-top-style: double", {"border": {"top": {"style": "double"}}}),
  166. # - color
  167. (
  168. "border-style: solid; border-color: #0000ff",
  169. {
  170. "border": {
  171. "top": {"style": "medium", "color": "0000FF"},
  172. "right": {"style": "medium", "color": "0000FF"},
  173. "bottom": {"style": "medium", "color": "0000FF"},
  174. "left": {"style": "medium", "color": "0000FF"},
  175. }
  176. },
  177. ),
  178. (
  179. "border-top-style: double; border-top-color: blue",
  180. {"border": {"top": {"style": "double", "color": "0000FF"}}},
  181. ),
  182. (
  183. "border-top-style: solid; border-top-color: #06c",
  184. {"border": {"top": {"style": "medium", "color": "0066CC"}}},
  185. ),
  186. (
  187. "border-top-color: blue",
  188. {"border": {"top": {"color": "0000FF", "style": "none"}}},
  189. ),
  190. # ALIGNMENT
  191. # - horizontal
  192. ("text-align: center", {"alignment": {"horizontal": "center"}}),
  193. ("text-align: left", {"alignment": {"horizontal": "left"}}),
  194. ("text-align: right", {"alignment": {"horizontal": "right"}}),
  195. ("text-align: justify", {"alignment": {"horizontal": "justify"}}),
  196. # - vertical
  197. ("vertical-align: top", {"alignment": {"vertical": "top"}}),
  198. ("vertical-align: text-top", {"alignment": {"vertical": "top"}}),
  199. ("vertical-align: middle", {"alignment": {"vertical": "center"}}),
  200. ("vertical-align: bottom", {"alignment": {"vertical": "bottom"}}),
  201. ("vertical-align: text-bottom", {"alignment": {"vertical": "bottom"}}),
  202. # - wrap_text
  203. ("white-space: nowrap", {"alignment": {"wrap_text": False}}),
  204. ("white-space: pre", {"alignment": {"wrap_text": False}}),
  205. ("white-space: pre-line", {"alignment": {"wrap_text": False}}),
  206. ("white-space: normal", {"alignment": {"wrap_text": True}}),
  207. # NUMBER FORMAT
  208. ("number-format: 0%", {"number_format": {"format_code": "0%"}}),
  209. (
  210. "number-format: 0§[Red](0)§-§@;",
  211. {"number_format": {"format_code": "0;[red](0);-;@"}}, # GH 46152
  212. ),
  213. ],
  214. )
  215. def test_css_to_excel(css, expected):
  216. convert = CSSToExcelConverter()
  217. assert expected == convert(css)
  218. def test_css_to_excel_multiple():
  219. convert = CSSToExcelConverter()
  220. actual = convert(
  221. """
  222. font-weight: bold;
  223. text-decoration: underline;
  224. color: red;
  225. border-width: thin;
  226. text-align: center;
  227. vertical-align: top;
  228. unused: something;
  229. """
  230. )
  231. assert {
  232. "font": {"bold": True, "underline": "single", "color": "FF0000"},
  233. "border": {
  234. "top": {"style": "thin"},
  235. "right": {"style": "thin"},
  236. "bottom": {"style": "thin"},
  237. "left": {"style": "thin"},
  238. },
  239. "alignment": {"horizontal": "center", "vertical": "top"},
  240. } == actual
  241. @pytest.mark.parametrize(
  242. "css,inherited,expected",
  243. [
  244. ("font-weight: bold", "", {"font": {"bold": True}}),
  245. ("", "font-weight: bold", {"font": {"bold": True}}),
  246. (
  247. "font-weight: bold",
  248. "font-style: italic",
  249. {"font": {"bold": True, "italic": True}},
  250. ),
  251. ("font-style: normal", "font-style: italic", {"font": {"italic": False}}),
  252. ("font-style: inherit", "", {}),
  253. (
  254. "font-style: normal; font-style: inherit",
  255. "font-style: italic",
  256. {"font": {"italic": True}},
  257. ),
  258. ],
  259. )
  260. def test_css_to_excel_inherited(css, inherited, expected):
  261. convert = CSSToExcelConverter(inherited)
  262. assert expected == convert(css)
  263. @pytest.mark.parametrize(
  264. "input_color,output_color",
  265. (
  266. list(CSSToExcelConverter.NAMED_COLORS.items())
  267. + [("#" + rgb, rgb) for rgb in CSSToExcelConverter.NAMED_COLORS.values()]
  268. + [("#F0F", "FF00FF"), ("#ABC", "AABBCC")]
  269. ),
  270. )
  271. def test_css_to_excel_good_colors(input_color, output_color):
  272. # see gh-18392
  273. css = (
  274. f"border-top-color: {input_color}; "
  275. f"border-right-color: {input_color}; "
  276. f"border-bottom-color: {input_color}; "
  277. f"border-left-color: {input_color}; "
  278. f"background-color: {input_color}; "
  279. f"color: {input_color}"
  280. )
  281. expected = {}
  282. expected["fill"] = {"patternType": "solid", "fgColor": output_color}
  283. expected["font"] = {"color": output_color}
  284. expected["border"] = {
  285. k: {"color": output_color, "style": "none"}
  286. for k in ("top", "right", "bottom", "left")
  287. }
  288. with tm.assert_produces_warning(None):
  289. convert = CSSToExcelConverter()
  290. assert expected == convert(css)
  291. @pytest.mark.parametrize("input_color", [None, "not-a-color"])
  292. def test_css_to_excel_bad_colors(input_color):
  293. # see gh-18392
  294. css = (
  295. f"border-top-color: {input_color}; "
  296. f"border-right-color: {input_color}; "
  297. f"border-bottom-color: {input_color}; "
  298. f"border-left-color: {input_color}; "
  299. f"background-color: {input_color}; "
  300. f"color: {input_color}"
  301. )
  302. expected = {}
  303. if input_color is not None:
  304. expected["fill"] = {"patternType": "solid"}
  305. with tm.assert_produces_warning(CSSWarning):
  306. convert = CSSToExcelConverter()
  307. assert expected == convert(css)
  308. def tests_css_named_colors_valid():
  309. upper_hexs = set(map(str.upper, string.hexdigits))
  310. for color in CSSToExcelConverter.NAMED_COLORS.values():
  311. assert len(color) == 6 and all(c in upper_hexs for c in color)
  312. @td.skip_if_no_mpl
  313. def test_css_named_colors_from_mpl_present():
  314. from matplotlib.colors import CSS4_COLORS as mpl_colors
  315. pd_colors = CSSToExcelConverter.NAMED_COLORS
  316. for name, color in mpl_colors.items():
  317. assert name in pd_colors and pd_colors[name] == color[1:]
  318. @pytest.mark.parametrize(
  319. "styles,expected",
  320. [
  321. ([("color", "green"), ("color", "red")], "color: red;"),
  322. ([("font-weight", "bold"), ("font-weight", "normal")], "font-weight: normal;"),
  323. ([("text-align", "center"), ("TEXT-ALIGN", "right")], "text-align: right;"),
  324. ],
  325. )
  326. def test_css_excel_cell_precedence(styles, expected):
  327. """It applies favors latter declarations over former declarations"""
  328. # See GH 47371
  329. converter = CSSToExcelConverter()
  330. converter._call_cached.cache_clear()
  331. css_styles = {(0, 0): styles}
  332. cell = CssExcelCell(
  333. row=0,
  334. col=0,
  335. val="",
  336. style=None,
  337. css_styles=css_styles,
  338. css_row=0,
  339. css_col=0,
  340. css_converter=converter,
  341. )
  342. converter._call_cached.cache_clear()
  343. assert cell.style == converter(expected)
  344. @pytest.mark.parametrize(
  345. "styles,cache_hits,cache_misses",
  346. [
  347. ([[("color", "green"), ("color", "red"), ("color", "green")]], 0, 1),
  348. (
  349. [
  350. [("font-weight", "bold")],
  351. [("font-weight", "normal"), ("font-weight", "bold")],
  352. ],
  353. 1,
  354. 1,
  355. ),
  356. ([[("text-align", "center")], [("TEXT-ALIGN", "center")]], 1, 1),
  357. (
  358. [
  359. [("font-weight", "bold"), ("text-align", "center")],
  360. [("font-weight", "bold"), ("text-align", "left")],
  361. ],
  362. 0,
  363. 2,
  364. ),
  365. (
  366. [
  367. [("font-weight", "bold"), ("text-align", "center")],
  368. [("font-weight", "bold"), ("text-align", "left")],
  369. [("font-weight", "bold"), ("text-align", "center")],
  370. ],
  371. 1,
  372. 2,
  373. ),
  374. ],
  375. )
  376. def test_css_excel_cell_cache(styles, cache_hits, cache_misses):
  377. """It caches unique cell styles"""
  378. # See GH 47371
  379. converter = CSSToExcelConverter()
  380. converter._call_cached.cache_clear()
  381. css_styles = {(0, i): _style for i, _style in enumerate(styles)}
  382. for css_row, css_col in css_styles:
  383. CssExcelCell(
  384. row=0,
  385. col=0,
  386. val="",
  387. style=None,
  388. css_styles=css_styles,
  389. css_row=css_row,
  390. css_col=css_col,
  391. css_converter=converter,
  392. )
  393. cache_info = converter._call_cached.cache_info()
  394. converter._call_cached.cache_clear()
  395. assert cache_info.hits == cache_hits
  396. assert cache_info.misses == cache_misses