fancy-template 0.2.0__tar.gz → 0.4.0__tar.gz

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 (31) hide show
  1. {fancy_template-0.2.0 → fancy_template-0.4.0}/PKG-INFO +1 -1
  2. {fancy_template-0.2.0 → fancy_template-0.4.0}/SPEC.md +343 -5
  3. fancy_template-0.4.0/profiles/fine-print.json +50 -0
  4. {fancy_template-0.2.0 → fancy_template-0.4.0}/profiles/last-word.json +2 -1
  5. {fancy_template-0.2.0 → fancy_template-0.4.0}/pyproject.toml +1 -1
  6. {fancy_template-0.2.0 → fancy_template-0.4.0}/src/fancy_template/__init__.py +5 -1
  7. fancy_template-0.4.0/src/fancy_template/_files.py +40 -0
  8. {fancy_template-0.2.0 → fancy_template-0.4.0}/src/fancy_template/_renderer.py +9 -1
  9. fancy_template-0.4.0/tests/test_band_value.py +92 -0
  10. {fancy_template-0.2.0 → fancy_template-0.4.0}/tests/test_conformance.py +1 -1
  11. fancy_template-0.4.0/tests/test_files.py +28 -0
  12. fancy_template-0.2.0/profiles/fine-print.json +0 -18
  13. {fancy_template-0.2.0 → fancy_template-0.4.0}/.gitignore +0 -0
  14. {fancy_template-0.2.0 → fancy_template-0.4.0}/LICENSE +0 -0
  15. {fancy_template-0.2.0 → fancy_template-0.4.0}/README.md +0 -0
  16. {fancy_template-0.2.0 → fancy_template-0.4.0}/profiles/dark-slide.json +0 -0
  17. {fancy_template-0.2.0 → fancy_template-0.4.0}/profiles/holy-sheet.json +0 -0
  18. {fancy_template-0.2.0 → fancy_template-0.4.0}/src/fancy_template/_dates.py +0 -0
  19. {fancy_template-0.2.0 → fancy_template-0.4.0}/src/fancy_template/_declarations.py +0 -0
  20. {fancy_template-0.2.0 → fancy_template-0.4.0}/src/fancy_template/_errors.py +0 -0
  21. {fancy_template-0.2.0 → fancy_template-0.4.0}/src/fancy_template/_filters.py +0 -0
  22. {fancy_template-0.2.0 → fancy_template-0.4.0}/src/fancy_template/_inspector.py +0 -0
  23. {fancy_template-0.2.0 → fancy_template-0.4.0}/src/fancy_template/_json.py +0 -0
  24. {fancy_template-0.2.0 → fancy_template-0.4.0}/src/fancy_template/_limits.py +0 -0
  25. {fancy_template-0.2.0 → fancy_template-0.4.0}/src/fancy_template/_numbers.py +0 -0
  26. {fancy_template-0.2.0 → fancy_template-0.4.0}/src/fancy_template/_profile.py +0 -0
  27. {fancy_template-0.2.0 → fancy_template-0.4.0}/src/fancy_template/_scanner.py +0 -0
  28. {fancy_template-0.2.0 → fancy_template-0.4.0}/src/fancy_template/py.typed +0 -0
  29. {fancy_template-0.2.0 → fancy_template-0.4.0}/tests/template_runner.py +0 -0
  30. {fancy_template-0.2.0 → fancy_template-0.4.0}/tests/test_security.py +0 -0
  31. {fancy_template-0.2.0 → fancy_template-0.4.0}/tests/test_spec.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fancy-template
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Document templates for the Fancy document writers: a writer document with {{ tokens }} and JSON directives, plus a declaration of the data it needs. inspect() at save time, validate_data() before render, render() to plain writer JSON. No code execution, no includes, bounded everywhere. One spec, three runtimes, held to a shared conformance table.
5
5
  Project-URL: Homepage, https://github.com/Particle-Academy/fancy-template
6
6
  Project-URL: Issues, https://github.com/Particle-Academy/fancy-template/issues
@@ -331,7 +331,7 @@ index, `**` for zero or more. **Anything not listed refuses tokens**
331
331
  | encoding | result |
332
332
  |---|---|
333
333
  | `text` | the string, every token stringified |
334
- | `band` | as `text`, but a VALUE containing `{page}` or `{pages}` is `VALUE_PAGE_FIELD` |
334
+ | `band` | as `text`, but a VALUE containing a writer's band field is `VALUE_PAGE_FIELD`: `{page}` `{pages}` `{section}` `{sectionpages}` `{author}` `{title}` `{filename}`, and `{date}` / `{time}`, each with an optional `:FORMAT` of one or more characters that are not a brace, a double quote or a backslash. Other braces are ordinary text |
335
335
  | `markup` | as `text`, each value escaped for the element's `format`: `markdown` backslash-escapes every ASCII punctuation character (CommonMark), `html` escapes `& < > " '` as entities, `plain` or absent leaves it |
336
336
  | `markdown` | as `markup` with format fixed to markdown |
337
337
  | `cell` | a whole token keeps a number, boolean or date as-is; every other result is written `{ "value": s }`, so user data can never become a formula. "Date" means: the token's path is DECLARED `date` and the result (after filters) is a valid `date` string (section 3) -- a date-shaped value of a `string` variable is wrapped, and `{{ d \| date:"yyyy" }}` is text. A valid date cannot begin with `=`. A literal (token-free) cell is the author's and is left as written |
@@ -339,10 +339,13 @@ index, `**` for zero or more. **Anything not listed refuses tokens**
339
339
 
340
340
  Wrong use of an image field is `TOKEN_IMAGE_FIELD`.
341
341
 
342
- **`{page}` is refused rather than escaped** because neither fine-print nor
343
- last-word has an escape for it: both replace the literal text in a band. A refusal
344
- is loud; any lookalike substitution would print a different character. When a
345
- writer gains an escape, the band encoding uses it.
342
+ **A band field is refused rather than escaped** because neither fine-print nor
343
+ last-word has an escape for it: both turn the literal text into a live value in a
344
+ band, in a one-line slot and in a blocks band's run text alike, so a blocks band's
345
+ run text is `band` too. The list is one for both writers, including the ones a
346
+ writer writes literally today (fine-print's `{date}`), so a value that refuses in
347
+ one refuses in the other. A refusal is loud; any lookalike substitution would print
348
+ a different character. When a writer gains an escape, the band encoding uses it.
346
349
 
347
350
  **The markdown escape is CommonMark's, and the profile does not force `plain`.**
348
351
  dark-slide's PPTX writer (`MarkdownInline`, in all three of its languages) does
@@ -414,5 +417,340 @@ PHP `Template::validateData`, TypeScript `validateData`, Python `validate_data`)
414
417
  - `preview(template, options?) -> document` -- `render` with each variable's
415
418
  `example` (else `default`).
416
419
  - `tokenize(text, options?) -> parts` (section 5).
420
+ - `inspectFile(bytes, options?)` and `renderFile(bytes, data, options?)` (section 12).
417
421
 
418
422
  Output key order is the template's, minus directive keys.
423
+
424
+ ## 12. Office files as templates
425
+
426
+ Owner's rule: **any file can be a template.** A customer's own `.docx`, `.pptx`
427
+ or `.xlsx` with `{{ tokens }}` typed into it is filled AS IT IS, in its own
428
+ text, with every other byte of the package kept. Nothing is read into a writer
429
+ model and written back, because a model drops what it does not carry (custom
430
+ styles, embedded objects, fields, comments, layout).
431
+
432
+ **Status.** PHP implements this section. TypeScript and Python expose the two
433
+ functions and throw `FileTemplatesNotImplementedError` (`code`
434
+ `FILE_NOT_IMPLEMENTED`, `operation` the function's name), never a silent
435
+ no-op. Shared rows and fixtures: `fixtures/files/` (section 12.10).
436
+
437
+ ### 12.1 API
438
+
439
+ | | PHP | TypeScript | Python |
440
+ |---|---|---|---|
441
+ | inspect | `Template::inspectFile(string $bytes, array $options = [])` | `inspectFile(bytes: Uint8Array, options?)` | `inspect_file(data: bytes, options=None)` |
442
+ | render | `Template::renderFile(string $bytes, mixed $data, array $options = []) : string` | `renderFile(bytes, data, options?) : Uint8Array` | `render_file(data, values, options=None) -> bytes` |
443
+
444
+ `inspectFile` returns
445
+ `{ ok, format, kind, issues: [{code, path, variable?}], placeholders: [{path, token, variable?}], variables: [name] }`:
446
+
447
+ - `format`: `docx`, `pptx` or `xlsx`; `kind`: `document` or `template` (from the
448
+ main part's content type, 12.3). Both `null` when the file is refused.
449
+ - `placeholders`: every token, in order (12.5): `path` is its location, `token`
450
+ the token's source text from `{{` to `}}` as it reads in the joined text,
451
+ `variable` the dotted path (absent for a string-literal token).
452
+ - `variables`: the HEAD of every token path, distinct, in first-use order: the
453
+ names the data must supply.
454
+ - A refused file (any `FILE_` code) is reported as the single issue, as an
455
+ oversized template is (section 10), and `inspectFile` does not throw for it.
456
+
457
+ `renderFile` returns the filled file's bytes, or throws `TemplateError`. Order:
458
+ open and classify the package (`FILE_` codes), inspect (first issue), validate
459
+ the data as `validateData` does (first error, path into the data), fill.
460
+
461
+ Options: `variables` (12.2), `limits` (`tokens`, `tokenLength`, `outputBytes`,
462
+ and `depth` for the data, apply; the rest have nothing to bound in a file) and
463
+ `fileLimits` (12.8). An
464
+ unknown `fileLimits` key, or a value that is not a positive integer, is refused
465
+ as an unknown `limits` key is.
466
+
467
+ ### 12.2 Declarations: a sidecar, not document properties
468
+
469
+ `options.variables` is a declaration map exactly as an envelope's `variables`
470
+ (section 3), checked by the same rules with the same codes and paths
471
+ (`DECL_INVALID` at `/variables/<name>/...`; a non-object is `TEMPLATE_SHAPE` at
472
+ `/variables`). With it, the file is inspected as a template is: a token whose
473
+ path is not declared is `TOKEN_UNDECLARED`, a token naming an `image` variable
474
+ is `TOKEN_IMAGE_FIELD` (an image cannot be text), data is validated with
475
+ `DATA_*` codes and defaults apply.
476
+
477
+ **Why a sidecar.** Custom document properties would put the declarations inside
478
+ the customer's file, where:
479
+
480
+ - anyone who opens it in Word sees and can edit them (File > Properties), so the
481
+ customer can break the template without knowing it is one;
482
+ - writing or updating them rewrites `docProps/custom.xml`, which is exactly the
483
+ kind of change to a customer's file this feature exists to avoid;
484
+ - they are flat strings, so a declaration would be JSON inside a property, a
485
+ second format to parse in three languages;
486
+ - some editors drop unknown custom properties on save.
487
+
488
+ The host already stores a record for every file it accepts as a template; the
489
+ declarations belong in that record, beside the file, where they can be versioned
490
+ and reused across new uploads of the same file.
491
+
492
+ **Without declarations** the names are INFERRED from the tokens, the way
493
+ `inspect` reports them: no token is undeclared, nothing is typed, and
494
+ `validateData` has no member to check (data must still be a plain JSON object:
495
+ `DATA_TYPE`, `DATA_TOO_DEEP`). Every token must still resolve at render: absent
496
+ is `TEMPLATE_MISSING`, a container is `TEMPLATE_NOT_SCALAR`, a wrong filter input
497
+ is `FILTER_INPUT`. A file has no `$each`, so `$index`, `$first` and `$last` are
498
+ `TOKEN_UNDECLARED` in both modes.
499
+
500
+ ### 12.3 Recognising the package
501
+
502
+ In this order; the first failure is the answer.
503
+
504
+ 1. An OLE compound file (first 8 bytes `D0 CF 11 E0 A1 B1 1A E1`) is
505
+ `FILE_ENCRYPTED` when its bytes contain the UTF-16LE stream name
506
+ `EncryptedPackage` (a password-protected OOXML file), else `FILE_UNSUPPORTED`
507
+ (a legacy binary `.doc`/`.xls`/`.ppt`).
508
+ 2. The zip is indexed and checked (12.8).
509
+ 3. `[Content_Types].xml` must be a member (exact name), else `FILE_UNSUPPORTED`.
510
+ It is read like every part (12.7). Each member's content type is its
511
+ `Override` (by part name `/` + member name, ASCII case-insensitive), else the
512
+ `Default` for its extension (case-insensitive), else none. Content types
513
+ compare case-insensitively.
514
+ 4. A member whose type is a macro-enabled or binary main type is
515
+ `FILE_MACRO_ENABLED`: `application/vnd.ms-word.document.macroEnabled.main+xml`,
516
+ `...ms-word.template.macroEnabledTemplate.main+xml`,
517
+ `...ms-powerpoint.{presentation,slideshow,template,addin}.macroEnabled.main+xml`,
518
+ `...ms-excel.{sheet,template,addin}.macroEnabled.main+xml`,
519
+ `...ms-excel.sheet.binary.macroEnabled.main`.
520
+ 5. Exactly one member must have one of these main types, else `FILE_UNSUPPORTED`:
521
+
522
+ | content type (`application/vnd.openxmlformats-officedocument.` +) | format | kind |
523
+ |---|---|---|
524
+ | `wordprocessingml.document.main+xml` | docx | document |
525
+ | `wordprocessingml.template.main+xml` | docx | template |
526
+ | `presentationml.presentation.main+xml`, `presentationml.slideshow.main+xml` | pptx | document |
527
+ | `presentationml.template.main+xml` | pptx | template |
528
+ | `spreadsheetml.sheet.main+xml` | xlsx | document |
529
+ | `spreadsheetml.template.main+xml` | xlsx | template |
530
+
531
+ 6. A VBA project anywhere is `FILE_MACRO_ENABLED`: any declared type (`Default`
532
+ or `Override`) equal to `application/vnd.ms-office.vbaProject`, or any member
533
+ whose name ends with `vbaProject.bin` (case-insensitive). A `.docx` may not
534
+ smuggle what a `.docm` declares.
535
+
536
+ **Macro-enabled files are refused, not passed through.** Passing one through
537
+ would hand back, from this system and filled by an agent, a file carrying code
538
+ nothing here inspected; macro documents are the most common malware carrier,
539
+ and last-word's template intake already refuses them. A customer who needs the
540
+ macros keeps them in their own copy. **Encrypted files are refused** because
541
+ there is nothing to fill: the text is ciphertext without the password, and
542
+ "passing it through" would return the input unchanged with every placeholder
543
+ still in it.
544
+
545
+ **The parts that are filled**, by content type (prefix as above):
546
+
547
+ | format | parts |
548
+ |---|---|
549
+ | docx | the main document (`document.main` or `template.main`), `header`, `footer`, `footnotes`, `endnotes`, `comments` (`wordprocessingml.*+xml`) |
550
+ | pptx | `slide`, `slideLayout`, `slideMaster`, `notesSlide`, `notesMaster` (`presentationml.*+xml`) |
551
+ | xlsx | `sharedStrings`, `worksheet` (`spreadsheetml.*+xml`) |
552
+
553
+ **Layouts and masters are filled.** A token on a master or layout (a footer's
554
+ `{{ client }}`, a confidentiality line) is drawn on every slide that uses it,
555
+ so leaving it unfilled would print `{{ client }}` on every slide; that is the
556
+ common reason to put text there at all. A placeholder's prompt text on a layout
557
+ ("Click to add title") is never shown in a slide show, so filling a token in it
558
+ is harmless. **Comments are filled** because they ride along with the file and
559
+ an unfilled token in one leaks the template into the customer's review pane.
560
+
561
+ **Every other `.xml` or `.rels` member is still read** (12.7, so a DOCTYPE in
562
+ it is refused) and searched for `{{`: one found is `TOKEN_FORBIDDEN_FIELD` at
563
+ `/<member name>`. A token in a place that is not filled is refused, not left
564
+ in the output, exactly as a profile refuses a field it does not list (section
565
+ 9): document properties, charts, SmartArt, a glossary, custom XML.
566
+
567
+ ### 12.4 Where text is: containers, runs, text nodes, barriers
568
+
569
+ Elements are matched by namespace URI and local name, never by prefix. Each
570
+ format accepts its transitional and strict namespace:
571
+
572
+ | format | namespace (transitional; strict) |
573
+ |---|---|
574
+ | docx | `http://schemas.openxmlformats.org/wordprocessingml/2006/main`; `http://purl.oclc.org/ooxml/wordprocessingml/main` |
575
+ | pptx | `http://schemas.openxmlformats.org/drawingml/2006/main`; `http://purl.oclc.org/ooxml/drawingml/main` |
576
+ | xlsx | `http://schemas.openxmlformats.org/spreadsheetml/2006/main`; `http://purl.oclc.org/ooxml/spreadsheetml/main` |
577
+
578
+ | format | container | run | text node | transparent run children | barriers |
579
+ |---|---|---|---|---|---|
580
+ | docx | `p` | `r` | `t` child of a run | `rPr`, `delText`, `lastRenderedPageBreak` | every other run child (`tab`, `br`, `cr`, `drawing`, `pict`, `fldChar`, `instrText`, `sym`, `footnoteReference`, ...); `m:oMath`/`m:oMathPara` (OMML namespace) inside the container |
581
+ | pptx | `p` | `r` | `t` child of a run | `rPr` | every other run child; `br` and `fld` inside the container (outside a run) |
582
+ | xlsx | `si`, `is` | `r` | `t` child of a run, or `t` child of the container itself | `rPr` | every other run child |
583
+
584
+ - A **container** is a paragraph (docx/pptx), a shared-string item `si` or an
585
+ inline string `is` (xlsx). A container inside another (a text box's paragraph
586
+ inside a run's drawing) is its own container; the run child holding it
587
+ (`drawing`) is a barrier in the outer one.
588
+ - A **run** is a run element that is a descendant of the innermost open
589
+ container and not inside another run. Containers' other children (`proofErr`,
590
+ `bookmarkStart`, `ins`, `hyperlink`, `fldSimple`, `sdt`, `commentRangeStart`,
591
+ `rPh`, ...) are neither text nor barriers; runs inside them are runs.
592
+ `delText` is not text: deleted text is not shown and is not joined.
593
+ - The text nodes of a container, in document order, split at barriers, form its
594
+ **spans**. A span's **joined text** is its nodes' decoded text (12.7)
595
+ concatenated. An empty text node (`<w:t/>`, `<w:t></w:t>`) is skipped.
596
+ - A container's **location** is `/<member name>#<label>`, the label being the
597
+ container's local name and its 0-based index among the part's containers in
598
+ START-tag order (`#p0`, `#si3`), except an xlsx `is` inside a `c` whose `r`
599
+ attribute is a cell reference (`[A-Z0-9]+`): then the label is that reference
600
+ (`#B4`). A text box's paragraph is numbered after the paragraph holding it.
601
+ - The **visible text** of a container, used by the shared rows, is its text
602
+ nodes' decoded text concatenated (barriers add nothing); in xlsx, a
603
+ `_xHHHH_` escape is then decoded to its character.
604
+
605
+ ### 12.5 Tokens, inspection, and the run-split rule
606
+
607
+ Each span whose joined text contains `{{` is scanned with section 5's grammar
608
+ and bounds. A scan failure (`TOKEN_UNCLOSED`, `TOKEN_TOO_LONG`, `TOKEN_EMPTY`,
609
+ `TOKEN_SYNTAX`) is one issue at the container's location, and that span
610
+ contributes no tokens. A token cannot cross a barrier or a container boundary,
611
+ so a `{{` before a tab and its `}}` after it is `TOKEN_UNCLOSED`.
612
+
613
+ Per token, in order: its path (`TOKEN_UNDECLARED` with `variable`, or
614
+ `TOKEN_IMAGE_FIELD`, 12.2), then each filter (`FILTER_UNKNOWN`, `FILTER_ARG`),
615
+ all at the container's location.
616
+
617
+ Issue order: sidecar declarations (12.2), then members in CENTRAL-DIRECTORY
618
+ order; within a filled part, containers in location order, spans in order,
619
+ tokens in order. Placeholders are in the same order. When the file's tokens
620
+ outnumber `limits.tokens`, the issues are exactly `[TEMPLATE_TOO_MANY_TOKENS at ""]`.
621
+
622
+ **The run-split rule.** Word splits text into runs for spell-check marks,
623
+ revision ids and formatting, so `{{ client }}` may be `{{ cli` in one run and
624
+ `ent }}` in the next. A token is found in the span's joined text, then:
625
+
626
+ 1. The value is written into the text node where the token STARTS, at the
627
+ token's position, replacing the token's characters in that node. That node's
628
+ run keeps its properties, so **the value takes the formatting of the run
629
+ holding `{{`**: `{{ cli` bold + `ent }}` italic fills bold.
630
+ 2. The rest of the token is cut from each following node it covers. Text in
631
+ those nodes outside the token stays, in its own run with its own formatting.
632
+ 3. **No node and no run is ever removed**, even one left empty: an empty run is
633
+ invisible, and a run may be what a bookmark, a comment range or a revision
634
+ is anchored to.
635
+ 4. Text around a token in a touched node is copied as its ORIGINAL bytes (its
636
+ entities, character references, comments and CDATA as written); only the
637
+ token's own bytes change. A node no token touches is not changed at all.
638
+
639
+ ### 12.6 Values
640
+
641
+ A token's value is computed as a `text` field's (sections 6 and 7): resolved
642
+ against the (defaulted) data, filtered, stringified. Then:
643
+
644
+ - It must hold only characters XML 1.0 can carry: a control character other
645
+ than TAB, LF and CR, or U+FFFE / U+FFFF, is `VALUE_XML_CHAR` at the location,
646
+ with `variable`. There is no escape for these in XML 1.0.
647
+ - It is **XML-escaped**: `&` `<` `>` as `&amp;` `&lt;` `&gt;`. Nothing in a
648
+ value can become markup.
649
+ - **xlsx**: a value is written as text in the shared string or inline string
650
+ that held the token, and the cell around it is never touched, so it stays a
651
+ text cell: a value starting with `=`, `+`, `-` or `@` is text, never a
652
+ formula. A `_xHHHH_` sequence in a value has its `_` written as `_x005F_`
653
+ (ECMA-376 ST_Xstring), so Excel shows the value as given instead of decoding it.
654
+ - **A whole-token number in an xlsx cell stays a string.** The token sits in a
655
+ shared string, which may be used by several cells, so turning one cell into a
656
+ number means editing the worksheet's cells and possibly splitting the string;
657
+ the cell's number format was authored for text; and the value has already been
658
+ stringified by section 6's rules, which is what the customer typed the token
659
+ into. A future option may write declared `number` values as number cells;
660
+ until then the rule is: text in, text out.
661
+ - **docx and xlsx white space**: when a touched text node's new text begins or
662
+ ends with white space and its start tag has no `xml:space` attribute,
663
+ ` xml:space="preserve"` is inserted before the tag's `>`, because Word and
664
+ Excel otherwise drop it. pptx text keeps its white space and needs none.
665
+ - A value is never re-scanned: `{{` in a value reaches the file literally.
666
+ - `limits.outputBytes` bounds the UTF-8 bytes of all values written
667
+ (`RENDER_TOO_LARGE` at `""`).
668
+
669
+ ### 12.7 Reading XML
670
+
671
+ Every member that is read (`[Content_Types].xml`, the filled parts and the
672
+ searched `.xml`/`.rels` members) is refused with `FILE_DOCTYPE` at
673
+ `/<member name>` when it contains `<!DOCTYPE` (ASCII case-insensitive),
674
+ before it is parsed. Nothing defines an entity, so nothing expands one, and
675
+ there is no DTD to fetch.
676
+
677
+ The filled parts and `[Content_Types].xml` are read by a forward-only lexer that
678
+ reports each element with its byte offsets. `FILE_XML` at `/<member name>` for:
679
+ a UTF-16 byte-order mark, an XML declaration naming an encoding other than
680
+ UTF-8, any `<!` other than a comment or CDATA, an unterminated comment, CDATA or
681
+ processing instruction, a mismatched or unclosed tag, more than one root, an
682
+ attribute that is unquoted, repeated or holds `<`, an unbound prefix, an entity
683
+ other than the five predefined ones, a character reference to a character XML
684
+ cannot hold, or an element inside a text node. Text-node content decodes the
685
+ five entities and character references, takes CDATA literally and skips
686
+ comments and processing instructions.
687
+
688
+ A filled part is re-read by the same lexer before it is written; failing that is
689
+ a bug in the implementation, not a file problem.
690
+
691
+ ### 12.8 The archive
692
+
693
+ The central directory is parsed and checked before any member is decompressed.
694
+ Bounds, through `options.fileLimits` (the defaults are last-word's):
695
+
696
+ | limit | default | measured as | code |
697
+ |---|---|---|---|
698
+ | `archiveBytes` | 67108864 | the file | `FILE_TOO_LARGE` |
699
+ | `entries` | 16384 | members in the central directory | `FILE_TOO_MANY_ENTRIES` |
700
+ | `partBytes` | 67108864 | any member's declared uncompressed size | `FILE_TOO_LARGE` |
701
+ | `expandedBytes` | 268435456 | all declared uncompressed sizes together | `FILE_TOO_LARGE` |
702
+ | `compressionRatio` | 1000 | a member's uncompressed / max(1, compressed) | `FILE_ZIP_BOMB` |
703
+
704
+ All with path `""`. Also refused, path `""`:
705
+
706
+ | code | when |
707
+ |---|---|
708
+ | `FILE_ARCHIVE` | no end-of-central-directory record that accounts for the whole file; multi-disk; zip64 (an entry count of 65535, a 0xFFFFFFFF size or offset, a zip64 extra field); a method other than stored (0) or deflate (8); a version needed above 2.0; a flag other than bits 1, 2, 3 and 11; a stored member whose sizes differ; a local header that disagrees with the central one (signature, flags, method, name, CRC and sizes unless bit 3 defers them to a matching data descriptor); members that overlap; bytes between the last central record and the end record; a non-ASCII name without the UTF-8 flag; a member whose decompressed length or CRC differs from the directory |
709
+ | `FILE_ENCRYPTED` | the encryption flag (bit 0) or the strong-encryption flag (bit 6), method 99, or an AES extra field (0x9901) |
710
+ | `FILE_PATH` | a name that is empty, starts with `/`, contains `\` or `:` or a control character, is not UTF-8, or has an empty, `.` or `..` segment; two names equal under ASCII case folding (two spellings of one OPC part let a scan read one and an office suite the other) |
711
+
712
+ **Nothing is fetched.** Relationships are never resolved: an external target
713
+ (`TargetMode="External"`, a remote image, a `file://` link) is bytes in a
714
+ `.rels` member, copied with it.
715
+
716
+ **Output.** The output has the input's members in the same local and central
717
+ order with the same archive comment. Every member not filled is its ORIGINAL
718
+ LOCAL RECORD, byte for byte (header, extra field, compressed data, data
719
+ descriptor), and its central record is the original with only the local-header
720
+ offset changed. A filled member is written deflated (or stored, if the
721
+ compressor chooses) with its original timestamp, UTF-8 flag and attributes, no
722
+ extra field, no comment and no data descriptor.
723
+
724
+ ### 12.9 Not covered
725
+
726
+ - **Repeating sections** (a table row per line item). They would need: a
727
+ marker that delimits the repeated block in the file's own text (for example
728
+ a row whose first cell holds `{{ #each lines as line }}` and a closing marker),
729
+ the block located at the XML-element level (a `w:tr`, a run of `w:p`, an
730
+ `a:tr`, a worksheet `row`) rather than in text, copies of that element with
731
+ fresh ids where the format demands unique ones (`w14:paraId`, drawing
732
+ `docPr/@id`, bookmark ids, xlsx row numbers and every cell reference, shared
733
+ formula ranges, merged cells and defined names below it), the same
734
+ `iterations` bound, and a rule for what happens to a split marker. That is a
735
+ structural edit, not a text substitution, which is why it is a separate step.
736
+ - Text that is not in a container's text nodes: attribute values (alt text,
737
+ hyperlink targets), field codes (`instrText`), chart and SmartArt text, xlsx
738
+ header/footer strings (`oddHeader`), cell comments, formulas. A token in a
739
+ member that is not filled is refused (12.3); one in a filled member outside
740
+ its text nodes is not seen.
741
+ - A newline in a value is written as a newline character, which Word and
742
+ PowerPoint show as a space; turning it into a break element is not done.
743
+ - ODF (`.odt`, `.odp`, `.ods`), legacy binary formats, `.xlsb`.
744
+
745
+ ### 12.10 Shared rows
746
+
747
+ `fixtures/files/cases.json` holds the rows (hand-written from this section)
748
+ and `fixtures/files/*` the files, built by `scripts/files/build-fixtures.php`:
749
+ hand-authored minimal OOXML, plus a docx written by last-word and a pptx written
750
+ by dark-slide (then given a layout, a master and a split run). The rows are in
751
+ fancy-conformance's case-table shape, ready to become its `template/files`
752
+ suite; until then every leg reads them from here. A port is done when it passes
753
+ every row: `expect.text` by visible text (12.4), `expect.changed` as the exact
754
+ set of members whose bytes differ (all others byte-identical records),
755
+ `expect.contains` / `expect.absent` as raw substrings, `expect.error` as the
756
+ thrown error and as `inspectFile`'s single issue.
@@ -0,0 +1,50 @@
1
+ {
2
+ "writer": "fine-print",
3
+ "schema": "fine-print 0.3",
4
+ "notes": "An ALLOWLIST: a string field not matched here refuses tokens. link, color, font, fonts, page geometry and every style field are deliberately absent. First match wins, so a band's own patterns come before the generic text ones. Every band slot and a blocks band's run text refuse a value holding a field token ({page}, {section}, {date}, ...): fine-print resolves them there and has no escape.",
5
+ "fields": [
6
+ { "path": "title", "encode": "text" },
7
+ { "path": "page.header.left", "encode": "band" },
8
+ { "path": "page.header.center", "encode": "band" },
9
+ { "path": "page.header.right", "encode": "band" },
10
+ { "path": "page.footer.left", "encode": "band" },
11
+ { "path": "page.footer.center", "encode": "band" },
12
+ { "path": "page.footer.right", "encode": "band" },
13
+ { "path": "page.firstHeader.left", "encode": "band" },
14
+ { "path": "page.firstHeader.center", "encode": "band" },
15
+ { "path": "page.firstHeader.right", "encode": "band" },
16
+ { "path": "page.firstFooter.left", "encode": "band" },
17
+ { "path": "page.firstFooter.center", "encode": "band" },
18
+ { "path": "page.firstFooter.right", "encode": "band" },
19
+ { "path": "page.evenHeader.left", "encode": "band" },
20
+ { "path": "page.evenHeader.center", "encode": "band" },
21
+ { "path": "page.evenHeader.right", "encode": "band" },
22
+ { "path": "page.evenFooter.left", "encode": "band" },
23
+ { "path": "page.evenFooter.center", "encode": "band" },
24
+ { "path": "page.evenFooter.right", "encode": "band" },
25
+ { "path": "blocks.*.page.header.left", "encode": "band" },
26
+ { "path": "blocks.*.page.header.center", "encode": "band" },
27
+ { "path": "blocks.*.page.header.right", "encode": "band" },
28
+ { "path": "blocks.*.page.footer.left", "encode": "band" },
29
+ { "path": "blocks.*.page.footer.center", "encode": "band" },
30
+ { "path": "blocks.*.page.footer.right", "encode": "band" },
31
+ { "path": "blocks.*.page.firstHeader.left", "encode": "band" },
32
+ { "path": "blocks.*.page.firstHeader.center", "encode": "band" },
33
+ { "path": "blocks.*.page.firstHeader.right", "encode": "band" },
34
+ { "path": "blocks.*.page.firstFooter.left", "encode": "band" },
35
+ { "path": "blocks.*.page.firstFooter.center", "encode": "band" },
36
+ { "path": "blocks.*.page.firstFooter.right", "encode": "band" },
37
+ { "path": "blocks.*.page.evenHeader.left", "encode": "band" },
38
+ { "path": "blocks.*.page.evenHeader.center", "encode": "band" },
39
+ { "path": "blocks.*.page.evenHeader.right", "encode": "band" },
40
+ { "path": "blocks.*.page.evenFooter.left", "encode": "band" },
41
+ { "path": "blocks.*.page.evenFooter.center", "encode": "band" },
42
+ { "path": "blocks.*.page.evenFooter.right", "encode": "band" },
43
+ { "path": "page.*.blocks.**.runs.*.text", "encode": "band" },
44
+ { "path": "blocks.*.page.*.blocks.**.runs.*.text", "encode": "band" },
45
+ { "path": "**.runs.*.text", "encode": "text" },
46
+ { "path": "**.blocks.*.text", "encode": "text" },
47
+ { "path": "**.blocks.*.alt", "encode": "text" },
48
+ { "path": "**.blocks.*.src", "encode": "image", "mime": ["image/png", "image/jpeg"] }
49
+ ]
50
+ }
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "writer": "last-word",
3
3
  "schema": "last-word 0.x (Doc model shared with fine-print)",
4
- "notes": "An ALLOWLIST: a string field not matched here refuses tokens. link, chart option, color, font, fonts, page geometry and every style field are deliberately absent. Band slots refuse a value containing {page}/{pages}: last-word writes them as PAGE and NUMPAGES fields with str_replace and has no escape.",
4
+ "notes": "An ALLOWLIST: a string field not matched here refuses tokens. link, chart option, color, font, fonts, page geometry and every style field are deliberately absent. First match wins, so a band's own patterns come before the generic text ones. Band slots and a blocks band's run text refuse a value holding a field token ({page}, {section}, {date}, ...): last-word writes them as live Word fields and has no escape.",
5
5
  "fields": [
6
6
  { "path": "title", "encode": "text" },
7
+ { "path": "page.*.blocks.**.runs.*.text", "encode": "band" },
7
8
  { "path": "**.runs.*.text", "encode": "text" },
8
9
  { "path": "**.blocks.*.text", "encode": "text" },
9
10
  { "path": "**.blocks.*.alt", "encode": "text" },
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fancy-template"
7
- version = "0.2.0"
7
+ version = "0.4.0"
8
8
  description = "Document templates for the Fancy document writers: a writer document with {{ tokens }} and JSON directives, plus a declaration of the data it needs. inspect() at save time, validate_data() before render, render() to plain writer JSON. No code execution, no includes, bounded everywhere. One spec, three runtimes, held to a shared conformance table."
9
9
  readme = "README.md"
10
10
  # 3.11, matching every other Fancy Python package (3.10 reaches end of life on
@@ -28,6 +28,7 @@ from typing import Any
28
28
 
29
29
  from ._declarations import apply_defaults, validate_members
30
30
  from ._errors import TemplateError
31
+ from ._files import FileTemplatesNotImplementedError, inspect_file, render_file
31
32
  from ._inspector import Inspector, declared_variables
32
33
  from ._json import first_too_deep, plain
33
34
  from ._limits import DEFAULT_LIMITS, Limits, limits_from
@@ -37,15 +38,18 @@ from ._scanner import tokenize as _scan
37
38
 
38
39
  __all__ = [
39
40
  "DEFAULT_LIMITS",
41
+ "FileTemplatesNotImplementedError",
40
42
  "TemplateError",
41
43
  "inspect",
44
+ "inspect_file",
42
45
  "preview",
43
46
  "render",
47
+ "render_file",
44
48
  "tokenize",
45
49
  "validate_data",
46
50
  ]
47
51
 
48
- __version__ = "0.2.0"
52
+ __version__ = "0.4.0"
49
53
 
50
54
 
51
55
  def inspect(template: Any, options: dict[str, Any] | None = None) -> dict[str, Any]:
@@ -0,0 +1,40 @@
1
+ """Office files as templates (SPEC.md section 12): ``inspect_file`` and
2
+ ``render_file``. Implemented in the PHP leg only, so far.
3
+
4
+ They exist in this leg so that a caller gets a LOUD, named refusal rather than
5
+ a missing name or a silent no-op: a ``render_file`` that returned its input
6
+ unchanged would hand back a document full of ``{{ placeholders }}`` and look
7
+ like success. The behaviour to implement is SPEC.md section 12; the shared rows
8
+ and fixtures are in ``fixtures/files/``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Literal, NoReturn
14
+
15
+
16
+ class FileTemplatesNotImplementedError(NotImplementedError):
17
+ """Raised by ``inspect_file`` and ``render_file`` in this language.
18
+
19
+ Office-file templates are implemented in PHP (``Template::inspectFile``),
20
+ not yet here. ``code`` is always ``FILE_NOT_IMPLEMENTED``; ``operation``
21
+ names the function.
22
+ """
23
+
24
+ code = "FILE_NOT_IMPLEMENTED"
25
+
26
+ def __init__(self, operation: Literal["inspect_file", "render_file"]) -> None:
27
+ super().__init__(
28
+ f"{operation} is not yet implemented in the Python leg of fancy-template (SPEC.md section 12; PHP has it)."
29
+ )
30
+ self.operation = operation
31
+
32
+
33
+ def inspect_file(data: bytes, options: dict[str, Any] | None = None) -> NoReturn:
34
+ """The placeholders in a .docx / .pptx / .xlsx. Not yet implemented here: always raises."""
35
+ raise FileTemplatesNotImplementedError("inspect_file")
36
+
37
+
38
+ def render_file(data: bytes, values: Any, options: dict[str, Any] | None = None) -> NoReturn:
39
+ """Fill a .docx / .pptx / .xlsx in place. Not yet implemented here: always raises."""
40
+ raise FileTemplatesNotImplementedError("render_file")
@@ -9,6 +9,7 @@ is refused before it is built rather than after.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import re
12
13
  from collections.abc import Callable, Iterator
13
14
  from typing import Any
14
15
 
@@ -23,6 +24,13 @@ from ._limits import Limits
23
24
  from ._profile import Profile
24
25
  from ._scanner import LOOP_HEADS, is_digits, path_only, tokenize
25
26
 
27
+ # A writer's band field, the grammar last-word's Schema::FIELD_TOKEN and
28
+ # fine-print's share. Run over a VALUE, never over template text; a value holding
29
+ # one is refused (SPEC.md section 9).
30
+ BAND_FIELD = re.compile(
31
+ r'\{(?:(?:page|pages|section|sectionpages|author|title|filename)|(?:date|time)(?::[^{}"\\]+)?)\}'
32
+ )
33
+
26
34
 
27
35
  def escape_markdown(s: str) -> str:
28
36
  """CommonMark: a backslash before every ASCII punctuation character (SPEC.md section 9)."""
@@ -232,7 +240,7 @@ class Renderer:
232
240
  except TemplateError as e:
233
241
  raise TemplateError(e.code, path, variable) from None
234
242
 
235
- if field["encode"] == "band" and ("{page}" in piece or "{pages}" in piece):
243
+ if field["encode"] == "band" and BAND_FIELD.search(piece):
236
244
  raise TemplateError("VALUE_PAGE_FIELD", path, variable)
237
245
  out += escape(piece)
238
246
 
@@ -0,0 +1,92 @@
1
+ """User data must not become a live field: a value holding a band token is refused,
2
+ never rewritten. The PHP and TypeScript legs hold the same rows."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ import pytest
9
+
10
+ from fancy_template import TemplateError, render
11
+
12
+ BANDS = ["header", "footer", "firstHeader", "firstFooter", "evenHeader", "evenFooter"]
13
+
14
+
15
+ def tpl(writer: str, document: dict[str, Any]) -> dict[str, Any]:
16
+ return {"fancyTemplate": 1, "writer": writer, "variables": {"v": {"type": "string"}}, "document": document}
17
+
18
+
19
+ def code(template: dict[str, Any], value: str) -> str | None:
20
+ try:
21
+ render(template, {"v": value})
22
+ except TemplateError as e:
23
+ return e.code
24
+ return None
25
+
26
+
27
+ @pytest.mark.parametrize(
28
+ "value",
29
+ [
30
+ "{page}",
31
+ "{pages}",
32
+ "{section}",
33
+ "{sectionpages}",
34
+ "{author}",
35
+ "{title}",
36
+ "{filename}",
37
+ "{date}",
38
+ "{time}",
39
+ "see {section} here",
40
+ "{date:d MMMM yyyy}",
41
+ "{time:HH:mm}",
42
+ ],
43
+ )
44
+ @pytest.mark.parametrize("writer", ["fine-print", "last-word"])
45
+ def test_refuses_a_band_value_holding_any_field_token(writer: str, value: str) -> None:
46
+ assert code(tpl(writer, {"blocks": [], "page": {"footer": {"left": "{{ v }}"}}}), value) == "VALUE_PAGE_FIELD"
47
+
48
+
49
+ @pytest.mark.parametrize("value", ["{Page}", "{pagex}", "x{ page }", "{date:}", "{date:a{b}", "no braces"])
50
+ def test_keeps_a_value_that_is_only_brace_shaped(value: str) -> None:
51
+ out = render(tpl("fine-print", {"blocks": [], "page": {"footer": {"left": "{{ v }}"}}}), {"v": value})
52
+ assert out["page"]["footer"]["left"] == value
53
+
54
+
55
+ @pytest.mark.parametrize("key", BANDS)
56
+ @pytest.mark.parametrize("slot", ["left", "center", "right"])
57
+ def test_every_fine_print_band_slot(key: str, slot: str) -> None:
58
+ template = tpl("fine-print", {"blocks": [], "page": {key: {slot: "{{ v }}"}}})
59
+ assert code(template, "Ada") is None
60
+ assert code(template, "{section}") == "VALUE_PAGE_FIELD"
61
+
62
+
63
+ @pytest.mark.parametrize("key", BANDS)
64
+ def test_a_band_on_a_fine_print_break(key: str) -> None:
65
+ template = tpl(
66
+ "fine-print",
67
+ {"blocks": [{"type": "pageBreak", "page": {"orientation": "landscape", key: {"left": "{{ v }}"}}}]},
68
+ )
69
+ assert code(template, "Ada") is None
70
+ assert code(template, "{pages}") == "VALUE_PAGE_FIELD"
71
+
72
+
73
+ @pytest.mark.parametrize("writer", ["fine-print", "last-word"])
74
+ def test_a_blocks_band_and_a_table_cell_in_it(writer: str) -> None:
75
+ para = {"type": "paragraph", "runs": [{"text": "{{ v }}"}]}
76
+ cell = {"type": "table", "rows": [{"cells": [{"blocks": [para]}]}]}
77
+ for block in (para, cell):
78
+ assert (
79
+ code(tpl(writer, {"blocks": [], "page": {"footer": {"blocks": [block]}}}), "see {page}")
80
+ == "VALUE_PAGE_FIELD"
81
+ )
82
+
83
+
84
+ def test_a_body_run_keeps_a_token_shaped_value_literal() -> None:
85
+ assert code(tpl("fine-print", {"blocks": [{"type": "paragraph", "runs": [{"text": "{{ v }}"}]}]}), "{page}") is None
86
+
87
+
88
+ def test_page_geometry_on_a_break_stays_closed_to_tokens() -> None:
89
+ assert (
90
+ code(tpl("fine-print", {"blocks": [{"type": "pageBreak", "page": {"orientation": "{{ v }}"}}]}), "landscape")
91
+ is not None
92
+ )
@@ -24,7 +24,7 @@ from template_runner import run_template_case
24
24
  # version (package.json's range + the committed lockfile; composer.json's exact
25
25
  # require-dev). Move all of them together, and only after re-running the tables in
26
26
  # every runtime; the tests below fail otherwise.
27
- PINNED_SUITE_VERSION = "0.36.0"
27
+ PINNED_SUITE_VERSION = "0.44.0"
28
28
 
29
29
  SUITES = (
30
30
  "template/tokenize",
@@ -0,0 +1,28 @@
1
+ """Office-file templates (SPEC.md section 12) are PHP-only so far. This leg must
2
+ refuse LOUDLY and by name -- never return the input, never return nothing."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from fancy_template import FileTemplatesNotImplementedError, TemplateError, inspect_file, render_file
11
+
12
+ DOCX = (Path(__file__).resolve().parents[2] / "fixtures" / "files" / "proposal.docx").read_bytes()
13
+
14
+
15
+ def test_inspect_file_raises_the_named_error() -> None:
16
+ with pytest.raises(FileTemplatesNotImplementedError) as info:
17
+ inspect_file(DOCX)
18
+ assert info.value.code == "FILE_NOT_IMPLEMENTED"
19
+ assert info.value.operation == "inspect_file"
20
+ assert isinstance(info.value, NotImplementedError)
21
+
22
+
23
+ def test_render_file_raises_the_named_error_not_a_template_refusal() -> None:
24
+ with pytest.raises(FileTemplatesNotImplementedError) as info:
25
+ render_file(DOCX, {"client": "Ada"})
26
+ assert info.value.operation == "render_file"
27
+ assert not isinstance(info.value, TemplateError)
28
+ assert "SPEC.md section 12" in str(info.value)
@@ -1,18 +0,0 @@
1
- {
2
- "writer": "fine-print",
3
- "schema": "fine-print 0.3",
4
- "notes": "An ALLOWLIST: a string field not matched here refuses tokens. link, color, font, fonts, page geometry and every style field are deliberately absent. Band slots refuse a value containing {page}/{pages}: fine-print replaces them with str_replace and has no escape.",
5
- "fields": [
6
- { "path": "title", "encode": "text" },
7
- { "path": "**.runs.*.text", "encode": "text" },
8
- { "path": "**.blocks.*.text", "encode": "text" },
9
- { "path": "**.blocks.*.alt", "encode": "text" },
10
- { "path": "**.blocks.*.src", "encode": "image", "mime": ["image/png", "image/jpeg"] },
11
- { "path": "page.header.left", "encode": "band" },
12
- { "path": "page.header.center", "encode": "band" },
13
- { "path": "page.header.right", "encode": "band" },
14
- { "path": "page.footer.left", "encode": "band" },
15
- { "path": "page.footer.center", "encode": "band" },
16
- { "path": "page.footer.right", "encode": "band" }
17
- ]
18
- }
File without changes
File without changes