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 +7 -0
- laudo/__main__.py +113 -0
- laudo/assets.py +14 -0
- laudo/core.py +141 -0
- laudo/exif.py +58 -0
- laudo/filters/__init__.py +17 -0
- laudo/filters/filters.py +172 -0
- laudo/filters/helpers_filters.py +166 -0
- laudo/filters/markdown_filter.py +114 -0
- laudo/globals/__init__.py +17 -0
- laudo/globals/global_functions.py +98 -0
- laudo/globals/inline_image.py +40 -0
- laudo/globals/subdoc_func.py +13 -0
- laudo/gui/__init__.py +3 -0
- laudo/gui/caption_editor.py +140 -0
- laudo/images.py +105 -0
- laudo/pdf.py +42 -0
- laudo/pre_process.py +0 -0
- laudo/py.typed +0 -0
- laudo/reference_replacer.py +104 -0
- laudo-0.1.0.dist-info/METADATA +150 -0
- laudo-0.1.0.dist-info/RECORD +24 -0
- laudo-0.1.0.dist-info/WHEEL +4 -0
- laudo-0.1.0.dist-info/entry_points.txt +3 -0
laudo/__init__.py
ADDED
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
|
+
|
laudo/filters/filters.py
ADDED
|
@@ -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
|