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.
Files changed (96) hide show
  1. carbotek/__init__.py +32 -0
  2. carbotek/__main__.py +6 -0
  3. carbotek/_native.pyi +14 -0
  4. carbotek/_native_adapter.py +100 -0
  5. carbotek/api.py +120 -0
  6. carbotek/cache.py +17 -0
  7. carbotek/cli.py +195 -0
  8. carbotek/docx/__init__.py +2 -0
  9. carbotek/docx/blocks.py +90 -0
  10. carbotek/docx/dialect.py +73 -0
  11. carbotek/docx/directives.py +527 -0
  12. carbotek/docx/loop_syntax.py +8 -0
  13. carbotek/docx/loops.py +912 -0
  14. carbotek/docx/normalize.py +315 -0
  15. carbotek/docx/plan.py +102 -0
  16. carbotek/docx/renderer.py +1897 -0
  17. carbotek/docx/session.py +131 -0
  18. carbotek/docx/set_markers.py +92 -0
  19. carbotek/docx/strings.py +678 -0
  20. carbotek/docx/text.py +62 -0
  21. carbotek/errors.py +62 -0
  22. carbotek/features/__init__.py +18 -0
  23. carbotek/features/_barcode_writer.py +72 -0
  24. carbotek/features/barcode.py +312 -0
  25. carbotek/features/color.py +397 -0
  26. carbotek/features/fetch.py +242 -0
  27. carbotek/features/html.py +1467 -0
  28. carbotek/features/hyperlinks.py +179 -0
  29. carbotek/features/image.py +674 -0
  30. carbotek/features/native_chart.py +844 -0
  31. carbotek/features/template_options.py +32 -0
  32. carbotek/formatters/__init__.py +49 -0
  33. carbotek/formatters/aggregator.py +350 -0
  34. carbotek/formatters/array.py +93 -0
  35. carbotek/formatters/barcode.py +179 -0
  36. carbotek/formatters/chart.py +45 -0
  37. carbotek/formatters/color.py +103 -0
  38. carbotek/formatters/condition.py +222 -0
  39. carbotek/formatters/currencies.json +1482 -0
  40. carbotek/formatters/date.py +507 -0
  41. carbotek/formatters/dayjs_date_locale.json.gz +0 -0
  42. carbotek/formatters/dayjs_relative_time.json.gz +0 -0
  43. carbotek/formatters/docx.py +193 -0
  44. carbotek/formatters/jsnumber.py +205 -0
  45. carbotek/formatters/locale_currency.json +1224 -0
  46. carbotek/formatters/locale_numbers.json +767 -0
  47. carbotek/formatters/number.py +364 -0
  48. carbotek/formatters/optional.py +23 -0
  49. carbotek/formatters/region_names.json +20664 -0
  50. carbotek/formatters/registration_order.json +1 -0
  51. carbotek/formatters/registry.py +186 -0
  52. carbotek/formatters/ru.py +454 -0
  53. carbotek/formatters/set.py +130 -0
  54. carbotek/formatters/string.py +305 -0
  55. carbotek/formatters/timezone_names.json +1 -0
  56. carbotek/formatters/various.py +61 -0
  57. carbotek/markers/__init__.py +20 -0
  58. carbotek/markers/math.py +89 -0
  59. carbotek/markers/scan.py +37 -0
  60. carbotek/markers/syntax.py +255 -0
  61. carbotek/markers/variables.py +134 -0
  62. carbotek/ooxml/__init__.py +2 -0
  63. carbotek/ooxml/fragments.py +141 -0
  64. carbotek/ooxml/image_types.py +39 -0
  65. carbotek/ooxml/ns.py +28 -0
  66. carbotek/ooxml/template_options.py +34 -0
  67. carbotek/ooxml/xml.py +52 -0
  68. carbotek/opc/__init__.py +7 -0
  69. carbotek/opc/cache.py +74 -0
  70. carbotek/opc/repository.py +117 -0
  71. carbotek/options.py +95 -0
  72. carbotek/package.py +122 -0
  73. carbotek/pdf/__init__.py +6 -0
  74. carbotek/pdf/convert.py +97 -0
  75. carbotek/pipeline/__init__.py +2 -0
  76. carbotek/pipeline/compile.py +256 -0
  77. carbotek/pipeline/document.py +188 -0
  78. carbotek/pipeline/ports.py +50 -0
  79. carbotek/py.typed +0 -0
  80. carbotek/render_state.py +59 -0
  81. carbotek/typing_helpers.py +25 -0
  82. carbotek/util.py +52 -0
  83. carbotek/xlsx/__init__.py +6 -0
  84. carbotek/xlsx/images.py +260 -0
  85. carbotek/xlsx/prepare.py +313 -0
  86. carbotek-0.1.0.dist-info/METADATA +318 -0
  87. carbotek-0.1.0.dist-info/RECORD +96 -0
  88. carbotek-0.1.0.dist-info/WHEEL +5 -0
  89. carbotek-0.1.0.dist-info/entry_points.txt +2 -0
  90. carbotek-0.1.0.dist-info/licenses/LICENSE +75 -0
  91. carbotek-0.1.0.dist-info/licenses/NOTICE +13 -0
  92. carbotek-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +228 -0
  93. carbotek-0.1.0.dist-info/licenses/licenses/Carbone-Apache-2.0.txt +201 -0
  94. carbotek-0.1.0.dist-info/licenses/licenses/Unicode-3.0.txt +39 -0
  95. carbotek-0.1.0.dist-info/licenses/licenses/dayjs-MIT.txt +21 -0
  96. 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
@@ -0,0 +1,6 @@
1
+ # pyright: strict, reportPrivateUsage=false
2
+ """``python -m carbotek`` runs the command line interface."""
3
+
4
+ from .cli import main
5
+
6
+ raise SystemExit(main())
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())
@@ -0,0 +1,2 @@
1
+ # pyright: strict, reportPrivateUsage=false
2
+ """DOCX-specific tree operations, kept separate from the domain model."""
@@ -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
@@ -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)