fancy-template 0.2.0__tar.gz → 0.3.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 (29) hide show
  1. {fancy_template-0.2.0 → fancy_template-0.3.0}/PKG-INFO +1 -1
  2. {fancy_template-0.2.0 → fancy_template-0.3.0}/SPEC.md +335 -0
  3. {fancy_template-0.2.0 → fancy_template-0.3.0}/pyproject.toml +1 -1
  4. {fancy_template-0.2.0 → fancy_template-0.3.0}/src/fancy_template/__init__.py +5 -1
  5. fancy_template-0.3.0/src/fancy_template/_files.py +40 -0
  6. fancy_template-0.3.0/tests/test_files.py +28 -0
  7. {fancy_template-0.2.0 → fancy_template-0.3.0}/.gitignore +0 -0
  8. {fancy_template-0.2.0 → fancy_template-0.3.0}/LICENSE +0 -0
  9. {fancy_template-0.2.0 → fancy_template-0.3.0}/README.md +0 -0
  10. {fancy_template-0.2.0 → fancy_template-0.3.0}/profiles/dark-slide.json +0 -0
  11. {fancy_template-0.2.0 → fancy_template-0.3.0}/profiles/fine-print.json +0 -0
  12. {fancy_template-0.2.0 → fancy_template-0.3.0}/profiles/holy-sheet.json +0 -0
  13. {fancy_template-0.2.0 → fancy_template-0.3.0}/profiles/last-word.json +0 -0
  14. {fancy_template-0.2.0 → fancy_template-0.3.0}/src/fancy_template/_dates.py +0 -0
  15. {fancy_template-0.2.0 → fancy_template-0.3.0}/src/fancy_template/_declarations.py +0 -0
  16. {fancy_template-0.2.0 → fancy_template-0.3.0}/src/fancy_template/_errors.py +0 -0
  17. {fancy_template-0.2.0 → fancy_template-0.3.0}/src/fancy_template/_filters.py +0 -0
  18. {fancy_template-0.2.0 → fancy_template-0.3.0}/src/fancy_template/_inspector.py +0 -0
  19. {fancy_template-0.2.0 → fancy_template-0.3.0}/src/fancy_template/_json.py +0 -0
  20. {fancy_template-0.2.0 → fancy_template-0.3.0}/src/fancy_template/_limits.py +0 -0
  21. {fancy_template-0.2.0 → fancy_template-0.3.0}/src/fancy_template/_numbers.py +0 -0
  22. {fancy_template-0.2.0 → fancy_template-0.3.0}/src/fancy_template/_profile.py +0 -0
  23. {fancy_template-0.2.0 → fancy_template-0.3.0}/src/fancy_template/_renderer.py +0 -0
  24. {fancy_template-0.2.0 → fancy_template-0.3.0}/src/fancy_template/_scanner.py +0 -0
  25. {fancy_template-0.2.0 → fancy_template-0.3.0}/src/fancy_template/py.typed +0 -0
  26. {fancy_template-0.2.0 → fancy_template-0.3.0}/tests/template_runner.py +0 -0
  27. {fancy_template-0.2.0 → fancy_template-0.3.0}/tests/test_conformance.py +0 -0
  28. {fancy_template-0.2.0 → fancy_template-0.3.0}/tests/test_security.py +0 -0
  29. {fancy_template-0.2.0 → fancy_template-0.3.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.3.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
@@ -414,5 +414,340 @@ PHP `Template::validateData`, TypeScript `validateData`, Python `validate_data`)
414
414
  - `preview(template, options?) -> document` -- `render` with each variable's
415
415
  `example` (else `default`).
416
416
  - `tokenize(text, options?) -> parts` (section 5).
417
+ - `inspectFile(bytes, options?)` and `renderFile(bytes, data, options?)` (section 12).
417
418
 
418
419
  Output key order is the template's, minus directive keys.
420
+
421
+ ## 12. Office files as templates
422
+
423
+ Owner's rule: **any file can be a template.** A customer's own `.docx`, `.pptx`
424
+ or `.xlsx` with `{{ tokens }}` typed into it is filled AS IT IS, in its own
425
+ text, with every other byte of the package kept. Nothing is read into a writer
426
+ model and written back, because a model drops what it does not carry (custom
427
+ styles, embedded objects, fields, comments, layout).
428
+
429
+ **Status.** PHP implements this section. TypeScript and Python expose the two
430
+ functions and throw `FileTemplatesNotImplementedError` (`code`
431
+ `FILE_NOT_IMPLEMENTED`, `operation` the function's name), never a silent
432
+ no-op. Shared rows and fixtures: `fixtures/files/` (section 12.10).
433
+
434
+ ### 12.1 API
435
+
436
+ | | PHP | TypeScript | Python |
437
+ |---|---|---|---|
438
+ | inspect | `Template::inspectFile(string $bytes, array $options = [])` | `inspectFile(bytes: Uint8Array, options?)` | `inspect_file(data: bytes, options=None)` |
439
+ | render | `Template::renderFile(string $bytes, mixed $data, array $options = []) : string` | `renderFile(bytes, data, options?) : Uint8Array` | `render_file(data, values, options=None) -> bytes` |
440
+
441
+ `inspectFile` returns
442
+ `{ ok, format, kind, issues: [{code, path, variable?}], placeholders: [{path, token, variable?}], variables: [name] }`:
443
+
444
+ - `format`: `docx`, `pptx` or `xlsx`; `kind`: `document` or `template` (from the
445
+ main part's content type, 12.3). Both `null` when the file is refused.
446
+ - `placeholders`: every token, in order (12.5): `path` is its location, `token`
447
+ the token's source text from `{{` to `}}` as it reads in the joined text,
448
+ `variable` the dotted path (absent for a string-literal token).
449
+ - `variables`: the HEAD of every token path, distinct, in first-use order: the
450
+ names the data must supply.
451
+ - A refused file (any `FILE_` code) is reported as the single issue, as an
452
+ oversized template is (section 10), and `inspectFile` does not throw for it.
453
+
454
+ `renderFile` returns the filled file's bytes, or throws `TemplateError`. Order:
455
+ open and classify the package (`FILE_` codes), inspect (first issue), validate
456
+ the data as `validateData` does (first error, path into the data), fill.
457
+
458
+ Options: `variables` (12.2), `limits` (`tokens`, `tokenLength`, `outputBytes`,
459
+ and `depth` for the data, apply; the rest have nothing to bound in a file) and
460
+ `fileLimits` (12.8). An
461
+ unknown `fileLimits` key, or a value that is not a positive integer, is refused
462
+ as an unknown `limits` key is.
463
+
464
+ ### 12.2 Declarations: a sidecar, not document properties
465
+
466
+ `options.variables` is a declaration map exactly as an envelope's `variables`
467
+ (section 3), checked by the same rules with the same codes and paths
468
+ (`DECL_INVALID` at `/variables/<name>/...`; a non-object is `TEMPLATE_SHAPE` at
469
+ `/variables`). With it, the file is inspected as a template is: a token whose
470
+ path is not declared is `TOKEN_UNDECLARED`, a token naming an `image` variable
471
+ is `TOKEN_IMAGE_FIELD` (an image cannot be text), data is validated with
472
+ `DATA_*` codes and defaults apply.
473
+
474
+ **Why a sidecar.** Custom document properties would put the declarations inside
475
+ the customer's file, where:
476
+
477
+ - anyone who opens it in Word sees and can edit them (File > Properties), so the
478
+ customer can break the template without knowing it is one;
479
+ - writing or updating them rewrites `docProps/custom.xml`, which is exactly the
480
+ kind of change to a customer's file this feature exists to avoid;
481
+ - they are flat strings, so a declaration would be JSON inside a property, a
482
+ second format to parse in three languages;
483
+ - some editors drop unknown custom properties on save.
484
+
485
+ The host already stores a record for every file it accepts as a template; the
486
+ declarations belong in that record, beside the file, where they can be versioned
487
+ and reused across new uploads of the same file.
488
+
489
+ **Without declarations** the names are INFERRED from the tokens, the way
490
+ `inspect` reports them: no token is undeclared, nothing is typed, and
491
+ `validateData` has no member to check (data must still be a plain JSON object:
492
+ `DATA_TYPE`, `DATA_TOO_DEEP`). Every token must still resolve at render: absent
493
+ is `TEMPLATE_MISSING`, a container is `TEMPLATE_NOT_SCALAR`, a wrong filter input
494
+ is `FILTER_INPUT`. A file has no `$each`, so `$index`, `$first` and `$last` are
495
+ `TOKEN_UNDECLARED` in both modes.
496
+
497
+ ### 12.3 Recognising the package
498
+
499
+ In this order; the first failure is the answer.
500
+
501
+ 1. An OLE compound file (first 8 bytes `D0 CF 11 E0 A1 B1 1A E1`) is
502
+ `FILE_ENCRYPTED` when its bytes contain the UTF-16LE stream name
503
+ `EncryptedPackage` (a password-protected OOXML file), else `FILE_UNSUPPORTED`
504
+ (a legacy binary `.doc`/`.xls`/`.ppt`).
505
+ 2. The zip is indexed and checked (12.8).
506
+ 3. `[Content_Types].xml` must be a member (exact name), else `FILE_UNSUPPORTED`.
507
+ It is read like every part (12.7). Each member's content type is its
508
+ `Override` (by part name `/` + member name, ASCII case-insensitive), else the
509
+ `Default` for its extension (case-insensitive), else none. Content types
510
+ compare case-insensitively.
511
+ 4. A member whose type is a macro-enabled or binary main type is
512
+ `FILE_MACRO_ENABLED`: `application/vnd.ms-word.document.macroEnabled.main+xml`,
513
+ `...ms-word.template.macroEnabledTemplate.main+xml`,
514
+ `...ms-powerpoint.{presentation,slideshow,template,addin}.macroEnabled.main+xml`,
515
+ `...ms-excel.{sheet,template,addin}.macroEnabled.main+xml`,
516
+ `...ms-excel.sheet.binary.macroEnabled.main`.
517
+ 5. Exactly one member must have one of these main types, else `FILE_UNSUPPORTED`:
518
+
519
+ | content type (`application/vnd.openxmlformats-officedocument.` +) | format | kind |
520
+ |---|---|---|
521
+ | `wordprocessingml.document.main+xml` | docx | document |
522
+ | `wordprocessingml.template.main+xml` | docx | template |
523
+ | `presentationml.presentation.main+xml`, `presentationml.slideshow.main+xml` | pptx | document |
524
+ | `presentationml.template.main+xml` | pptx | template |
525
+ | `spreadsheetml.sheet.main+xml` | xlsx | document |
526
+ | `spreadsheetml.template.main+xml` | xlsx | template |
527
+
528
+ 6. A VBA project anywhere is `FILE_MACRO_ENABLED`: any declared type (`Default`
529
+ or `Override`) equal to `application/vnd.ms-office.vbaProject`, or any member
530
+ whose name ends with `vbaProject.bin` (case-insensitive). A `.docx` may not
531
+ smuggle what a `.docm` declares.
532
+
533
+ **Macro-enabled files are refused, not passed through.** Passing one through
534
+ would hand back, from this system and filled by an agent, a file carrying code
535
+ nothing here inspected; macro documents are the most common malware carrier,
536
+ and last-word's template intake already refuses them. A customer who needs the
537
+ macros keeps them in their own copy. **Encrypted files are refused** because
538
+ there is nothing to fill: the text is ciphertext without the password, and
539
+ "passing it through" would return the input unchanged with every placeholder
540
+ still in it.
541
+
542
+ **The parts that are filled**, by content type (prefix as above):
543
+
544
+ | format | parts |
545
+ |---|---|
546
+ | docx | the main document (`document.main` or `template.main`), `header`, `footer`, `footnotes`, `endnotes`, `comments` (`wordprocessingml.*+xml`) |
547
+ | pptx | `slide`, `slideLayout`, `slideMaster`, `notesSlide`, `notesMaster` (`presentationml.*+xml`) |
548
+ | xlsx | `sharedStrings`, `worksheet` (`spreadsheetml.*+xml`) |
549
+
550
+ **Layouts and masters are filled.** A token on a master or layout (a footer's
551
+ `{{ client }}`, a confidentiality line) is drawn on every slide that uses it,
552
+ so leaving it unfilled would print `{{ client }}` on every slide; that is the
553
+ common reason to put text there at all. A placeholder's prompt text on a layout
554
+ ("Click to add title") is never shown in a slide show, so filling a token in it
555
+ is harmless. **Comments are filled** because they ride along with the file and
556
+ an unfilled token in one leaks the template into the customer's review pane.
557
+
558
+ **Every other `.xml` or `.rels` member is still read** (12.7, so a DOCTYPE in
559
+ it is refused) and searched for `{{`: one found is `TOKEN_FORBIDDEN_FIELD` at
560
+ `/<member name>`. A token in a place that is not filled is refused, not left
561
+ in the output, exactly as a profile refuses a field it does not list (section
562
+ 9): document properties, charts, SmartArt, a glossary, custom XML.
563
+
564
+ ### 12.4 Where text is: containers, runs, text nodes, barriers
565
+
566
+ Elements are matched by namespace URI and local name, never by prefix. Each
567
+ format accepts its transitional and strict namespace:
568
+
569
+ | format | namespace (transitional; strict) |
570
+ |---|---|
571
+ | docx | `http://schemas.openxmlformats.org/wordprocessingml/2006/main`; `http://purl.oclc.org/ooxml/wordprocessingml/main` |
572
+ | pptx | `http://schemas.openxmlformats.org/drawingml/2006/main`; `http://purl.oclc.org/ooxml/drawingml/main` |
573
+ | xlsx | `http://schemas.openxmlformats.org/spreadsheetml/2006/main`; `http://purl.oclc.org/ooxml/spreadsheetml/main` |
574
+
575
+ | format | container | run | text node | transparent run children | barriers |
576
+ |---|---|---|---|---|---|
577
+ | 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 |
578
+ | pptx | `p` | `r` | `t` child of a run | `rPr` | every other run child; `br` and `fld` inside the container (outside a run) |
579
+ | xlsx | `si`, `is` | `r` | `t` child of a run, or `t` child of the container itself | `rPr` | every other run child |
580
+
581
+ - A **container** is a paragraph (docx/pptx), a shared-string item `si` or an
582
+ inline string `is` (xlsx). A container inside another (a text box's paragraph
583
+ inside a run's drawing) is its own container; the run child holding it
584
+ (`drawing`) is a barrier in the outer one.
585
+ - A **run** is a run element that is a descendant of the innermost open
586
+ container and not inside another run. Containers' other children (`proofErr`,
587
+ `bookmarkStart`, `ins`, `hyperlink`, `fldSimple`, `sdt`, `commentRangeStart`,
588
+ `rPh`, ...) are neither text nor barriers; runs inside them are runs.
589
+ `delText` is not text: deleted text is not shown and is not joined.
590
+ - The text nodes of a container, in document order, split at barriers, form its
591
+ **spans**. A span's **joined text** is its nodes' decoded text (12.7)
592
+ concatenated. An empty text node (`<w:t/>`, `<w:t></w:t>`) is skipped.
593
+ - A container's **location** is `/<member name>#<label>`, the label being the
594
+ container's local name and its 0-based index among the part's containers in
595
+ START-tag order (`#p0`, `#si3`), except an xlsx `is` inside a `c` whose `r`
596
+ attribute is a cell reference (`[A-Z0-9]+`): then the label is that reference
597
+ (`#B4`). A text box's paragraph is numbered after the paragraph holding it.
598
+ - The **visible text** of a container, used by the shared rows, is its text
599
+ nodes' decoded text concatenated (barriers add nothing); in xlsx, a
600
+ `_xHHHH_` escape is then decoded to its character.
601
+
602
+ ### 12.5 Tokens, inspection, and the run-split rule
603
+
604
+ Each span whose joined text contains `{{` is scanned with section 5's grammar
605
+ and bounds. A scan failure (`TOKEN_UNCLOSED`, `TOKEN_TOO_LONG`, `TOKEN_EMPTY`,
606
+ `TOKEN_SYNTAX`) is one issue at the container's location, and that span
607
+ contributes no tokens. A token cannot cross a barrier or a container boundary,
608
+ so a `{{` before a tab and its `}}` after it is `TOKEN_UNCLOSED`.
609
+
610
+ Per token, in order: its path (`TOKEN_UNDECLARED` with `variable`, or
611
+ `TOKEN_IMAGE_FIELD`, 12.2), then each filter (`FILTER_UNKNOWN`, `FILTER_ARG`),
612
+ all at the container's location.
613
+
614
+ Issue order: sidecar declarations (12.2), then members in CENTRAL-DIRECTORY
615
+ order; within a filled part, containers in location order, spans in order,
616
+ tokens in order. Placeholders are in the same order. When the file's tokens
617
+ outnumber `limits.tokens`, the issues are exactly `[TEMPLATE_TOO_MANY_TOKENS at ""]`.
618
+
619
+ **The run-split rule.** Word splits text into runs for spell-check marks,
620
+ revision ids and formatting, so `{{ client }}` may be `{{ cli` in one run and
621
+ `ent }}` in the next. A token is found in the span's joined text, then:
622
+
623
+ 1. The value is written into the text node where the token STARTS, at the
624
+ token's position, replacing the token's characters in that node. That node's
625
+ run keeps its properties, so **the value takes the formatting of the run
626
+ holding `{{`**: `{{ cli` bold + `ent }}` italic fills bold.
627
+ 2. The rest of the token is cut from each following node it covers. Text in
628
+ those nodes outside the token stays, in its own run with its own formatting.
629
+ 3. **No node and no run is ever removed**, even one left empty: an empty run is
630
+ invisible, and a run may be what a bookmark, a comment range or a revision
631
+ is anchored to.
632
+ 4. Text around a token in a touched node is copied as its ORIGINAL bytes (its
633
+ entities, character references, comments and CDATA as written); only the
634
+ token's own bytes change. A node no token touches is not changed at all.
635
+
636
+ ### 12.6 Values
637
+
638
+ A token's value is computed as a `text` field's (sections 6 and 7): resolved
639
+ against the (defaulted) data, filtered, stringified. Then:
640
+
641
+ - It must hold only characters XML 1.0 can carry: a control character other
642
+ than TAB, LF and CR, or U+FFFE / U+FFFF, is `VALUE_XML_CHAR` at the location,
643
+ with `variable`. There is no escape for these in XML 1.0.
644
+ - It is **XML-escaped**: `&` `<` `>` as `&amp;` `&lt;` `&gt;`. Nothing in a
645
+ value can become markup.
646
+ - **xlsx**: a value is written as text in the shared string or inline string
647
+ that held the token, and the cell around it is never touched, so it stays a
648
+ text cell: a value starting with `=`, `+`, `-` or `@` is text, never a
649
+ formula. A `_xHHHH_` sequence in a value has its `_` written as `_x005F_`
650
+ (ECMA-376 ST_Xstring), so Excel shows the value as given instead of decoding it.
651
+ - **A whole-token number in an xlsx cell stays a string.** The token sits in a
652
+ shared string, which may be used by several cells, so turning one cell into a
653
+ number means editing the worksheet's cells and possibly splitting the string;
654
+ the cell's number format was authored for text; and the value has already been
655
+ stringified by section 6's rules, which is what the customer typed the token
656
+ into. A future option may write declared `number` values as number cells;
657
+ until then the rule is: text in, text out.
658
+ - **docx and xlsx white space**: when a touched text node's new text begins or
659
+ ends with white space and its start tag has no `xml:space` attribute,
660
+ ` xml:space="preserve"` is inserted before the tag's `>`, because Word and
661
+ Excel otherwise drop it. pptx text keeps its white space and needs none.
662
+ - A value is never re-scanned: `{{` in a value reaches the file literally.
663
+ - `limits.outputBytes` bounds the UTF-8 bytes of all values written
664
+ (`RENDER_TOO_LARGE` at `""`).
665
+
666
+ ### 12.7 Reading XML
667
+
668
+ Every member that is read (`[Content_Types].xml`, the filled parts and the
669
+ searched `.xml`/`.rels` members) is refused with `FILE_DOCTYPE` at
670
+ `/<member name>` when it contains `<!DOCTYPE` (ASCII case-insensitive),
671
+ before it is parsed. Nothing defines an entity, so nothing expands one, and
672
+ there is no DTD to fetch.
673
+
674
+ The filled parts and `[Content_Types].xml` are read by a forward-only lexer that
675
+ reports each element with its byte offsets. `FILE_XML` at `/<member name>` for:
676
+ a UTF-16 byte-order mark, an XML declaration naming an encoding other than
677
+ UTF-8, any `<!` other than a comment or CDATA, an unterminated comment, CDATA or
678
+ processing instruction, a mismatched or unclosed tag, more than one root, an
679
+ attribute that is unquoted, repeated or holds `<`, an unbound prefix, an entity
680
+ other than the five predefined ones, a character reference to a character XML
681
+ cannot hold, or an element inside a text node. Text-node content decodes the
682
+ five entities and character references, takes CDATA literally and skips
683
+ comments and processing instructions.
684
+
685
+ A filled part is re-read by the same lexer before it is written; failing that is
686
+ a bug in the implementation, not a file problem.
687
+
688
+ ### 12.8 The archive
689
+
690
+ The central directory is parsed and checked before any member is decompressed.
691
+ Bounds, through `options.fileLimits` (the defaults are last-word's):
692
+
693
+ | limit | default | measured as | code |
694
+ |---|---|---|---|
695
+ | `archiveBytes` | 67108864 | the file | `FILE_TOO_LARGE` |
696
+ | `entries` | 16384 | members in the central directory | `FILE_TOO_MANY_ENTRIES` |
697
+ | `partBytes` | 67108864 | any member's declared uncompressed size | `FILE_TOO_LARGE` |
698
+ | `expandedBytes` | 268435456 | all declared uncompressed sizes together | `FILE_TOO_LARGE` |
699
+ | `compressionRatio` | 1000 | a member's uncompressed / max(1, compressed) | `FILE_ZIP_BOMB` |
700
+
701
+ All with path `""`. Also refused, path `""`:
702
+
703
+ | code | when |
704
+ |---|---|
705
+ | `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 |
706
+ | `FILE_ENCRYPTED` | the encryption flag (bit 0) or the strong-encryption flag (bit 6), method 99, or an AES extra field (0x9901) |
707
+ | `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) |
708
+
709
+ **Nothing is fetched.** Relationships are never resolved: an external target
710
+ (`TargetMode="External"`, a remote image, a `file://` link) is bytes in a
711
+ `.rels` member, copied with it.
712
+
713
+ **Output.** The output has the input's members in the same local and central
714
+ order with the same archive comment. Every member not filled is its ORIGINAL
715
+ LOCAL RECORD, byte for byte (header, extra field, compressed data, data
716
+ descriptor), and its central record is the original with only the local-header
717
+ offset changed. A filled member is written deflated (or stored, if the
718
+ compressor chooses) with its original timestamp, UTF-8 flag and attributes, no
719
+ extra field, no comment and no data descriptor.
720
+
721
+ ### 12.9 Not covered
722
+
723
+ - **Repeating sections** (a table row per line item). They would need: a
724
+ marker that delimits the repeated block in the file's own text (for example
725
+ a row whose first cell holds `{{ #each lines as line }}` and a closing marker),
726
+ the block located at the XML-element level (a `w:tr`, a run of `w:p`, an
727
+ `a:tr`, a worksheet `row`) rather than in text, copies of that element with
728
+ fresh ids where the format demands unique ones (`w14:paraId`, drawing
729
+ `docPr/@id`, bookmark ids, xlsx row numbers and every cell reference, shared
730
+ formula ranges, merged cells and defined names below it), the same
731
+ `iterations` bound, and a rule for what happens to a split marker. That is a
732
+ structural edit, not a text substitution, which is why it is a separate step.
733
+ - Text that is not in a container's text nodes: attribute values (alt text,
734
+ hyperlink targets), field codes (`instrText`), chart and SmartArt text, xlsx
735
+ header/footer strings (`oddHeader`), cell comments, formulas. A token in a
736
+ member that is not filled is refused (12.3); one in a filled member outside
737
+ its text nodes is not seen.
738
+ - A newline in a value is written as a newline character, which Word and
739
+ PowerPoint show as a space; turning it into a break element is not done.
740
+ - ODF (`.odt`, `.odp`, `.ods`), legacy binary formats, `.xlsb`.
741
+
742
+ ### 12.10 Shared rows
743
+
744
+ `fixtures/files/cases.json` holds the rows (hand-written from this section)
745
+ and `fixtures/files/*` the files, built by `scripts/files/build-fixtures.php`:
746
+ hand-authored minimal OOXML, plus a docx written by last-word and a pptx written
747
+ by dark-slide (then given a layout, a master and a split run). The rows are in
748
+ fancy-conformance's case-table shape, ready to become its `template/files`
749
+ suite; until then every leg reads them from here. A port is done when it passes
750
+ every row: `expect.text` by visible text (12.4), `expect.changed` as the exact
751
+ set of members whose bytes differ (all others byte-identical records),
752
+ `expect.contains` / `expect.absent` as raw substrings, `expect.error` as the
753
+ thrown error and as `inspectFile`'s single issue.
@@ -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.3.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.3.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")
@@ -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)
File without changes
File without changes