coffee-templater 0.0.0-stage → 0.1.1

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 coffee-templater contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,370 @@
1
- # Temporary Holding Version
1
+ # coffee-templater
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Open-source templating engine for **Word (.docx)**, **Excel (.xlsx)** and **PowerPoint (.pptx)** — plus a
4
+ JSON document model to parse Office files on the backend and build them back.
5
+
6
+ Powered by [coffee-templater-engine](https://github.com/vitJR1/coffee-templater-engine) — a Rust core
7
+ compiled to WebAssembly and bundled inside this package. The same engine is meant to back Go and Python
8
+ libraries, so documents render identically everywhere.
9
+
10
+ - Variables, loops, conditions, `else`, filters, images — **all free and MIT-licensed**
11
+ - Configurable tag delimiters (`{{x}}`, `${x}`, `[[x]]`, `<<x>>`, …) and syntax sigils
12
+ - Escaping: `\{{not a tag}}` prints the delimiters literally; values are always XML-escaped
13
+ - Tags split by Word into several runs (`{{na` + `me}}`) are handled transparently
14
+ - Loops repeat inline text, whole paragraphs, table rows (Word, PowerPoint) or sheet rows (Excel)
15
+ - Excel: numbers stay numbers, rows below a loop shift down, formulas / merged cells are adjusted
16
+ - `inspect()` lists template variables and returns a sample data shape — build forms or validate input
17
+ - `parse()` → plain JSON model → `build()`: edit documents programmatically without losing formatting
18
+ - Safe for untrusted uploads: limits against zip bombs, deep XML and runaway loops
19
+ - TypeScript-first, no native dependencies (the engine is WebAssembly), works in Node.js ≥ 18 and
20
+ browsers (ESM + CJS)
21
+
22
+ ```bash
23
+ npm install coffee-templater
24
+
25
+ # or straight from GitHub (a tag, branch or commit):
26
+ npm install github:vitJR1/coffee-templater.js#v0.1.1
27
+ # or the prebuilt package attached to a GitHub Release:
28
+ npm install https://github.com/vitJR1/coffee-templater.js/releases/download/v0.1.1/coffee-templater-0.1.1.tgz
29
+ ```
30
+
31
+ ## Quick start
32
+
33
+ ```ts
34
+ import { readFileSync, writeFileSync } from 'node:fs'
35
+ import { createTemplater } from 'coffee-templater'
36
+
37
+ const templater = createTemplater() // options below
38
+
39
+ const output = templater.render(readFileSync('invoice.docx'), {
40
+ number: 42,
41
+ client: { name: 'Ivan Petrov' },
42
+ items: [
43
+ { name: 'Coffee', qty: 2, price: 10.5 },
44
+ { name: 'Tea', qty: 1, price: 4 },
45
+ ],
46
+ paid: false,
47
+ })
48
+
49
+ writeFileSync('invoice-42.docx', output)
50
+ ```
51
+
52
+ The same call works for `.xlsx` and `.pptx` — the format is detected from the file.
53
+
54
+ Compile once, render many times (much faster for batch generation):
55
+
56
+ ```ts
57
+ const template = templater.compile(readFileSync('invoice.docx'))
58
+ for (const invoice of invoices) save(template.render(invoice))
59
+ ```
60
+
61
+ ## Template syntax
62
+
63
+ | Syntax | Meaning |
64
+ | ---------------------------------------- | ----------------------------------------------------------- |
65
+ | `{{name}}` | variable |
66
+ | `{{client.address.city}}`, `{{items[0].name}}`, `{{map["key with spaces"]}}` | paths |
67
+ | `{{price \| number: 2}}` | filter with arguments |
68
+ | `{{#items}}…{{/items}}` | loop over an array / context object / truthy check |
69
+ | `{{#each items}}…{{/each}}` | explicit loop (arrays and objects, `@key` for objects) |
70
+ | `{{#if paid}}…{{else}}…{{/if}}` | condition |
71
+ | `{{#unless paid}}…{{/unless}}` | negative condition |
72
+ | `{{^items}}…{{/items}}` | inverted section: rendered when value is empty/falsy |
73
+ | `{{/}}` | closes the innermost section |
74
+ | `{{%logo}}` | image |
75
+ | `\{{text}}` | literal `{{text}}` (escaped delimiter) |
76
+
77
+ Inside loops:
78
+
79
+ | Expression | Value |
80
+ | ----------------------- | ------------------------------------------- |
81
+ | `{{.}}` / `{{this}}` | current item |
82
+ | `{{name}}` | property of the current item, then outer scopes |
83
+ | `{{../title}}` | value from the parent scope |
84
+ | `{{@index}}` / `{{@number}}` | 0-based / 1-based position |
85
+ | `{{@first}}`, `{{@last}}`, `{{@length}}`, `{{@key}}` | loop info |
86
+
87
+ String literals accept `"…"`, `'…'` and the typographic quotes Word inserts automatically (`“…”`, `«…»`).
88
+
89
+ ### How loops expand
90
+
91
+ The engine decides what to repeat from where the opening and closing tags are:
92
+
93
+ | Tags placement | Repeated content |
94
+ | ---------------------------------------------------- | ------------------------------------- |
95
+ | in the same paragraph / cell | the text between the tags |
96
+ | each alone in its own paragraph | the paragraphs between them (tag paragraphs are removed) |
97
+ | in different cells of one table row (Word/PowerPoint)| the whole table row |
98
+ | in different cells of one row (Excel) | the whole sheet row |
99
+ | in different rows | the rows from the first to the last |
100
+
101
+ ```
102
+ Word paragraph loop Word / PowerPoint table row Excel row
103
+ ────────────────── ───────────────────────────── ─────────────────────────────────────
104
+ {{#items}} | {{#items}}{{name}} | {{qty}}{{/items}} | A3: {{#items}}{{name}} B3: {{qty}} C3: {{/items}}
105
+ • {{name}} — {{price}}
106
+ {{/items}}
107
+ ```
108
+
109
+ `{{else}}` in a paragraph/row loop must be alone in its own paragraph/row.
110
+
111
+ ### Filters
112
+
113
+ Built-in: `upper`, `lower`, `capitalize`, `trim`, `replace`, `truncate`, `prepend`, `append`, `default`,
114
+ `number` (`{{n | number: 2, " ", ","}}` → `1 234,50`), `fixed`, `round`, `currency`
115
+ (`{{n | currency: "EUR", "de-DE"}}`), `percent`, `date` (`{{d | date: "DD.MM.YYYY HH:mm"}}`), `join`, `length`,
116
+ `first`, `last`, `reverse`, `sort` (`{{#each items | sort: "price", "desc"}}`), `where`, `slice`, `json`,
117
+ comparison/logic for conditions: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `not`, `and`, `or`, `contains`, `empty`,
118
+ and `yesno` (`{{paid | yesno: "Paid", "Due"}}`).
119
+
120
+ Custom filters:
121
+
122
+ ```ts
123
+ const templater = createTemplater({
124
+ filters: {
125
+ money: (value, currency = 'RUB') => `${Number(value).toFixed(2)} ${currency}`,
126
+ },
127
+ })
128
+ // {{ total | money: "USD" }}
129
+ ```
130
+
131
+ Unknown filters are reported when the template is compiled.
132
+
133
+ ### Images
134
+
135
+ ```ts
136
+ templater.render(template, {
137
+ logo: { data: pngBytes, width: 120 }, // Uint8Array | ArrayBuffer | Buffer | base64 | data URL
138
+ })
139
+ ```
140
+
141
+ `width`/`height` are in pixels; if only one is given the other follows the aspect ratio, if none — the
142
+ natural size. PNG, JPEG, GIF, BMP and SVG are detected automatically (`extension` overrides).
143
+
144
+ - **Word** — the picture is inserted inline where the tag is.
145
+ - **PowerPoint** — put `{{%logo}}` in a text box: the box is replaced by the picture, fitted and centered
146
+ in the box's area (placeholders inherit the area from the layout).
147
+ - **Excel** — not supported yet.
148
+
149
+ A falsy value removes the tag (and in PowerPoint the text box).
150
+
151
+ ### Excel specifics
152
+
153
+ - A cell that contains only one tag gets the value's type: numbers and booleans become real numeric/boolean
154
+ cells; text around the tag keeps the cell a string.
155
+ - When loops add or remove rows, everything below is shifted: cell references, formulas (`=SUM(D3:D3)`
156
+ becomes `=SUM(D3:D12)`, per-row formulas like `=B3*C3` follow their row), merged cells, conditional
157
+ formatting, data validation and hyperlinks. `calcChain.xml` is dropped and Excel recalculates on open.
158
+
159
+ ## Options
160
+
161
+ ```ts
162
+ createTemplater({
163
+ delimiters: ['{{', '}}'], // or { start: '${', end: '}' }
164
+ escapeChar: '\\', // `\{{x}}` → literal; `false` disables escaping
165
+ syntax: { // all sigils/keywords are configurable
166
+ section: '#', inverted: '^', close: '/', image: '%',
167
+ else: 'else', if: 'if', unless: 'unless', each: 'each',
168
+ },
169
+ filters: {}, // merged with built-ins
170
+ nullGetter: (tag) => '', // value for undefined/null (tag.raw, tag.expression, tag.part)
171
+ strict: false, // throw TemplateError 'missing_value' instead of nullGetter
172
+ linebreaks: true, // '\n' → line break, '\t' → tab (Word)
173
+ stringify: (value) => '…', // how non-strings are printed (default: numbers, ISO dates, JSON)
174
+ timeZone: 'Europe/Moscow', // zone for JS Dates in data: 'local' or IANA name (default: UTC)
175
+ limits: { maxEntries: 10_000, maxLoopIterations: 1_000_000 /* … */ }, // safety limits, 0 = off
176
+ })
177
+ ```
178
+
179
+ **Dates.** A JS `Date` is an instant and is formatted in **UTC** by default, so a template renders the
180
+ same on every server. Set `timeZone` to print local time. Date strings without a zone
181
+ (`"2024-05-17"`) are printed as written.
182
+
183
+ **Browsers.** Optionally call `await initEngine()` once at startup: it compiles the WebAssembly engine
184
+ asynchronously instead of on the first call.
185
+
186
+ ## Inspecting templates
187
+
188
+ ```ts
189
+ const info = templater.inspect(readFileSync('invoice.docx'))
190
+
191
+ info.variables
192
+ // ['number', 'client.name', 'items[].name', 'items[].qty', 'items[].price', 'paid', 'logo']
193
+
194
+ info.schema
195
+ // { number: '', client: { name: '' }, items: [{ name: '', qty: '', price: '' }], paid: true, logo: null }
196
+
197
+ info.tags // full tag tree per part (sections with children, filters, raw tag text)
198
+ ```
199
+
200
+ ## Errors
201
+
202
+ Template mistakes throw a `TemplateError` with a `code`, the `tag` and the package `part`:
203
+
204
+ ```ts
205
+ import { TemplateError } from 'coffee-templater'
206
+
207
+ try {
208
+ templater.compile(file)
209
+ } catch (e) {
210
+ if (e instanceof TemplateError) console.log(e.code, e.tag, e.part)
211
+ // 'unclosed_section' '{{#items}}' 'word/document.xml'
212
+ }
213
+ ```
214
+
215
+ Template codes: `unclosed_tag`, `unclosed_section`, `unmatched_close`, `invalid_expression`, `unknown_filter`,
216
+ `filter_failed`, `missing_value`, `invalid_section`, `invalid_image`, `unsupported`.
217
+ Engine codes: `invalid_document`, `unsupported_format`, `invalid_model`, `invalid_options`,
218
+ `limit_exceeded`, `host_error`, `internal`.
219
+
220
+ All mistakes in tags are reported at once: `e.errors` lists every `{ code, message, tag, part }`,
221
+ and the error itself describes the first one.
222
+
223
+ ## Document model: parse → JSON → build
224
+
225
+ ```ts
226
+ import { parse, build, extractText } from 'coffee-templater'
227
+
228
+ const original = readFileSync('contract.docx')
229
+ const doc = parse(original) // DocxDocument | XlsxDocument | PptxDocument (plain JSON)
230
+
231
+ // store it, send it to a frontend, edit it...
232
+ const body = doc.format === 'docx' && doc.parts.find((p) => p.kind === 'body')!
233
+ body.blocks.push({
234
+ type: 'paragraph',
235
+ style: 'Heading2',
236
+ children: [{ type: 'run', text: 'Appendix', bold: true }],
237
+ })
238
+
239
+ const updated = build(doc, { base: original })
240
+ ```
241
+
242
+ Formatting is kept: every paragraph/run/cell carries its original properties XML in `props`, and the
243
+ convenient fields (`bold`, `size`, `color`, `align`, …) are applied on top only when you change them.
244
+ Anything the model does not cover (images, fields, bookmarks, content controls) is kept as `raw` XML.
245
+
246
+ ### Word (`DocxDocument`)
247
+
248
+ ```ts
249
+ {
250
+ format: 'docx',
251
+ parts: [{
252
+ path: 'word/document.xml', kind: 'body', // also 'header' / 'footer' parts
253
+ blocks: [
254
+ { type: 'paragraph', style: 'Heading1', align: 'center', list: { id: 1, level: 0 },
255
+ children: [
256
+ { type: 'run', text: 'Hello\nworld', bold: true, italic: true, underline: true, strike: false,
257
+ color: 'FF0000', highlight: 'yellow', size: 14, font: 'Arial', vertAlign: 'superscript' },
258
+ { type: 'hyperlink', url: 'https://example.com', children: [{ type: 'run', text: 'link' }] },
259
+ { type: 'break', kind: 'page' },
260
+ { type: 'raw', xml: '<w:r><w:drawing>…</w:drawing></w:r>' },
261
+ ] },
262
+ { type: 'table', rows: [{ cells: [{ blocks: [/* paragraphs */], colSpan: 2, vMerge: 'restart' }] }] },
263
+ ],
264
+ }],
265
+ }
266
+ ```
267
+
268
+ `\n` and `\t` in run text are line breaks and tabs. `build()` without `base` creates a new document with
269
+ default styles (`Normal`, `Title`, `Heading1-3`, `ListParagraph`, `TableGrid`).
270
+
271
+ ### Excel (`XlsxDocument`)
272
+
273
+ ```ts
274
+ {
275
+ format: 'xlsx',
276
+ sheets: [{
277
+ name: 'Report', path: 'xl/worksheets/sheet1.xml',
278
+ merges: ['A1:D1'],
279
+ rows: [{ index: 1, height: 20, cells: [
280
+ { ref: 'A1', value: 'Name' },
281
+ { ref: 'B1', value: 42, style: 3 },
282
+ { ref: 'C1', value: '2024-05-17', type: 'date' }, // date-formatted cells become ISO strings
283
+ { ref: 'D1', value: 84, formula: 'B1*2' },
284
+ { ref: 'E1', value: true },
285
+ ] }],
286
+ }],
287
+ }
288
+ ```
289
+
290
+ Helpers for backend imports/exports:
291
+
292
+ ```ts
293
+ import { parseXlsxFile, sheetToObjects, sheetToArrays, sheetFromArrays, build } from 'coffee-templater'
294
+
295
+ const rows = sheetToObjects(parseXlsxFile(upload).sheets[0]) // [{ Name: 'Tea', Qty: 2 }, ...]
296
+
297
+ const file = build({
298
+ format: 'xlsx',
299
+ sheets: [sheetFromArrays('Export', [['Name', 'Date'], ['Ann', new Date()]])],
300
+ }) // no base needed
301
+ ```
302
+
303
+ Renaming, adding and removing sheets is supported. Shared formulas are expanded into regular formulas.
304
+
305
+ ### PowerPoint (`PptxDocument`)
306
+
307
+ ```ts
308
+ {
309
+ format: 'pptx',
310
+ slides: [{
311
+ path: 'ppt/slides/slide1.xml',
312
+ notes: 'Speaker notes',
313
+ shapes: [
314
+ { type: 'text', id: 2, name: 'Title 1', placeholder: 'title',
315
+ paragraphs: [{ align: 'center', level: 0,
316
+ children: [{ type: 'run', text: 'Hello', bold: true, size: 40, color: '1F4E79', font: 'Arial' }] }] },
317
+ { type: 'table', id: 4, name: 'Table 3', rows: [{ cells: [{ paragraphs: [/* … */] }] }] },
318
+ { type: 'picture', id: 5, name: 'Logo' }, // other shapes are kept from the base file by id
319
+ ],
320
+ }],
321
+ }
322
+ ```
323
+
324
+ Reorder or remove slides/shapes by reordering/removing them in the arrays. Table rows can be added (the last
325
+ row is cloned). Building a pptx requires `base`.
326
+
327
+ ### Plain text
328
+
329
+ ```ts
330
+ extractText(file) // docx: paragraphs; xlsx: tab-separated rows per sheet; pptx: text per slide
331
+ ```
332
+
333
+ ## Limitations / roadmap
334
+
335
+ - Images in Excel templates
336
+ - Repeating whole slides in PowerPoint (`{{#slides}}`)
337
+ - Excel: shared formulas inside repeated rows and table objects (`ListObject`) ranges are not adjusted
338
+ - Tracked changes and comments are preserved as raw XML, not modelled
339
+
340
+ Contributions are welcome!
341
+
342
+ ## Development
343
+
344
+ ```bash
345
+ npm install # requires Node.js 22+ for the dev tooling
346
+ npm test # vitest
347
+ npm run typecheck
348
+ npm run build # tsup → dist (ESM + CJS + d.ts)
349
+ ```
350
+
351
+ The engine is not vendored: `npm run engine` (run automatically before test/typecheck/build) downloads
352
+ `coffee_core.wasm` from the engine's GitHub Release pinned in `package.json` → `coffeeEngine`, verifies its
353
+ sha256 and embeds it. To develop against a local engine build:
354
+
355
+ ```bash
356
+ COFFEE_ENGINE_WASM=../coffee-templater-engine/dist/coffee_core.wasm npm test
357
+ ```
358
+
359
+ Test fixtures are generated with real Office libraries: see `test/fixtures/generate.py`.
360
+ See [DOCS.md](DOCS.md) (Russian) for the full guide and internals.
361
+
362
+ ## License
363
+
364
+ MIT
365
+
366
+ ## Support
367
+
368
+ If this project helps you, consider buying me a coffee ☕
369
+
370
+ - PayPal ypi-2-22@mail.ru