carbotek 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.
- carbotek/__init__.py +32 -0
- carbotek/__main__.py +6 -0
- carbotek/_native.pyi +14 -0
- carbotek/_native_adapter.py +100 -0
- carbotek/api.py +120 -0
- carbotek/cache.py +17 -0
- carbotek/cli.py +195 -0
- carbotek/docx/__init__.py +2 -0
- carbotek/docx/blocks.py +90 -0
- carbotek/docx/dialect.py +73 -0
- carbotek/docx/directives.py +527 -0
- carbotek/docx/loop_syntax.py +8 -0
- carbotek/docx/loops.py +912 -0
- carbotek/docx/normalize.py +315 -0
- carbotek/docx/plan.py +102 -0
- carbotek/docx/renderer.py +1897 -0
- carbotek/docx/session.py +131 -0
- carbotek/docx/set_markers.py +92 -0
- carbotek/docx/strings.py +678 -0
- carbotek/docx/text.py +62 -0
- carbotek/errors.py +62 -0
- carbotek/features/__init__.py +18 -0
- carbotek/features/_barcode_writer.py +72 -0
- carbotek/features/barcode.py +312 -0
- carbotek/features/color.py +397 -0
- carbotek/features/fetch.py +242 -0
- carbotek/features/html.py +1467 -0
- carbotek/features/hyperlinks.py +179 -0
- carbotek/features/image.py +674 -0
- carbotek/features/native_chart.py +844 -0
- carbotek/features/template_options.py +32 -0
- carbotek/formatters/__init__.py +49 -0
- carbotek/formatters/aggregator.py +350 -0
- carbotek/formatters/array.py +93 -0
- carbotek/formatters/barcode.py +179 -0
- carbotek/formatters/chart.py +45 -0
- carbotek/formatters/color.py +103 -0
- carbotek/formatters/condition.py +222 -0
- carbotek/formatters/currencies.json +1482 -0
- carbotek/formatters/date.py +507 -0
- carbotek/formatters/dayjs_date_locale.json.gz +0 -0
- carbotek/formatters/dayjs_relative_time.json.gz +0 -0
- carbotek/formatters/docx.py +193 -0
- carbotek/formatters/jsnumber.py +205 -0
- carbotek/formatters/locale_currency.json +1224 -0
- carbotek/formatters/locale_numbers.json +767 -0
- carbotek/formatters/number.py +364 -0
- carbotek/formatters/optional.py +23 -0
- carbotek/formatters/region_names.json +20664 -0
- carbotek/formatters/registration_order.json +1 -0
- carbotek/formatters/registry.py +186 -0
- carbotek/formatters/ru.py +454 -0
- carbotek/formatters/set.py +130 -0
- carbotek/formatters/string.py +305 -0
- carbotek/formatters/timezone_names.json +1 -0
- carbotek/formatters/various.py +61 -0
- carbotek/markers/__init__.py +20 -0
- carbotek/markers/math.py +89 -0
- carbotek/markers/scan.py +37 -0
- carbotek/markers/syntax.py +255 -0
- carbotek/markers/variables.py +134 -0
- carbotek/ooxml/__init__.py +2 -0
- carbotek/ooxml/fragments.py +141 -0
- carbotek/ooxml/image_types.py +39 -0
- carbotek/ooxml/ns.py +28 -0
- carbotek/ooxml/template_options.py +34 -0
- carbotek/ooxml/xml.py +52 -0
- carbotek/opc/__init__.py +7 -0
- carbotek/opc/cache.py +74 -0
- carbotek/opc/repository.py +117 -0
- carbotek/options.py +95 -0
- carbotek/package.py +122 -0
- carbotek/pdf/__init__.py +6 -0
- carbotek/pdf/convert.py +97 -0
- carbotek/pipeline/__init__.py +2 -0
- carbotek/pipeline/compile.py +256 -0
- carbotek/pipeline/document.py +188 -0
- carbotek/pipeline/ports.py +50 -0
- carbotek/py.typed +0 -0
- carbotek/render_state.py +59 -0
- carbotek/typing_helpers.py +25 -0
- carbotek/util.py +52 -0
- carbotek/xlsx/__init__.py +6 -0
- carbotek/xlsx/images.py +260 -0
- carbotek/xlsx/prepare.py +313 -0
- carbotek-0.1.0.dist-info/METADATA +318 -0
- carbotek-0.1.0.dist-info/RECORD +96 -0
- carbotek-0.1.0.dist-info/WHEEL +5 -0
- carbotek-0.1.0.dist-info/entry_points.txt +2 -0
- carbotek-0.1.0.dist-info/licenses/LICENSE +75 -0
- carbotek-0.1.0.dist-info/licenses/NOTICE +13 -0
- carbotek-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +228 -0
- carbotek-0.1.0.dist-info/licenses/licenses/Carbone-Apache-2.0.txt +201 -0
- carbotek-0.1.0.dist-info/licenses/licenses/Unicode-3.0.txt +39 -0
- carbotek-0.1.0.dist-info/licenses/licenses/dayjs-MIT.txt +21 -0
- carbotek-0.1.0.dist-info/top_level.txt +1 -0
carbotek/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# pyright: strict, reportPrivateUsage=false
|
|
2
|
+
"""carbotek: DOCX and XLSX templates with Carbone-style markers rendered to DOCX, XLSX or PDF (dxpdf).
|
|
3
|
+
|
|
4
|
+
import carbotek
|
|
5
|
+
docx = carbotek.render(template_bytes, {"name": "Ann"}, {"lang": "ru"})
|
|
6
|
+
pdf = carbotek.render_pdf(template_bytes, data)
|
|
7
|
+
engine = carbotek.Engine(cache_size=16) # own compile cache, e.g. for benchmarks
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
11
|
+
|
|
12
|
+
from .api import Engine, default_engine, render, render_pdf
|
|
13
|
+
from .errors import CarbotekError
|
|
14
|
+
from .options import RenderOptions
|
|
15
|
+
from .pdf import convert_docx_to_pdf
|
|
16
|
+
|
|
17
|
+
# The single source is native/Cargo.toml; the release workflow sets it from the git tag.
|
|
18
|
+
try:
|
|
19
|
+
__version__ = version("carbotek")
|
|
20
|
+
except PackageNotFoundError:
|
|
21
|
+
__version__ = "0+unknown"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"CarbotekError",
|
|
25
|
+
"Engine",
|
|
26
|
+
"RenderOptions",
|
|
27
|
+
"__version__",
|
|
28
|
+
"convert_docx_to_pdf",
|
|
29
|
+
"default_engine",
|
|
30
|
+
"render",
|
|
31
|
+
"render_pdf",
|
|
32
|
+
]
|
carbotek/__main__.py
ADDED
carbotek/_native.pyi
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from typing import Any, Final
|
|
2
|
+
|
|
3
|
+
API_VERSION: Final[int]
|
|
4
|
+
PACKAGE_VERSION: Final[str]
|
|
5
|
+
OPERATIONS: Final[dict[str, int]]
|
|
6
|
+
|
|
7
|
+
class PlainSection:
|
|
8
|
+
def __init__(self, literals: list[str], slots: list[tuple[int, list[tuple[int, str, str, str, str]]]]) -> None: ...
|
|
9
|
+
def render(self, rows: object) -> str | None: ...
|
|
10
|
+
def render_bytes(self, rows: object) -> bytes | None: ...
|
|
11
|
+
|
|
12
|
+
def digit_currency_cents(value: Any) -> str | None: ...
|
|
13
|
+
def join_utf8(parts: list[str | bytes]) -> bytes: ...
|
|
14
|
+
def decode_data_uri(value: Any) -> bytes | None: ...
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# pyright: strict
|
|
2
|
+
"""Optional compiled section backend; the public rendering API stays in Python.
|
|
3
|
+
|
|
4
|
+
``CARBOTEK_NATIVE`` is read once, when carbotek is imported: ``0``/``false``/``off``/``no`` keeps
|
|
5
|
+
every render on the Python path, ``1``/``true``/``on``/``yes`` turns a missing extension into an
|
|
6
|
+
import error, and any other value (the default) uses the extension when it is installed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
13
|
+
from typing import Any, Protocol
|
|
14
|
+
|
|
15
|
+
# Operation codes of a native section slot part, shared with native/src/lib.rs (OPERATIONS).
|
|
16
|
+
OP_LITERAL, OP_FIELD, OP_ISO_DATE, OP_RU_NUMBER, OP_IF_EQUAL, OP_CENTS = range(6)
|
|
17
|
+
OPERATIONS = {
|
|
18
|
+
"literal": OP_LITERAL,
|
|
19
|
+
"field": OP_FIELD,
|
|
20
|
+
"iso_date": OP_ISO_DATE,
|
|
21
|
+
"ru_number": OP_RU_NUMBER,
|
|
22
|
+
"if_equal": OP_IF_EQUAL,
|
|
23
|
+
"cents": OP_CENTS,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type SlotPart = tuple[int, str, str, str, str]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PlainSection(Protocol):
|
|
30
|
+
def render(self, rows: list[object]) -> str | None: ...
|
|
31
|
+
def render_bytes(self, rows: list[object]) -> bytes | None: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Native(Protocol):
|
|
35
|
+
"""The functions of ``carbotek._native`` (see ``_native.pyi``)."""
|
|
36
|
+
|
|
37
|
+
def PlainSection(self, literals: list[str], slots: list[tuple[int, list[SlotPart]]]) -> PlainSection: ...
|
|
38
|
+
def digit_currency_cents(self, value: Any) -> str | None: ...
|
|
39
|
+
def join_utf8(self, parts: list[str | bytes]) -> bytes: ...
|
|
40
|
+
def decode_data_uri(self, value: Any) -> bytes | None: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load(setting: str) -> _Native | None:
|
|
44
|
+
if setting in {"0", "false", "off", "no"}:
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
from carbotek import _native # pyright: ignore[reportMissingModuleSource] - optional extension
|
|
48
|
+
except ImportError:
|
|
49
|
+
if setting in {"1", "true", "on", "yes"}:
|
|
50
|
+
raise
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
installed_version = version("carbotek")
|
|
54
|
+
except PackageNotFoundError:
|
|
55
|
+
installed_version = None
|
|
56
|
+
native_version = getattr(_native, "PACKAGE_VERSION", None)
|
|
57
|
+
compatible = getattr(_native, "API_VERSION", None) == 2 and getattr(_native, "OPERATIONS", None) == OPERATIONS
|
|
58
|
+
if installed_version is not None:
|
|
59
|
+
normalized = re.sub(
|
|
60
|
+
r"-(alpha|beta|rc)\.?(\d+)$",
|
|
61
|
+
lambda match: {"alpha": "a", "beta": "b", "rc": "rc"}[match[1]] + match[2],
|
|
62
|
+
native_version or "",
|
|
63
|
+
)
|
|
64
|
+
compatible = compatible and normalized == installed_version
|
|
65
|
+
if not compatible:
|
|
66
|
+
if setting in {"1", "true", "on", "yes"}:
|
|
67
|
+
raise ImportError(
|
|
68
|
+
"carbotek._native is incompatible with this Python adapter; reinstall or rebuild carbotek"
|
|
69
|
+
)
|
|
70
|
+
return None
|
|
71
|
+
return _native
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Tests switch backends by replacing this attribute (monkeypatch), not the environment.
|
|
75
|
+
_native: _Native | None = _load(os.environ.get("CARBOTEK_NATIVE", "").strip().lower())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def native_available() -> bool:
|
|
79
|
+
return _native is not None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def compile_plain_section(literals: list[str], slots: list[tuple[int, list[SlotPart]]]) -> PlainSection | None:
|
|
83
|
+
native = _native
|
|
84
|
+
return native.PlainSection(literals, slots) if native is not None else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def native_digit_currency_cents(value: Any) -> str | None:
|
|
88
|
+
native = _native
|
|
89
|
+
return native.digit_currency_cents(value) if native is not None else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def native_utf8_joiner() -> Callable[[list[str | bytes]], bytes] | None:
|
|
93
|
+
"""``join_utf8``, chosen before any section field is evaluated; None for the Python path."""
|
|
94
|
+
native = _native
|
|
95
|
+
return native.join_utf8 if native is not None else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def native_decode_data_uri(value: Any) -> bytes | None:
|
|
99
|
+
native = _native
|
|
100
|
+
return native.decode_data_uri(value) if native is not None else None
|
carbotek/api.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# pyright: strict, reportPrivateUsage=false
|
|
2
|
+
"""Public synchronous API: module-level helpers over a shared :class:`Engine`."""
|
|
3
|
+
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
from typing import Any
|
|
8
|
+
from zipfile import BadZipFile, ZipFile
|
|
9
|
+
|
|
10
|
+
from .docx.renderer import DocxTextRenderer
|
|
11
|
+
from .errors import FeatureUnavailable, ValidationError
|
|
12
|
+
from .features.fetch import ImageSourceConfig
|
|
13
|
+
from .opc import OpcTemplateRepository
|
|
14
|
+
from .options import RenderOptions
|
|
15
|
+
from .pdf import convert_docx_to_pdf
|
|
16
|
+
from .pipeline.compile import CompiledTemplate, CompiledTemplateCache
|
|
17
|
+
from .pipeline.document import RenderDocument
|
|
18
|
+
from .typing_helpers import is_list, is_mapping
|
|
19
|
+
|
|
20
|
+
type Data = Mapping[str, Any] | list[Any]
|
|
21
|
+
type Options = RenderOptions | Mapping[str, Any] | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Engine:
|
|
25
|
+
"""A renderer with its own template caches; reuse one instance to benefit from the compile cache.
|
|
26
|
+
|
|
27
|
+
``cache_size``/``cache_bytes`` bound the compiled-template LRU (0 disables caching);
|
|
28
|
+
``image_sources`` configures remote/local image loading (default: data URIs only).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
cache_size: int = 64,
|
|
35
|
+
cache_bytes: int = 256 * 1024 * 1024,
|
|
36
|
+
image_sources: ImageSourceConfig | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
if image_sources is not None and not isinstance(image_sources, ImageSourceConfig): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
39
|
+
raise ValidationError("image_sources must be ImageSourceConfig or None")
|
|
40
|
+
self._document = RenderDocument(
|
|
41
|
+
OpcTemplateRepository(),
|
|
42
|
+
DocxTextRenderer(),
|
|
43
|
+
image_sources,
|
|
44
|
+
CompiledTemplateCache(capacity=cache_size, max_bytes=cache_bytes),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def compile(self, template: bytes, options: Options = None) -> CompiledTemplate:
|
|
48
|
+
"""Validate and prepare a template using the same compile cache as render()."""
|
|
49
|
+
if not isinstance(template, bytes): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
50
|
+
raise ValidationError("template must be bytes")
|
|
51
|
+
return self._document.compile(template, _options(options))
|
|
52
|
+
|
|
53
|
+
def render(self, template: bytes, data: Data, options: Options = None) -> bytes:
|
|
54
|
+
"""Render a DOCX template into a DOCX document, an XLSX template into an XLSX workbook."""
|
|
55
|
+
return self._execute(template, data, options, rasterize_svg=False, image_dpi=220)
|
|
56
|
+
|
|
57
|
+
def render_pdf(self, template: bytes, data: Data, options: Options = None, *, image_dpi: int = 220) -> bytes:
|
|
58
|
+
"""Render a DOCX template and convert the result to PDF with dxpdf (XLSX has no PDF output)."""
|
|
59
|
+
if type(image_dpi) is not int or not 1 <= image_dpi <= 2400:
|
|
60
|
+
raise ValidationError("image_dpi must be an integer between 1 and 2400")
|
|
61
|
+
if not isinstance(template, bytes): # pyright: ignore[reportUnnecessaryIsInstance] - public API guard
|
|
62
|
+
raise ValidationError("template must be bytes")
|
|
63
|
+
if _is_workbook(template):
|
|
64
|
+
raise FeatureUnavailable("PDF output is only available for DOCX templates (XLSX needs LibreOffice)")
|
|
65
|
+
document = self._execute(template, data, options, rasterize_svg=True, image_dpi=image_dpi)
|
|
66
|
+
return convert_docx_to_pdf(document, image_dpi=image_dpi)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def metrics(self) -> dict[str, int | float]:
|
|
70
|
+
"""Compile cache metrics: size, bytes, hits, misses, hit_rate."""
|
|
71
|
+
return self._document.metrics
|
|
72
|
+
|
|
73
|
+
def _execute(self, template: bytes, data: Data, options: Options, *, rasterize_svg: bool, image_dpi: int) -> bytes:
|
|
74
|
+
if not isinstance(template, bytes): # pyright: ignore[reportUnnecessaryIsInstance] - public API guard
|
|
75
|
+
raise ValidationError("template must be bytes")
|
|
76
|
+
if not isinstance(data, Mapping | list) or ( # pyright: ignore[reportUnnecessaryIsInstance] - public API guard
|
|
77
|
+
is_list(data) and any(not is_mapping(item) for item in data)
|
|
78
|
+
):
|
|
79
|
+
raise ValidationError("data must be a mapping or list of mappings")
|
|
80
|
+
return self._document.execute(
|
|
81
|
+
template, data, _options(options), rasterize_svg=rasterize_svg, image_dpi=image_dpi
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@lru_cache(maxsize=1)
|
|
86
|
+
def default_engine() -> Engine:
|
|
87
|
+
"""The process-wide engine behind :func:`render` and :func:`render_pdf`."""
|
|
88
|
+
return Engine()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def render(template: bytes, data: Data, options: Options = None) -> bytes:
|
|
92
|
+
"""Render a DOCX template into a DOCX document, an XLSX template into an XLSX workbook."""
|
|
93
|
+
return default_engine().render(template, data, options)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def render_pdf(template: bytes, data: Data, options: Options = None, *, image_dpi: int = 220) -> bytes:
|
|
97
|
+
"""Render a DOCX template and convert the resulting package to PDF."""
|
|
98
|
+
return default_engine().render_pdf(template, data, options, image_dpi=image_dpi)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _is_workbook(template: bytes) -> bool:
|
|
102
|
+
"""Whether the template is an XLSX package (checked before any rendering work)."""
|
|
103
|
+
try:
|
|
104
|
+
with ZipFile(BytesIO(template)) as archive:
|
|
105
|
+
names = archive.namelist()
|
|
106
|
+
except BadZipFile, ValueError:
|
|
107
|
+
return False # rejected with the engine's own error by the render
|
|
108
|
+
return "word/document.xml" not in names and any(name.startswith("xl/") for name in names)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _options(options: Options) -> RenderOptions:
|
|
112
|
+
if options is None:
|
|
113
|
+
return RenderOptions.create()
|
|
114
|
+
if isinstance(options, RenderOptions):
|
|
115
|
+
return options
|
|
116
|
+
if not is_mapping(options):
|
|
117
|
+
raise ValidationError("options must be a mapping or RenderOptions")
|
|
118
|
+
if any(not isinstance(key, str) for key in options): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
119
|
+
raise ValidationError("option names must be strings")
|
|
120
|
+
return RenderOptions.create(**options)
|
carbotek/cache.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# pyright: strict
|
|
2
|
+
"""Explicit process-cache reset for cold benchmark runs."""
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def clear_module_caches() -> None:
|
|
8
|
+
"""Clear every loaded Carbotek functools cache, including the shared formatter registry."""
|
|
9
|
+
cleared: set[int] = set()
|
|
10
|
+
for name, module in tuple(sys.modules.items()):
|
|
11
|
+
if module is None or not name.startswith("carbotek."): # pyright: ignore[reportUnnecessaryComparison]
|
|
12
|
+
continue
|
|
13
|
+
for value in tuple(vars(module).values()):
|
|
14
|
+
clear = getattr(value, "cache_clear", None)
|
|
15
|
+
if callable(clear) and id(value) not in cleared:
|
|
16
|
+
clear()
|
|
17
|
+
cleared.add(id(value))
|
carbotek/cli.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# pyright: strict, reportPrivateUsage=false
|
|
2
|
+
"""Command line interface.
|
|
3
|
+
|
|
4
|
+
carbotek render TEMPLATE DATA [-o OUT] [--pdf] [--lang ru] [--timezone Europe/Moscow] [--options JSON]
|
|
5
|
+
carbotek bench TEMPLATE DATA [-n 20] [--warmup 1] [--pdf] [--no-cache] [--profile]
|
|
6
|
+
carbotek markers TEMPLATE
|
|
7
|
+
|
|
8
|
+
``DATA`` is a JSON file (``-`` reads stdin). ``--options`` takes :meth:`RenderOptions.create`
|
|
9
|
+
keywords as JSON (``{"complement": {...}, "currency": {"source": "EUR"}, ...}``).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import statistics
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import Callable, Sequence
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from .api import Engine
|
|
23
|
+
from .cache import clear_module_caches
|
|
24
|
+
from .errors import CarbotekError, ValidationError
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
28
|
+
parser = _parser()
|
|
29
|
+
arguments = parser.parse_args(argv)
|
|
30
|
+
try:
|
|
31
|
+
return int(arguments.handler(arguments))
|
|
32
|
+
except CarbotekError as error:
|
|
33
|
+
sys.stderr.write(f"{error.code}: {error}\n")
|
|
34
|
+
return 2
|
|
35
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
36
|
+
sys.stderr.write(f"error: {error}\n")
|
|
37
|
+
return 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parser() -> argparse.ArgumentParser:
|
|
41
|
+
parser = argparse.ArgumentParser(
|
|
42
|
+
prog="carbotek", description="Render DOCX and XLSX templates with Carbotek markers."
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument("--version", action="version", version=f"carbotek {__version__}")
|
|
45
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
46
|
+
|
|
47
|
+
render = commands.add_parser("render", help="render one document")
|
|
48
|
+
_input_arguments(render)
|
|
49
|
+
render.add_argument("-o", "--output", type=Path, help="output file (default: TEMPLATE stem + .out.docx/.xlsx/.pdf)")
|
|
50
|
+
render.set_defaults(handler=_render)
|
|
51
|
+
|
|
52
|
+
bench = commands.add_parser("bench", help="measure repeated renders of one template")
|
|
53
|
+
_input_arguments(bench)
|
|
54
|
+
bench.add_argument("-n", "--iterations", type=int, default=20, help="measured renders (default 20)")
|
|
55
|
+
bench.add_argument("--warmup", type=int, default=1, help="unmeasured renders first (default 1)")
|
|
56
|
+
bench.add_argument(
|
|
57
|
+
"--no-cache", action="store_true", help="clear module caches and create a new engine for every render"
|
|
58
|
+
)
|
|
59
|
+
bench.add_argument("--profile", action="store_true", help="print the cProfile top 25 of the measured renders")
|
|
60
|
+
bench.add_argument("--json", action="store_true", help="print the result as JSON")
|
|
61
|
+
bench.set_defaults(handler=_bench)
|
|
62
|
+
|
|
63
|
+
markers = commands.add_parser("markers", help="list the markers of a template after normalization")
|
|
64
|
+
markers.add_argument("template", type=Path)
|
|
65
|
+
markers.set_defaults(handler=_markers)
|
|
66
|
+
return parser
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _input_arguments(parser: argparse.ArgumentParser) -> None:
|
|
70
|
+
parser.add_argument("template", type=Path, help="DOCX or XLSX template")
|
|
71
|
+
parser.add_argument("data", help="JSON data file, or - for stdin")
|
|
72
|
+
parser.add_argument("--pdf", action="store_true", help="convert the result to PDF (dxpdf)")
|
|
73
|
+
parser.add_argument("--image-dpi", type=int, default=220, help="PDF image DPI (default 220)")
|
|
74
|
+
parser.add_argument("--lang", help="document language (default ru)")
|
|
75
|
+
parser.add_argument("--timezone", help="IANA time zone (default Europe/Moscow)")
|
|
76
|
+
parser.add_argument("--options", default="{}", help="RenderOptions.create keywords as JSON")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _load(arguments: argparse.Namespace) -> tuple[bytes, Any, dict[str, Any]]:
|
|
80
|
+
template = Path(arguments.template).read_bytes()
|
|
81
|
+
raw = sys.stdin.read() if arguments.data == "-" else Path(arguments.data).read_text(encoding="utf-8")
|
|
82
|
+
options: dict[str, Any] = json.loads(arguments.options)
|
|
83
|
+
if not isinstance(options, dict): # pyright: ignore[reportUnnecessaryIsInstance] - JSON may be an array
|
|
84
|
+
raise ValidationError("--options must contain a JSON object")
|
|
85
|
+
if arguments.lang:
|
|
86
|
+
options["lang"] = arguments.lang
|
|
87
|
+
if arguments.timezone:
|
|
88
|
+
options["timezone"] = arguments.timezone
|
|
89
|
+
return template, json.loads(raw), options
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _renderer(engine: Engine, arguments: argparse.Namespace) -> Callable[[bytes, Any, dict[str, Any]], bytes]:
|
|
93
|
+
if arguments.pdf:
|
|
94
|
+
return lambda template, data, options: engine.render_pdf(template, data, options, image_dpi=arguments.image_dpi)
|
|
95
|
+
return engine.render
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _render(arguments: argparse.Namespace) -> int:
|
|
99
|
+
template, data, options = _load(arguments)
|
|
100
|
+
started = time.perf_counter()
|
|
101
|
+
engine = Engine()
|
|
102
|
+
compiled = engine.compile(template, options)
|
|
103
|
+
result = _renderer(engine, arguments)(template, data, options)
|
|
104
|
+
elapsed = time.perf_counter() - started
|
|
105
|
+
suffix = "." + compiled.package.kind
|
|
106
|
+
output: Path = arguments.output or Path(arguments.template).with_suffix(
|
|
107
|
+
".out.pdf" if arguments.pdf else ".out" + suffix
|
|
108
|
+
)
|
|
109
|
+
output.write_bytes(result)
|
|
110
|
+
sys.stderr.write(f"{output} ({len(result)} bytes, {elapsed * 1000:.1f} ms)\n")
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _bench(arguments: argparse.Namespace) -> int:
|
|
115
|
+
template, data, options = _load(arguments)
|
|
116
|
+
shared = Engine()
|
|
117
|
+
|
|
118
|
+
def once() -> float:
|
|
119
|
+
if arguments.no_cache:
|
|
120
|
+
clear_module_caches()
|
|
121
|
+
started = time.perf_counter()
|
|
122
|
+
engine = Engine(cache_size=0) if arguments.no_cache else shared
|
|
123
|
+
_renderer(engine, arguments)(template, data, options)
|
|
124
|
+
return time.perf_counter() - started
|
|
125
|
+
|
|
126
|
+
cold = once()
|
|
127
|
+
for _ in range(max(arguments.warmup - 1, 0)):
|
|
128
|
+
once()
|
|
129
|
+
profiler = None
|
|
130
|
+
if arguments.profile:
|
|
131
|
+
import cProfile
|
|
132
|
+
|
|
133
|
+
profiler = cProfile.Profile()
|
|
134
|
+
profiler.enable()
|
|
135
|
+
timings = [once() for _ in range(max(arguments.iterations, 1))]
|
|
136
|
+
if profiler is not None:
|
|
137
|
+
profiler.disable()
|
|
138
|
+
result = _summary(cold, timings, None if arguments.no_cache else shared.metrics)
|
|
139
|
+
if arguments.json:
|
|
140
|
+
sys.stdout.write(json.dumps(result) + "\n")
|
|
141
|
+
else:
|
|
142
|
+
# Read the package kind after timing; metadata must not warm a measured cold run.
|
|
143
|
+
kind = "pdf" if arguments.pdf else Engine(cache_size=0).compile(template, options).package.kind
|
|
144
|
+
sys.stdout.write(
|
|
145
|
+
f"{arguments.template.name}: {kind}, n={result['iterations']}"
|
|
146
|
+
f"{', no cache' if arguments.no_cache else ''}\n"
|
|
147
|
+
f" cold {result['cold_ms']:.1f} ms | mean {result['mean_ms']:.1f} | p50 {result['p50_ms']:.1f}"
|
|
148
|
+
f" | p95 {result['p95_ms']:.1f} | min {result['min_ms']:.1f} | max {result['max_ms']:.1f} ms"
|
|
149
|
+
f" | {result['per_second']:.1f}/s\n"
|
|
150
|
+
)
|
|
151
|
+
if result.get("cache"):
|
|
152
|
+
sys.stdout.write(f" compile cache: {result['cache']}\n")
|
|
153
|
+
if profiler is not None:
|
|
154
|
+
import pstats
|
|
155
|
+
|
|
156
|
+
pstats.Stats(profiler, stream=sys.stderr).sort_stats("cumulative").print_stats(25)
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _summary(cold: float, timings: list[float], cache: dict[str, int | float] | None) -> dict[str, Any]:
|
|
161
|
+
ordered = sorted(timings)
|
|
162
|
+
mean = statistics.fmean(ordered)
|
|
163
|
+
return {
|
|
164
|
+
"iterations": len(ordered),
|
|
165
|
+
"cold_ms": cold * 1000,
|
|
166
|
+
"mean_ms": mean * 1000,
|
|
167
|
+
"p50_ms": statistics.median(ordered) * 1000,
|
|
168
|
+
"p95_ms": ordered[min(len(ordered) - 1, round(0.95 * (len(ordered) - 1)))] * 1000,
|
|
169
|
+
"min_ms": ordered[0] * 1000,
|
|
170
|
+
"max_ms": ordered[-1] * 1000,
|
|
171
|
+
"per_second": 1 / mean if mean else 0.0,
|
|
172
|
+
"cache": cache,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _markers(arguments: argparse.Namespace) -> int:
|
|
177
|
+
from .markers import scan
|
|
178
|
+
from .ooxml.xml import parse_xml
|
|
179
|
+
|
|
180
|
+
compiled = Engine().compile(arguments.template.read_bytes())
|
|
181
|
+
package = compiled.package
|
|
182
|
+
for name in package.order:
|
|
183
|
+
part = package.parts[name]
|
|
184
|
+
if not part.is_xml:
|
|
185
|
+
continue
|
|
186
|
+
for node in parse_xml(part.content).iter():
|
|
187
|
+
texts = [*node.attrib.values(), node.text or ""] if isinstance(node.tag, str) else []
|
|
188
|
+
for text in texts:
|
|
189
|
+
for occurrence in scan(str(text), include_aliases=True):
|
|
190
|
+
sys.stdout.write(f"{name}\t{occurrence.raw}\n")
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
raise SystemExit(main())
|
carbotek/docx/blocks.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# pyright: strict, reportPrivateUsage=false
|
|
2
|
+
"""Structural removal of hidden conditional blocks (``showBegin``/``hideBegin`` ... ``End``).
|
|
3
|
+
|
|
4
|
+
JS (``extracter.findAndSetValidPositionOfConditionalBlocks_v5`` with
|
|
5
|
+
``parser.findSafeConditionalBlockPosition_v5``) drops the XML between the two markers, split into
|
|
6
|
+
balanced pieces: elements partly inside keep their tags, elements wholly inside go. The tree
|
|
7
|
+
renderer records where hidden blocks start and end during its text pass and removes the elements
|
|
8
|
+
afterwards; the string renderer of loop sections cuts the same elements (``strings.py``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from lxml import etree # pyright: ignore[reportAttributeAccessIssue] lxml has incomplete stubs.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HiddenBlocks:
|
|
15
|
+
"""Hidden conditional blocks of a pass, removed like ``findSafeConditionalBlockPosition_v5``.
|
|
16
|
+
|
|
17
|
+
Everything between the begin and the end marker goes; an element holding the begin marker at
|
|
18
|
+
its very start (and closing before the end marker), or the end marker at its very end (and
|
|
19
|
+
opening after the begin marker), goes whole, climbing while the element is the first (last)
|
|
20
|
+
child of its parent. A block inside one text only loses its text.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self.events: list[tuple[str, str]] = []
|
|
25
|
+
self._open: tuple[etree._Element, bool] | None = None
|
|
26
|
+
self._ranges: list[tuple[etree._Element, bool, etree._Element, bool]] = []
|
|
27
|
+
|
|
28
|
+
def consume(self, node: etree._Element) -> None:
|
|
29
|
+
for kind, text in self.events:
|
|
30
|
+
if kind == "begin":
|
|
31
|
+
if self._open is None:
|
|
32
|
+
self._open = (node, text == "")
|
|
33
|
+
elif self._open is not None:
|
|
34
|
+
begin, empty_before = self._open
|
|
35
|
+
self._open = None
|
|
36
|
+
if begin is not node:
|
|
37
|
+
self._ranges.append((begin, empty_before, node, text == ""))
|
|
38
|
+
self.events.clear()
|
|
39
|
+
|
|
40
|
+
def remove(self) -> None:
|
|
41
|
+
removals: list[etree._Element] = []
|
|
42
|
+
for begin, empty_before, end, empty_after in self._ranges:
|
|
43
|
+
removals.extend(hidden_range(begin, empty_before, end, empty_after))
|
|
44
|
+
for element in {id(item): item for item in removals}.values():
|
|
45
|
+
parent = element.getparent()
|
|
46
|
+
if parent is not None and not any(ancestor in removals for ancestor in element.iterancestors()):
|
|
47
|
+
parent.remove(element)
|
|
48
|
+
self._ranges.clear()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def hidden_range(
|
|
52
|
+
begin: etree._Element, empty_before: bool, end: etree._Element, empty_after: bool
|
|
53
|
+
) -> list[etree._Element]:
|
|
54
|
+
"""Elements strictly inside a hidden block (see ``HiddenBlocks``)."""
|
|
55
|
+
begin_path = [begin, *begin.iterancestors()]
|
|
56
|
+
end_path = [end, *end.iterancestors()]
|
|
57
|
+
end_set = set(end_path)
|
|
58
|
+
common = next((node for node in begin_path if node in end_set), None)
|
|
59
|
+
if common is None:
|
|
60
|
+
return []
|
|
61
|
+
begin_path = begin_path[: begin_path.index(common)]
|
|
62
|
+
end_path = end_path[: end_path.index(common)]
|
|
63
|
+
if not begin_path or not end_path:
|
|
64
|
+
return [] # one holds the other: no element lies between
|
|
65
|
+
removals: list[etree._Element] = []
|
|
66
|
+
siblings = list(common)
|
|
67
|
+
first, last = siblings.index(begin_path[-1]), siblings.index(end_path[-1])
|
|
68
|
+
removals.extend(siblings[first + 1 : last])
|
|
69
|
+
for node in begin_path[:-1]:
|
|
70
|
+
removals.extend(node.itersiblings())
|
|
71
|
+
for node in end_path[:-1]:
|
|
72
|
+
removals.extend(node.itersiblings(preceding=True))
|
|
73
|
+
if empty_before:
|
|
74
|
+
removals.append(_climb(begin_path, preceding=True))
|
|
75
|
+
if empty_after:
|
|
76
|
+
removals.append(_climb(end_path, preceding=False))
|
|
77
|
+
return removals
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _climb(path: list[etree._Element], *, preceding: bool) -> etree._Element:
|
|
81
|
+
"""The highest element of ``path`` the marker opens (closes) directly, with no content before (after)."""
|
|
82
|
+
top = path[0]
|
|
83
|
+
for node in path[1:]:
|
|
84
|
+
child = top
|
|
85
|
+
if preceding and (node.text or child.getprevious() is not None):
|
|
86
|
+
break
|
|
87
|
+
if not preceding and (child.tail or child.getnext() is not None):
|
|
88
|
+
break
|
|
89
|
+
top = node
|
|
90
|
+
return top
|
carbotek/docx/dialect.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# pyright: strict, reportPrivateUsage=false
|
|
2
|
+
"""The XML vocabulary a templated part is written in: WordprocessingML or SpreadsheetML.
|
|
3
|
+
|
|
4
|
+
The marker engine (fields, loops, directives, string sections) only needs to know which elements
|
|
5
|
+
hold template text and what a paragraph and a run look like; the rest is shared. The dialect of the
|
|
6
|
+
part being rendered is a context variable set by ``DocxTextRenderer.render_tree``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
from lxml import etree # pyright: ignore[reportAttributeAccessIssue] lxml has incomplete stubs.
|
|
13
|
+
|
|
14
|
+
from carbotek.ooxml.ns import NAMESPACES, qn
|
|
15
|
+
from carbotek.render_state import StateSlot
|
|
16
|
+
|
|
17
|
+
SHEET_NAMESPACE = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _AnyElement(tuple[Any, ...]):
|
|
21
|
+
"""``texts`` of a dialect where the text of any element can hold markers (SpreadsheetML).
|
|
22
|
+
|
|
23
|
+
``element.iter(*texts)`` visits every element (not comments), ``tag in texts`` holds for them.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __contains__(self, tag: object) -> bool:
|
|
27
|
+
return isinstance(tag, str)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class Dialect:
|
|
32
|
+
name: str
|
|
33
|
+
# Elements whose text can hold markers (``iter(*texts)``, ``tag in texts``).
|
|
34
|
+
texts: tuple[Any, ...]
|
|
35
|
+
# The element a new text run gets, its paragraph and run.
|
|
36
|
+
text: str
|
|
37
|
+
paragraph: str
|
|
38
|
+
run: str
|
|
39
|
+
run_properties: str
|
|
40
|
+
namespace: str
|
|
41
|
+
|
|
42
|
+
def marker_texts(self, element: etree._Element) -> list[etree._Element]:
|
|
43
|
+
"""Text elements of the subtree holding a ``{`` (XPath in C instead of a Python walk)."""
|
|
44
|
+
return cast(list[etree._Element], _MARKER_TEXTS[self.name](element))
|
|
45
|
+
|
|
46
|
+
def emptied_texts(self, element: etree._Element, marker: str) -> list[etree._Element]:
|
|
47
|
+
"""Text elements of the subtree whose whole text is ``marker``."""
|
|
48
|
+
return cast(list[etree._Element], _EMPTIED_TEXTS[self.name](element, empty=marker))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
WORD = Dialect("docx", (qn("w:t"),), qn("w:t"), qn("w:p"), qn("w:r"), qn("w:rPr"), NAMESPACES["w"])
|
|
52
|
+
# JS renders a marker in the text of any element of a workbook part: cells (<t>, <v>, <f>), header
|
|
53
|
+
# and footer, defined names, conditional formats, data validations, comments.
|
|
54
|
+
SHEET = Dialect(
|
|
55
|
+
"xlsx",
|
|
56
|
+
_AnyElement((etree.Element,)),
|
|
57
|
+
f"{{{SHEET_NAMESPACE}}}t",
|
|
58
|
+
f"{{{SHEET_NAMESPACE}}}is",
|
|
59
|
+
f"{{{SHEET_NAMESPACE}}}r",
|
|
60
|
+
f"{{{SHEET_NAMESPACE}}}rPr",
|
|
61
|
+
SHEET_NAMESPACE,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
_MARKER_TEXTS = {
|
|
65
|
+
"docx": etree.XPath("descendant-or-self::w:t[contains(., '{')]", namespaces={"w": NAMESPACES["w"]}),
|
|
66
|
+
"xlsx": etree.XPath("descendant-or-self::*[text()[contains(., '{')]]"),
|
|
67
|
+
}
|
|
68
|
+
_EMPTIED_TEXTS = {
|
|
69
|
+
"docx": etree.XPath("descendant::w:t[. = $empty]", namespaces={"w": NAMESPACES["w"]}, smart_strings=False),
|
|
70
|
+
"xlsx": etree.XPath("descendant::*[not(*) and . = $empty]", smart_strings=False),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
DIALECT: StateSlot[Dialect] = StateSlot("DIALECT", default=WORD)
|