tesorotools-python 0.0.39__tar.gz → 0.0.41__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/PKG-INFO +1 -1
  2. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/pyproject.toml +1 -1
  3. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/assets/plots.yaml +3 -0
  4. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/render/content/table.py +148 -55
  5. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/.gitignore +0 -0
  6. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/__init__.py +0 -0
  7. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/_build_context.py +0 -0
  8. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/_registry.py +0 -0
  9. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/artists/__init__.py +0 -0
  10. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/artists/_common.py +0 -0
  11. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/artists/barh_plot.py +0 -0
  12. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/artists/line_plot.py +0 -0
  13. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/artists/stacked.py +0 -0
  14. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/artists/type_curve.py +0 -0
  15. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/assets/README.md +0 -0
  16. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  17. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  18. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  19. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  20. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  21. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  22. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  23. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  24. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/assets/fonts/README.md +0 -0
  25. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  26. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/data_sources/__init__.py +0 -0
  27. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/data_sources/debug.py +0 -0
  28. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/database/__init__.py +0 -0
  29. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/database/local.py +0 -0
  30. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/database/push.py +0 -0
  31. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/database/shared.py +0 -0
  32. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/dependencies/__init__.py +0 -0
  33. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/dependencies/node.py +0 -0
  34. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/dependencies/resolution.py +0 -0
  35. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/driver.py +0 -0
  36. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/manifest.py +0 -0
  37. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/offsets/__init__.py +0 -0
  38. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/offsets/offsets.py +0 -0
  39. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/offsets/outliers.py +0 -0
  40. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/orchestration.py +0 -0
  41. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/pipeline/__init__.py +0 -0
  42. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/pipeline/diagnose.py +0 -0
  43. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/pipeline/engine.py +0 -0
  44. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/pipeline/rules.py +0 -0
  45. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/providers/__init__.py +0 -0
  46. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/providers/base.py +0 -0
  47. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/providers/bde.py +0 -0
  48. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/providers/ecb.py +0 -0
  49. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/py.typed +0 -0
  50. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/render/__init__.py +0 -0
  51. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/render/content/__init__.py +0 -0
  52. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/render/content/content.py +0 -0
  53. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/render/content/images.py +0 -0
  54. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/render/content/section.py +0 -0
  55. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/render/content/subtitle.py +0 -0
  56. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/render/content/text.py +0 -0
  57. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/render/content/title.py +0 -0
  58. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/render/report.py +0 -0
  59. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/testing/__init__.py +0 -0
  60. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/testing/compare.py +0 -0
  61. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/utils/__init__.py +0 -0
  62. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/utils/config.py +0 -0
  63. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/utils/format.py +0 -0
  64. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/utils/globals.py +0 -0
  65. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/utils/matplotlib.py +0 -0
  66. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/utils/series.py +0 -0
  67. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/utils/shortcuts.py +0 -0
  68. {tesorotools_python-0.0.39 → tesorotools_python-0.0.41}/src/tesorotools/utils/template.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tesorotools-python
3
- Version: 0.0.39
3
+ Version: 0.0.41
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "tesorotools-python"
3
3
  requires-python = ">=3.13"
4
- version = "0.0.39"
4
+ version = "0.0.41"
5
5
  dependencies = [
6
6
  # database and ORM
7
7
  "psycopg[binary]>=3.1",
@@ -39,3 +39,6 @@ line:
39
39
  table:
40
40
  style: Light Shading Accent 1
41
41
  autofit: False
42
+ block_separator:
43
+ fill: "BFBFBF" # hex color of the inter-block band
44
+ height_twips: 20 # half-band height; total band = 2 * height_twips
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  # pyright: reportPrivateUsage=false
4
4
  from dataclasses import dataclass
5
5
  from pathlib import Path
6
- from typing import Any, Self
6
+ from typing import Any, Self, TypedDict
7
7
 
8
8
  import numpy as np
9
9
  import pandas as pd
@@ -15,6 +15,7 @@ from docx.oxml.ns import nsdecls, qn
15
15
  from docx.shared import Inches, Pt, RGBColor
16
16
  from docx.table import Table as TableDocx
17
17
  from docx.table import _Cell as TableCell
18
+ from docx.table import _Row as TableRow
18
19
  from yaml import MappingNode
19
20
 
20
21
  from tesorotools.utils.config import read_config
@@ -24,6 +25,35 @@ from tesorotools.utils.template import TemplateLoader
24
25
  RENDER_CONFIG: dict[str, Any] = read_config(PLOT_CONFIG_FILE)["table"]
25
26
 
26
27
 
28
+ class BlockSeparatorConfig(TypedDict, total=False):
29
+ """User-facing visual config for the inter-block separator band.
30
+
31
+ The band is rendered as **two adjacent rows** sharing the same
32
+ fill, so the pair shifts Word's automatic banding count by an
33
+ even number (preserving alternation in data rows below). Both
34
+ keys are optional; missing keys fall back to defaults.
35
+ """
36
+
37
+ fill: str
38
+ """Hex color of the band, without leading ``#``. Default ``BFBFBF``."""
39
+
40
+ height_twips: int
41
+ """Height of *each* of the two rows. Total band height is
42
+ ``2 * height_twips``. Default ``20`` (1pt per row, 2pt total)."""
43
+
44
+
45
+ class _ResolvedSeparator(TypedDict):
46
+ """Internal, fully-resolved variant of :class:`BlockSeparatorConfig`.
47
+
48
+ After defaults are merged in, every field is guaranteed present —
49
+ this lets call sites index without ``.get()`` and silences
50
+ pyright's NotRequired warnings.
51
+ """
52
+
53
+ fill: str
54
+ height_twips: int
55
+
56
+
27
57
  @dataclass(frozen=True)
28
58
  class RenderConfig:
29
59
  """Per-call rendering options for :func:`render_table`.
@@ -35,12 +65,18 @@ class RenderConfig:
35
65
 
36
66
  style: str | None = None
37
67
  autofit: bool = False
68
+ block_separator: BlockSeparatorConfig | None = None
38
69
 
39
70
  @classmethod
40
71
  def from_global(cls) -> RenderConfig:
72
+ sep_raw: Any = RENDER_CONFIG.get("block_separator")
73
+ block_separator: BlockSeparatorConfig | None = (
74
+ None if sep_raw is None else BlockSeparatorConfig(**sep_raw)
75
+ )
41
76
  return cls(
42
77
  style=RENDER_CONFIG.get("style"),
43
78
  autofit=bool(RENDER_CONFIG["autofit"]),
79
+ block_separator=block_separator,
44
80
  )
45
81
 
46
82
 
@@ -49,46 +85,6 @@ TEXTO_TABLAS: int = 9
49
85
  CENTER = WD_ALIGN_PARAGRAPH.CENTER
50
86
 
51
87
 
52
- def _set_cell_border(cell: TableCell, **kwargs: Any) -> None:
53
- """
54
- Set cell`s border
55
- Usage:
56
-
57
- set_cell_border(
58
- cell,
59
- top={"sz": 12, "val": "single", "color": "#FF0000", "space": "0"},
60
- bottom={"sz": 12, "color": "#00FF00", "val": "single"},
61
- start={"sz": 24, "val": "dashed", "shadow": "true"},
62
- end={"sz": 12, "val": "dashed"},
63
- )
64
- """
65
- tc = cell._tc
66
- tcPr = tc.get_or_add_tcPr()
67
-
68
- # check for tag existence, if none found, create one
69
- tcBorders: Any = tcPr.first_child_found_in("w:tcBorders") # type: ignore[reportUnknownMemberType]
70
- if tcBorders is None:
71
- tcBorders = OxmlElement("w:tcBorders") # type: ignore[reportUnknownVariableType]
72
- tcPr.append(tcBorders) # type: ignore[reportUnknownMemberType]
73
-
74
- # list over all available tags
75
- for edge in ("start", "top", "end", "bottom", "insideH", "insideV"):
76
- edge_data: Any = kwargs.get(edge)
77
- if edge_data:
78
- tag = "w:{}".format(edge)
79
-
80
- # check for tag existence, if none found, then create one
81
- element: Any = tcBorders.find(qn(tag)) # type: ignore[reportUnknownMemberType]
82
- if element is None:
83
- element = OxmlElement(tag) # type: ignore[reportUnknownVariableType]
84
- tcBorders.append(element) # type: ignore[reportUnknownMemberType]
85
-
86
- # looks like order of attributes is important
87
- for key in ["sz", "val", "color", "space", "shadow"]:
88
- if key in edge_data:
89
- element.set(qn("w:{}".format(key)), str(edge_data[key])) # type: ignore[reportUnknownMemberType]
90
-
91
-
92
88
  def _style_horizontal_blocks_header(cell: TableCell) -> None:
93
89
  cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
94
90
  cell.paragraphs[0].runs[0].font.size = Pt(12)
@@ -181,36 +177,125 @@ def _fill_index_names(
181
177
  _style_index_names(cell)
182
178
 
183
179
 
180
+ _DEFAULT_SEPARATOR: _ResolvedSeparator = {
181
+ "fill": "BFBFBF",
182
+ "height_twips": 20,
183
+ }
184
+
185
+
184
186
  # we only separate blocks in vertically stacked tables
185
187
  def _separate_blocks(
186
188
  index: pd.Index[Any] | pd.MultiIndex,
187
189
  table_docx: TableDocx,
190
+ *,
191
+ horizontal: bool,
192
+ config: BlockSeparatorConfig | None = None,
188
193
  ) -> None:
194
+ """Insert two adjacent separator rows after every block boundary.
195
+
196
+ Each separator is one merged cell (``<w:gridSpan>`` covering all
197
+ columns) with exact-height ``<w:trHeight>`` and a solid ``<w:shd>``
198
+ fill. Two adjacent rows with identical fill render as a single
199
+ seamless band; their pair shifts Word's automatic banding count by
200
+ an even number, preserving the alternation in the data rows below.
201
+
202
+ The cnfStyle approach this replaces was OOXML-valid but Word
203
+ treated ``<w:cnfStyle>`` as descriptive bookkeeping rather than as
204
+ an override directive — the conditional formatting was never
205
+ applied. See README "Block separators" for the postmortem.
206
+ """
189
207
  if not isinstance(index, pd.MultiIndex):
190
208
  raise ValueError(
191
209
  "block_sep=True requires the row index to be a MultiIndex; "
192
210
  "got a flat Index. Set block_sep=False or wrap rows in a "
193
211
  "MultiIndex with the block name as level 0."
194
212
  )
213
+ cfg: _ResolvedSeparator = {**_DEFAULT_SEPARATOR, **(config or {})}
214
+ num_cols: int = len(table_docx.columns)
215
+ grid_total: int = _grid_total_twips(table_docx)
216
+ header_rows: int = 2 if horizontal else 1
195
217
  blocks: list[str] = list(index.get_level_values(level=0).unique())
196
218
  previous_rows: int = 0
219
+ inserted: int = 0
197
220
  for block in blocks[:-1]:
198
221
  block_size: int = len(index[index.get_level_values(level=0) == block])
199
- for cell in table_docx.rows[block_size + previous_rows].cells:
200
- _separate_cell(cell)
222
+ anchor_idx: int = (
223
+ header_rows + previous_rows + block_size - 1 + inserted
224
+ )
225
+ anchor: TableRow = table_docx.rows[anchor_idx]
226
+ _insert_double_separator(anchor, num_cols, grid_total, cfg)
201
227
  previous_rows += block_size
228
+ inserted += 2
202
229
 
203
230
 
204
- def _separate_cell(cell: TableCell) -> None:
205
- _set_cell_border(
206
- cell,
207
- bottom={
208
- "sz": 1,
209
- "val": "double",
210
- "color": "#000000",
211
- "space": 2,
212
- },
213
- )
231
+ def _grid_total_twips(table_docx: TableDocx) -> int:
232
+ """Sum the widths of every ``<w:gridCol>`` under ``<w:tblGrid>``."""
233
+ tblGrid: Any = table_docx._element.find(qn("w:tblGrid")) # type: ignore[reportUnknownMemberType]
234
+ cols: Any = tblGrid.findall(qn("w:gridCol")) # type: ignore[reportUnknownMemberType]
235
+ return sum(int(gc.get(qn("w:w"))) for gc in cols) # type: ignore[reportUnknownArgumentType, reportUnknownVariableType]
236
+
237
+
238
+ def _insert_double_separator(
239
+ anchor_row: TableRow,
240
+ num_cols: int,
241
+ grid_total_twips: int,
242
+ cfg: _ResolvedSeparator,
243
+ ) -> None:
244
+ sep1: Any = _build_separator_row(num_cols, grid_total_twips, cfg)
245
+ sep2: Any = _build_separator_row(num_cols, grid_total_twips, cfg)
246
+ # Insert sep2 first, then sep1 in front of sep2 — both end up after
247
+ # anchor_row in document order (anchor → sep1 → sep2 → next data row).
248
+ anchor_row._tr.addnext(sep2) # type: ignore[reportUnknownMemberType]
249
+ anchor_row._tr.addnext(sep1) # type: ignore[reportUnknownMemberType]
250
+
251
+
252
+ def _build_separator_row(
253
+ num_cols: int, grid_total_twips: int, cfg: _ResolvedSeparator
254
+ ) -> Any:
255
+ fill: str = cfg["fill"]
256
+ height_twips: int = cfg["height_twips"]
257
+
258
+ tr: Any = OxmlElement("w:tr") # type: ignore[reportUnknownVariableType]
259
+
260
+ trPr: Any = OxmlElement("w:trPr") # type: ignore[reportUnknownVariableType]
261
+ trHeight: Any = OxmlElement("w:trHeight") # type: ignore[reportUnknownVariableType]
262
+ trHeight.set(qn("w:val"), str(height_twips)) # type: ignore[reportUnknownMemberType]
263
+ trHeight.set(qn("w:hRule"), "exact") # type: ignore[reportUnknownMemberType]
264
+ trPr.append(trHeight) # type: ignore[reportUnknownMemberType]
265
+ tr.append(trPr) # type: ignore[reportUnknownMemberType]
266
+
267
+ tc: Any = OxmlElement("w:tc") # type: ignore[reportUnknownVariableType]
268
+ tcPr: Any = OxmlElement("w:tcPr") # type: ignore[reportUnknownVariableType]
269
+ tcW: Any = OxmlElement("w:tcW") # type: ignore[reportUnknownVariableType]
270
+ tcW.set(qn("w:w"), str(grid_total_twips)) # type: ignore[reportUnknownMemberType]
271
+ tcW.set(qn("w:type"), "dxa") # type: ignore[reportUnknownMemberType]
272
+ tcPr.append(tcW) # type: ignore[reportUnknownMemberType]
273
+ gridSpan: Any = OxmlElement("w:gridSpan") # type: ignore[reportUnknownVariableType]
274
+ gridSpan.set(qn("w:val"), str(num_cols)) # type: ignore[reportUnknownMemberType]
275
+ tcPr.append(gridSpan) # type: ignore[reportUnknownMemberType]
276
+ shd: Any = OxmlElement("w:shd") # type: ignore[reportUnknownVariableType]
277
+ shd.set(qn("w:val"), "clear") # type: ignore[reportUnknownMemberType]
278
+ shd.set(qn("w:color"), "auto") # type: ignore[reportUnknownMemberType]
279
+ shd.set(qn("w:fill"), fill) # type: ignore[reportUnknownMemberType]
280
+ tcPr.append(shd) # type: ignore[reportUnknownMemberType]
281
+ tc.append(tcPr) # type: ignore[reportUnknownMemberType]
282
+
283
+ # OOXML requires <w:tc> to contain at least one block-level element.
284
+ # The inner paragraph carries exact line spacing so Word does not
285
+ # inflate the row past the configured trHeight on screen.
286
+ p: Any = OxmlElement("w:p") # type: ignore[reportUnknownVariableType]
287
+ pPr: Any = OxmlElement("w:pPr") # type: ignore[reportUnknownVariableType]
288
+ spacing: Any = OxmlElement("w:spacing") # type: ignore[reportUnknownVariableType]
289
+ spacing.set(qn("w:before"), "0") # type: ignore[reportUnknownMemberType]
290
+ spacing.set(qn("w:after"), "0") # type: ignore[reportUnknownMemberType]
291
+ spacing.set(qn("w:line"), str(height_twips)) # type: ignore[reportUnknownMemberType]
292
+ spacing.set(qn("w:lineRule"), "exact") # type: ignore[reportUnknownMemberType]
293
+ pPr.append(spacing) # type: ignore[reportUnknownMemberType]
294
+ p.append(pPr) # type: ignore[reportUnknownMemberType]
295
+ tc.append(p) # type: ignore[reportUnknownMemberType]
296
+
297
+ tr.append(tc) # type: ignore[reportUnknownMemberType]
298
+ return tr # type: ignore[reportUnknownVariableType]
214
299
 
215
300
 
216
301
  def _is_bright(hex_color: str) -> bool:
@@ -313,9 +398,17 @@ def render_table(
313
398
  horizontal=horizontal,
314
399
  **kwargs,
315
400
  )
316
- if block_sep:
317
- _separate_blocks(table.index, table_docx)
318
401
  _fill_content(table, color_table, shade_table, table_docx, horizontal)
402
+ # Block separators insert *new rows* into the table, so they must
403
+ # run after _fill_content — otherwise the (row, col) indexing in
404
+ # _fill_content would land on the inserted separator rows.
405
+ if block_sep:
406
+ _separate_blocks(
407
+ table.index,
408
+ table_docx,
409
+ horizontal=horizontal,
410
+ config=cfg.block_separator,
411
+ )
319
412
  table_docx.alignment = WD_TABLE_ALIGNMENT.CENTER
320
413
  return table_docx
321
414