laudo 0.1.0__py3-none-any.whl

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.
laudo/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from pathlib import Path
2
+
3
+ from .core import run
4
+
5
+
6
+ def convert(folder: str | Path, output: str | Path, *, debug: bool = False) -> Path:
7
+ return run(Path(folder), Path(output), debug=debug)
laudo/__main__.py ADDED
@@ -0,0 +1,113 @@
1
+ import argparse
2
+ import os
3
+ import stat
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from . import convert
8
+
9
+
10
+ def _find_project_root() -> Path:
11
+ candidate = Path(__file__).resolve()
12
+ for parent in [candidate] + list(candidate.parents):
13
+ if (parent / "pyproject.toml").is_file():
14
+ return parent
15
+ return candidate.parent
16
+
17
+
18
+ def _cmd_install() -> None:
19
+ bindir = Path.home() / ".local" / "bin"
20
+ bindir.mkdir(parents=True, exist_ok=True)
21
+ project_root = _find_project_root()
22
+
23
+ if os.name == "nt":
24
+ script = bindir / "laudo-dev.ps1"
25
+ content = f"& uv run --project {project_root} laudo @args\n"
26
+ script.write_text(content.lstrip())
27
+ print(f"Installed laudo wrapper at {script}")
28
+ path_sep = ";"
29
+ path_hint = f"$env:Path = \"{bindir};$env:Path\""
30
+ else:
31
+ script = bindir / "laudo"
32
+ content = f"#!/usr/bin/env bash\nexec uv run --project {project_root} laudo \"$@\"\n"
33
+ script.write_text(content.lstrip())
34
+ script.chmod(script.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
35
+ print(f"Installed laudo wrapper at {script}")
36
+ path_sep = ":"
37
+ path_hint = f'export PATH="$PATH:{bindir}"'
38
+
39
+ if str(bindir) not in os.environ.get("PATH", "").split(path_sep):
40
+ print(f"Warning: {bindir} is not in your PATH.", file=sys.stderr)
41
+ if os.name == "nt":
42
+ print("Add this to your PowerShell profile:")
43
+ print(f" {path_hint}")
44
+ else:
45
+ shell = Path(os.environ.get("SHELL", "/bin/bash")).name
46
+ rc: Path | None = (
47
+ Path.home() / ".bashrc"
48
+ if shell == "bash"
49
+ else Path.home() / ".zshrc"
50
+ if shell == "zsh"
51
+ else None
52
+ )
53
+ if rc is not None:
54
+ print(f"Add this line to {rc}:")
55
+ print(f" {path_hint}")
56
+
57
+
58
+ def _cmd_gen(dir_: Path | None, debug: bool) -> None:
59
+ if dir_ is not None:
60
+ os.chdir(str(dir_))
61
+ folder = Path.cwd()
62
+ output = folder / "laudo.docx"
63
+ result = convert(folder, output, debug=debug)
64
+ print(f"Generated: {result}")
65
+
66
+
67
+ def _cmd_captions(dir_: Path | None) -> None:
68
+ if dir_ is not None:
69
+ os.chdir(str(dir_))
70
+ from .gui import run as run_gui
71
+
72
+ run_gui(Path.cwd())
73
+
74
+
75
+ def _existing_dir(value: str) -> Path:
76
+ p = Path(value)
77
+ if not p.is_dir():
78
+ raise argparse.ArgumentTypeError(f"not a directory: {value}")
79
+ return p
80
+
81
+
82
+ def main() -> None:
83
+ parser = argparse.ArgumentParser(
84
+ description="Convert markdown files or open the caption editor."
85
+ )
86
+ sub = parser.add_subparsers(dest="command")
87
+
88
+ gen_p = sub.add_parser("gen", help="Generate docx/pdf from markdown files.")
89
+ gen_p.add_argument("--dir", type=_existing_dir, default=None, help="Working directory (default: current directory)")
90
+ gen_p.add_argument("--debug", action="store_true", help="Print the template context before rendering")
91
+
92
+ cap_p = sub.add_parser("captions", help="Open the caption editor GUI.")
93
+ cap_p.add_argument("--dir", type=_existing_dir, default=None, help="Working directory (default: current directory)")
94
+
95
+ sub.add_parser("install", help="Install the laudo wrapper script to ~/.local/bin.")
96
+
97
+ args = parser.parse_args()
98
+
99
+ if args.command is None:
100
+ parser.print_help()
101
+ sys.exit(1)
102
+
103
+ match args.command:
104
+ case "install":
105
+ _cmd_install()
106
+ case "gen":
107
+ _cmd_gen(args.dir, args.debug)
108
+ case "captions":
109
+ _cmd_captions(args.dir)
110
+
111
+
112
+ if __name__ == "__main__":
113
+ main()
laudo/assets.py ADDED
@@ -0,0 +1,14 @@
1
+ from pathlib import Path
2
+
3
+ from .exif import get_caption
4
+
5
+
6
+ def collect_assets(assets_path: Path) -> dict[str, dict]:
7
+ assets: dict[str, dict] = {}
8
+ if not assets_path.is_dir():
9
+ return assets
10
+ for f in sorted(assets_path.iterdir()):
11
+ if f.is_file():
12
+ caption = get_caption(f) if f.suffix.lower() in {".jpg", ".jpeg", ".tiff", ".png"} else ""
13
+ assets[f.stem] = {"path": f, "caption": caption}
14
+ return assets
laudo/core.py ADDED
@@ -0,0 +1,141 @@
1
+ import os
2
+ import shutil
3
+ import tempfile
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ import json
7
+ from docxtpl import DocxTemplate
8
+ from jinja2 import Environment, Template
9
+
10
+ from laudo.reference_replacer import DocxReferenceReplacer
11
+
12
+ from .filters import register as register_filters
13
+ from .globals import register as register_globals
14
+
15
+
16
+ def _parse_context_txt(folder: Path) -> dict:
17
+ context: dict = {}
18
+ ctx_file = folder / "context.txt"
19
+ if not ctx_file.is_file():
20
+ return context
21
+ for line in ctx_file.read_text(encoding="utf-8").splitlines():
22
+ line = line.strip()
23
+ if not line or "=" not in line:
24
+ continue
25
+ key, _, val = line.partition("=")
26
+ key = key.strip()
27
+ if not key:
28
+ continue
29
+ context[key] = val.strip()
30
+ return context
31
+
32
+
33
+ def _read_context_json(folder: Path) -> dict:
34
+ ctx: dict = {}
35
+ for json_file in sorted(folder.glob("*.json")):
36
+ key = json_file.stem.replace(" ", "_")
37
+ content = json_file.read_text(encoding="utf-8")
38
+ ctx[key] = json.loads(content)
39
+ return ctx
40
+
41
+
42
+ def _read_context_markdown(folder: Path, ctx_vars: dict) -> dict:
43
+ ctx: dict = {}
44
+ for md_file in sorted(folder.glob("*.md")):
45
+ key = md_file.stem.replace(" ", "_")
46
+ content = md_file.read_text(encoding="utf-8")
47
+ ctx[key] = Template(content).render(ctx_vars)
48
+ return ctx
49
+
50
+
51
+ def _read_context_pics(folder: Path) -> list[dict]:
52
+ from .exif import get_caption as _get_exif_caption
53
+ from .images import _IMAGE_EXTENSIONS, get_reduced, get_thumbnail
54
+
55
+ fotos = folder / "fotos"
56
+ if not fotos.is_dir():
57
+ return []
58
+
59
+ cwd = Path.cwd()
60
+ os.chdir(str(folder))
61
+ try:
62
+ pics: list[dict] = []
63
+ for img in sorted(fotos.iterdir()):
64
+ if img.is_file() and img.suffix.lower() in _IMAGE_EXTENSIONS:
65
+ pics.append({
66
+ "refname": img.stem,
67
+ "path": img,
68
+ "caption": _get_exif_caption(img),
69
+ "thumb": get_thumbnail(img.stem),
70
+ "reduced": get_reduced(img.stem),
71
+ "label": "Foto",
72
+ })
73
+ return pics
74
+ finally:
75
+ os.chdir(str(cwd))
76
+
77
+
78
+ def _build_context(folder: Path) -> dict:
79
+ context_folder = folder / "context"
80
+ if not context_folder.is_dir():
81
+ context_folder = folder
82
+ ctx_vars = _parse_context_txt(context_folder)
83
+ ctx_vars.update(_read_context_json(context_folder))
84
+ ctx_vars.update(_read_context_markdown(context_folder, ctx_vars))
85
+ pics = _read_context_pics(folder)
86
+ if pics:
87
+ ctx_vars["pics"] = pics
88
+ return ctx_vars
89
+
90
+ @dataclass
91
+ class RenderEnv:
92
+ tpl: DocxTemplate
93
+ jinja_env: Environment
94
+ temp_folder: Path
95
+ assets_folder: Path
96
+
97
+ def render_docx(template_path: Path, context: dict, output_path: Path, replace_references=True) -> Path:
98
+ temp_folder = Path(tempfile.mkdtemp(prefix="laudo_"))
99
+ renv = RenderEnv(
100
+ tpl=DocxTemplate(str(template_path)),
101
+ jinja_env=Environment(),
102
+ temp_folder=temp_folder,
103
+ assets_folder=template_path.parent / "assets",
104
+ )
105
+ try:
106
+ register_filters(renv)
107
+ register_globals(renv)
108
+ renv.tpl.render(context, jinja_env=renv.jinja_env)
109
+ if replace_references:
110
+ dr = DocxReferenceReplacer()
111
+ dr.replace_in_doc(renv.tpl.docx)
112
+ renv.tpl.save(str(output_path))
113
+ return output_path
114
+ finally:
115
+ shutil.rmtree(temp_folder, ignore_errors=True)
116
+
117
+
118
+ def run(folder: Path, output: Path, *, debug: bool = False) -> Path:
119
+ template_path = folder / "template.docx"
120
+ print(template_path)
121
+ if not template_path.is_file():
122
+ raise FileNotFoundError(f"template.docx not found in project folder or in package templates")
123
+
124
+
125
+ context = _build_context(folder)
126
+ if debug:
127
+ import pprint
128
+ print("--- context ---")
129
+ pprint.pprint(context, indent=2, sort_dicts=False)
130
+ print("---------------")
131
+
132
+ if output.suffix == ".pdf":
133
+ docx_path = output.with_suffix(".docx")
134
+ render_docx(template_path, context, docx_path)
135
+ from .pdf import convert_to_pdf
136
+
137
+ result = convert_to_pdf(docx_path, output)
138
+ # docx_path.unlink()
139
+ return result
140
+
141
+ return render_docx(template_path, context, output)
laudo/exif.py ADDED
@@ -0,0 +1,58 @@
1
+ from pathlib import Path
2
+
3
+ import piexif # type: ignore[import-untyped]
4
+ from PIL import Image
5
+
6
+
7
+ _IMAGE_DESC_TAG = piexif.ImageIFD.ImageDescription
8
+
9
+
10
+ def _sanitize(exif_dict: dict) -> dict:
11
+ """Convert Undefined-type tuples back to bytes for piexif.dump."""
12
+ for ifd_name in ("0th", "Exif", "GPS", "1st"):
13
+ ifd = exif_dict.get(ifd_name, {})
14
+ info = piexif.TAGS.get(ifd_name, {})
15
+ for tag, value in ifd.items():
16
+ if isinstance(value, tuple) and info.get(tag, {}).get("type") == 7:
17
+ ifd[tag] = bytes(value)
18
+ return exif_dict
19
+
20
+
21
+ def get_caption(path: Path) -> str:
22
+ """Read caption from the EXIF ImageDescription tag."""
23
+ try:
24
+ exif_dict = piexif.load(str(path))
25
+ raw = exif_dict.get("0th", {}).get(_IMAGE_DESC_TAG)
26
+ if raw is None:
27
+ return ""
28
+ if isinstance(raw, bytes):
29
+ return raw.decode("utf-8", errors="replace")
30
+ return str(raw)
31
+ except Exception:
32
+ return ""
33
+
34
+
35
+ def set_caption(path: Path, caption: str) -> None:
36
+ """Write caption to the EXIF ImageDescription tag (in-place)."""
37
+ exif_dict = piexif.load(str(path))
38
+ exif_dict["0th"][_IMAGE_DESC_TAG] = caption.encode("utf-8")
39
+ exif_bytes = piexif.dump(_sanitize(exif_dict))
40
+ _save_exif(path, exif_bytes)
41
+
42
+
43
+ def clear_caption(path: Path) -> None:
44
+ """Remove the EXIF ImageDescription tag."""
45
+ exif_dict = piexif.load(str(path))
46
+ exif_dict["0th"].pop(_IMAGE_DESC_TAG, None)
47
+ exif_bytes = piexif.dump(_sanitize(exif_dict))
48
+ _save_exif(path, exif_bytes)
49
+
50
+
51
+ def has_caption(path: Path) -> bool:
52
+ """Check if the image has a non-empty caption."""
53
+ return bool(get_caption(path))
54
+
55
+
56
+ def _save_exif(path: Path, exif_bytes: bytes) -> None:
57
+ with Image.open(path) as img:
58
+ img.save(path, exif=exif_bytes)
@@ -0,0 +1,17 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from .filters import Filters
4
+ from laudo.filters.markdown_filter import MarkdownFilter
5
+ if TYPE_CHECKING:
6
+ from laudo.core import RenderEnv
7
+
8
+
9
+ def register(rend: 'RenderEnv') -> None:
10
+ rend.jinja_env.filters["markdown"] = MarkdownFilter(rend)
11
+ fs = Filters()
12
+ for name in dir(fs):
13
+ if not name.startswith("_"):
14
+ method = getattr(fs, name)
15
+ if callable(method):
16
+ rend.jinja_env.filters[name] = method
17
+
@@ -0,0 +1,172 @@
1
+ from datetime import date, datetime
2
+ from .helpers_filters import *
3
+ import math
4
+ from pathlib import Path
5
+
6
+
7
+ nome_meses = [
8
+ 'janeiro',
9
+ 'fevereiro',
10
+ 'março',
11
+ 'abril',
12
+ 'maio',
13
+ 'junho',
14
+ 'julho',
15
+ 'agosto',
16
+ 'setembro',
17
+ 'outubro',
18
+ 'novembro',
19
+ 'dezembro'
20
+ ]
21
+
22
+
23
+ class Filters:
24
+
25
+ def rgano(self, value: str) -> dict:
26
+ try:
27
+ parts = value.split("/")
28
+ return {"rg": int(parts[0]), "ano": int(parts[1])}
29
+ except:
30
+ raise Exception(f"Valor {value} não segue o formato rg/ano")
31
+
32
+ def perano(self, value: str) -> int:
33
+ return self.rgano(value)['ano']
34
+
35
+ def perrg(self, value: str) -> int:
36
+ return self.rgano(value)['rg']
37
+
38
+ def br(self, value, loop_):
39
+ return value if loop_.index == 1 else f"<w:br/>{value}"
40
+
41
+ def not_null(self, value):
42
+ if value is None:
43
+ return ""
44
+ return value
45
+
46
+ def xxx(self, value):
47
+ if value is None:
48
+ return "XXX"
49
+ return value
50
+
51
+ def data_completa(self, value):
52
+ if not isinstance(value, datetime) and not isinstance(value, date):
53
+ return ""
54
+ dia = str(value.day).rjust(2, "0")
55
+ dia_extenso = get_extenso(value.day)
56
+ return f"{dia} ({dia_extenso}) dias do mês de {nome_meses[value.month - 1]} do ano de {value.year} ({get_extenso(value.year)})"
57
+
58
+ def data_mes_extenso(self, value):
59
+ if not isinstance(value, datetime) and not isinstance(value, date):
60
+ return ""
61
+ value = convert_datetime(value)
62
+ dia = str(value.day) if value.day > 1 else f"{value.day}°"
63
+ return f"{dia} de {nome_meses[value.month - 1]} de {value.year}"
64
+
65
+ def hora_minuto(self, value):
66
+ if not isinstance(value, datetime):
67
+ return "XXX"
68
+ value = convert_datetime(value)
69
+ hora = str(value.hour).rjust(2, "0")
70
+ minuto = str(value.minute).rjust(2, "0")
71
+ return f"{hora}:{minuto}"
72
+
73
+ def dia(self, value):
74
+ if not isinstance(value, datetime):
75
+ return "XXX"
76
+ value = convert_datetime(value)
77
+ return str(value.day).rjust(2, "0")
78
+
79
+ def dia_extenso(self, value):
80
+ if not isinstance(value, datetime):
81
+ return "XXX"
82
+ value = convert_datetime(value)
83
+ dia = str(value.day).rjust(2, "0")
84
+ dia_extenso = get_extenso(value.day)
85
+ return f"{dia} ({dia_extenso})"
86
+
87
+ def numero_extenso_masc(self, value):
88
+ return get_extenso(value)
89
+
90
+ def numero_extenso_fem(self, value):
91
+ return get_extenso(value, feminino=True)
92
+
93
+ def mes_extenso(self, value):
94
+ if not isinstance(value, datetime):
95
+ return "XXX"
96
+ return nome_meses[value.month-1]
97
+
98
+ def data_simples(self, value):
99
+ if not value:
100
+ return ""
101
+ value = convert_datetime(value)
102
+ if isinstance(value, datetime):
103
+ return value.strftime("%d/%m/%Y")
104
+ try:
105
+ data = datetime.strptime(value, "%d/%m/%Y")
106
+ return data.strftime("%d/%m/%Y")
107
+ except Exception as e:
108
+ print(e)
109
+ pass
110
+
111
+ def is_male(self, value):
112
+ if isinstance(value, str):
113
+ firstname = value.split()[0]
114
+ return firstname.endswith('o')
115
+ return False
116
+
117
+ def get_extenso(self, value, feminino=False):
118
+ return get_extenso(value, feminino=feminino)
119
+
120
+ def datetime(self, value):
121
+ try:
122
+ return datetime.strptime(value, "%d/%m/%Y")
123
+ except:
124
+ pass
125
+
126
+ def join_enumerate(self, values, with_quotes=False):
127
+ n = len(values)
128
+ if n == 0 :
129
+ return ""
130
+ if n == 1:
131
+ return str(values[0])
132
+ if with_quotes:
133
+ values = [f"\"{v}\"" for v in values]
134
+ else:
135
+ values = [str(v) for v in values]
136
+ parts1 = values[:-1]
137
+ text1 = ", ".join(parts1)
138
+ return f"{text1} e {values[-1]}"
139
+
140
+ def moeda_extenso(self, value, prefix="R$ "):
141
+ value_str = f"{value:.2f}".replace(".", ",")
142
+ reais = math.floor(value)
143
+ centavos = int((value%reais + 0.0000000001)*100) if reais > 0 else value*100
144
+ reais_text = get_extenso(reais)
145
+ aux1 = "reais" if reais > 1 else "real"
146
+ aux2 = "centavos" if centavos > 1 else "centavo"
147
+ centavos_text = get_extenso(centavos)
148
+
149
+ middle_text = f"{reais_text} {aux1}"
150
+ if centavos > 0:
151
+ middle_text += f" e {centavos_text} {aux2}"
152
+ text = f"{prefix}{value_str} ({middle_text})"
153
+ return text
154
+
155
+ def file_stem(self, value):
156
+ try:
157
+ path = Path(value)
158
+ return path.stem
159
+ except:
160
+ return ""
161
+
162
+
163
+ def str(self, value):
164
+ return str(value)
165
+
166
+ def bookmark(self, value, anyvalue):
167
+ return value
168
+
169
+ def ornow(self, value):
170
+ if not value:
171
+ return datetime.now()
172
+ return value
@@ -0,0 +1,166 @@
1
+ from datetime import datetime
2
+ from dateutil import parser
3
+
4
+ class NumeroExtenso():
5
+ trioextenso = ()
6
+ classextenso = ()
7
+
8
+ def __init__(self):
9
+ self.trioextenso_masculino = (
10
+ ("dummy", "um", "dois", "três", "quatro", "cinco", "seis", "sete",
11
+ "oito", "nove"),
12
+ ("dez", "onze", "doze", "treze", "quatorze", "quinze", "dezesseis",
13
+ "dezessete", "dezoito", "dezenove"),
14
+ ("dummy", "dummy", "vinte", "trinta", "quarenta", "cinquenta",
15
+ "sessenta", "setenta", "oitenta", "noventa"),
16
+ ("dummy", "cento", "duzentos", "trezentos", "quatrocentos",
17
+ "quinhentos", "seiscentos", "setecentos", "oitocentos",
18
+ "novecentos"))
19
+ self.trioextenso_feminino = (
20
+ ("dummy", "uma", "duas", "três", "quatro", "cinco", "seis", "sete",
21
+ "oito", "nove"),
22
+ ("dez", "onze", "doze", "treze", "quatorze", "quinze", "dezesseis",
23
+ "dezessete", "dezoito", "dezenove"),
24
+ ("dummy", "dummy", "vinte", "trinta", "quarenta", "cinquenta",
25
+ "sessenta", "setenta", "oitenta", "noventa"),
26
+ ("dummy", "cento", "duzentas", "trezentas", "quatrocentas",
27
+ "quinhentas", "seiscentas", "setecentas", "oitocentas",
28
+ "novecentas"))
29
+ self.classextenso = (
30
+ "dummy", "mil", "milh", "bilh", "trilh", "quatrilh",
31
+ "quintilh", "sextilh", "septilh", "octilh",
32
+ "nonilh", "decilh", "undecilh", "duodecilh",
33
+ "tredecilh", "quatordecilh", "quindecilh",
34
+ "sexdecilh", "setedecilh", "octodecilh",
35
+ "novedecilh", "vigesilh")
36
+ self.trioextenso = self.trioextenso_masculino
37
+
38
+
39
+ def escrever_trio_extenso(self, trio):
40
+ """
41
+ Retorna um trio por extenso.
42
+
43
+ Entrada: trio na forma de string.
44
+
45
+ Retorno: trio em extenso.
46
+ """
47
+ saida = []
48
+
49
+ if trio == '100':
50
+ # Erro antigo aqui. Consertado usando "return"
51
+ return 'cem'
52
+ elif trio == '000':
53
+ return 'zero'
54
+ else:
55
+ c, d, u = trio
56
+ c, d, u = int(c), int(d), int(u)
57
+
58
+ if c != 0:
59
+ saida.append(self.trioextenso[3][c])
60
+ if d == 1:
61
+ saida.append(self.trioextenso[1][u])
62
+ else:
63
+ if d != 0:
64
+ saida.append(self.trioextenso[2][d])
65
+ if u != 0:
66
+ saida.append(self.trioextenso[0][u])
67
+ return ' e '.join(saida)
68
+
69
+ def nao_e_ultimo_trio(self, totalTrios, contador):
70
+ """
71
+ Retorna verdadeiro se o trio indicado pelo contador
72
+ não é o último (isso é, não é o mais à direita).
73
+
74
+ Entrada: Número total de trios e o contador.
75
+
76
+ Retorno: Verdadeiro se o trio NÃO é o último e
77
+ falso caso contrário.
78
+ """
79
+ return contador < (totalTrios - 1)
80
+
81
+ def trio_a_esquerda_eq_zero(self, trioLista, contador):
82
+ """
83
+ Retorna verdadeiro se o trio à esquerda do trio
84
+ indicado pelo contador é igual a zero.
85
+
86
+ Entrada: Os trios em forma de Lista e o contador.
87
+
88
+ Retorno: Verdadeiro se o trio à esquerda do contador
89
+ for zero e falso caso contrário.
90
+ """
91
+
92
+ # Contador igual a zero indexa o elemento mais à direita,
93
+ # por isso devemos acrescentar tamanho da lista.
94
+ t = len(trioLista)-1
95
+ return trioLista[t-contador-1] == '000'
96
+
97
+ def getExtenso(self, num, quebradelinhas=0, feminino=False):
98
+ """
99
+ Algoritmo principal. Recebe um número na forma de
100
+ string e retorna sua escrita em extenso.
101
+
102
+ Entrada: Número na forma de string e uma flag que, se
103
+ tiver o valor 0 (zero), o extenso é retornado em uma
104
+ só linha. Um valor 1 (um) faz o extenso ser quebrado
105
+ em várias linhas.
106
+
107
+ Retorno: O número de entrada em extenso na forma de
108
+ string.
109
+ """
110
+ self.trioextenso = self.trioextenso_feminino if feminino else self.trioextenso_masculino
111
+ # Remove os zeros iniciais e faz padding
112
+ # para números com quantidade de algarismos
113
+ # não múltipla de 3
114
+ num = num.lstrip('0')
115
+ pad = 3 - len(num) % 3
116
+ if pad < 3:
117
+ num = '0'*pad + num
118
+
119
+ it = iter(num)
120
+ trioLista = [''.join([a, b, c]) for a, b, c in zip(it, it, it)]
121
+
122
+ if len(trioLista) > len(self.classextenso):
123
+ raise IndexError('Número muito grande')
124
+
125
+ contador = 0
126
+ saida = ''
127
+ extensofinal = ''
128
+
129
+ for trio in reversed(trioLista):
130
+ trioInt = int(trio)
131
+
132
+ if trioInt > 0:
133
+ saida = self.escrever_trio_extenso(trio)
134
+ if contador > 0:
135
+ saida = saida + ' ' + self.classextenso[contador]
136
+ if contador > 1:
137
+ if trioInt > 1:
138
+ saida = saida + 'ões'
139
+ else:
140
+ saida = saida + 'ão'
141
+ if quebradelinhas == 0:
142
+ if self.nao_e_ultimo_trio(len(trioLista), contador):
143
+ if self.trio_a_esquerda_eq_zero(trioLista, contador):
144
+ saida = ' e ' + saida
145
+ elif trioInt >= 100:
146
+ saida = ', ' + saida
147
+ else:
148
+ saida = ' e ' + saida
149
+ else:
150
+ saida = saida + '\n'
151
+
152
+ extensofinal = saida + extensofinal
153
+ contador = contador + 1
154
+ return extensofinal.rstrip('\n')
155
+
156
+
157
+ def get_extenso(num, feminino=False):
158
+ if not isinstance(num, str):
159
+ num = str(num)
160
+ n = NumeroExtenso()
161
+ return n.getExtenso(num, quebradelinhas=0, feminino=feminino)
162
+
163
+ def convert_datetime(value: str | datetime) -> datetime:
164
+ if isinstance(value, str):
165
+ return parser.parse(value)
166
+ return value