diff --git a/contributing.md b/contributing.md index 9c59ceb..4d4974a 100644 --- a/contributing.md +++ b/contributing.md @@ -22,7 +22,7 @@ dotnet run --project IntegrationTests/IntegrationTests --configuration Release dotnet run --project src/Parchment.Tests --configuration Release -- --filter "FullyQualifiedName~Substitution" ``` -Snapshots: **Verify.TUnit + Verify.OpenXml + Morph.OpenXml.Skia**. Failed test writes `*.received.*`; accept by renaming to `*.verified.*`. PNG page renders fire on `net10.0` automatically when `Morph.OpenXml.Skia` is referenced. +Snapshots: **Verify.TUnit + Verify.OpenXml + Morph.Skia**. Failed test writes `*.received.*`; accept by renaming to `*.verified.*`. PNG page renders fire on `net10.0` automatically when `Morph.Skia` is referenced. CI: `src/appveyor.yml`. Build first installs every TTF/OTF in `src/Fonts/` into the Windows fonts dir, validating each via `System.Drawing.Text.PrivateFontCollection` so a CRLF-mangled font fails the build instead of producing "font not found" at test time. @@ -190,9 +190,34 @@ Loop-scoped tokens fall through — same root-only key limitation as `ExcelsiorT Tests: `StringListTests` (Parchment.Tests/Docx). Scenario: `src/Parchment.Tests/Scenarios/string-list/`. +### Editable field dispatch (`[EditableField]`) + extraction + +Two-way binding: marked members render as tagged content controls inside editable-range exceptions, the document is locked read-only, and `ParchmentExtractor` reads user edits back. User-facing docs in `readme.md` → "Editable fields (two-way binding)"; PARCH013–PARCH018 in the diagnostics list. + +**Runtime render path** (`src/Parchment/Editable/`): + +1. `EditableMap.Build(modelType, name)` mirrors `FormatMap.WalkType` (same `ChainGetter` / `ShouldDescend` / per-branch `visited` discipline). Entries carry getter, **parent-routed setter** (`property.SetValue(parentGetter(root), value)` — never the root; the leaf lives on the object at the end of the path), `CanReach` (parent chain non-null), kind, `IsNullable` (via `NullabilityInfoContext` for refs, `Nullable.GetUnderlyingType` for values), `MultiLine`, `DateFormat`. Model-shape errors throw at build: unsupported type, `bool?`, no public non-init setter (init detected via `IsExternalInit` modreq), conflicting `[ExcelsiorTable]`/`[Html]`/`[Markdown]`/`[StringSyntax]`. +2. `EditableTokenValidator` runs after `FormatTokenValidator` in `RegisterDocxTemplate`: plain member access only; duplicate path rejected (body part only — the dotted path is the control tag); editable token sharing a paragraph with an Excelsior/Format token rejected (structural splice/split clones paragraph halves and would corrupt the perm-range markers). A render-time guard in `ScopeTreeRunner` catches the same conflict for `TokenValue`-typed properties that only turn out structural at render. +3. `SettingsProtection.Apply` writes `w:documentProtection w:edit="readOnly" w:enforcement="1"` into the settings part at **registration** (canonical bytes), gated on `!map.IsEmpty && protection == ProtectionMode.WhenEditable`. Passwordless by design (determinism + no real security either way). +4. `ScopeTreeRunner` dispatch order: Excelsior → Format → **Editable** → StringList → Fluid. `TryResolveEditable` returns an internal `EditableToken`; `ProcessSubstitutionAsync` builds the `[permStart, sdt, permEnd]` triple via `EditableFieldBuilder` (sdtPr: site rPr clone, alias, tag = dotted path, sequential id from `EditableState`, `sdtLocked`, kind element; canonical values: `w14:checked`, `w:fullDate`, `w:listItem`) and splices via `EditableSplicer`. + - **Temporal kinds split.** `DateOnly`/`DateTime` → `Date` kind = native date picker with canonical `w:fullDate` (no display-text parse). `DateTimeOffset` → `DateTimeOffset` kind and `TimeOnly` → `Time` kind, both **plain `w:text` controls** carrying round-trippable ISO run text — because `w:fullDate` is a bare `DateTime` (no offset) and Word has no time-only picker, so the run text is the only faithful carrier. `EditableFieldReader.ReadDateTimeOffset`/`ReadTime` parse it back offset-preserving. `EditableFieldKind.MapKind` (runtime) and `MapEditableKind` (SG) must agree on this split — both list `DateTimeOffset`/`Time` and both live in lockstep with the two `EditableFieldKind` enum copies (runtime `Parchment.Generated`, SG mirror). The old code mapped `DateTimeOffset` onto the date picker and zeroed the offset on extract — regression-guarded by `ExtractTests.TemporalTypesRoundTrip` / `EditedDateTimeOffsetPreservesNewOffset`. +5. **Body-only**: `RegisteredDocxTemplate.RenderPartAsync` passes `EditableMap.Empty` for non-body parts — header/footer occurrences render as plain read-only mirrors. `ProcessLoopAsync`'s cloned runner also gets `EditableMap.Empty` (per-iteration controls would duplicate tags); if-branch inner runners keep the real map. + +**Extraction** (`src/Parchment/Extraction/`): `ParchmentExtractor.Extract` opens read-only, matches `body.Descendants()` tags against the map (per-type cached — Extract is per-request), reads per kind via `EditableFieldReader`, reports `FieldState` per field (`Extracted`/`Empty`/`Missing`/`ParseFailed`/`Duplicate` — first occurrence wins). `ExtractResult.ApplyTo` validates `CanReach` for every applicable field **before** mutating anything, then assigns; `Empty` → null for nullable members only. Default culture is `CultureInfo.InvariantCulture` **because** Fluid's `TemplateOptions.CultureInfo` defaults to invariant and `SharedFluid` doesn't override it — the render and parse cultures must match, and both default the same way. + +**SG path** (`src/Parchment.SourceGenerator/`): + +1. `ShapeBuilder` detects `Parchment.EditableFieldAttribute` via typed symbol (`GetTypeByMetadataName`, like Excelsior), reads `MultiLine`/`DateFormat` named args, maps the kind from the symbol (`MapEditableKind` — lockstep with runtime `EditableMap.MapKind`), and bakes primitives into `MemberEntry` (`IsEditable`, `EditableKind?`, `EditableIsNullable`, `HasUsableSetter`, `EditableMultiLine`, `EditableDateFormat`). +2. `ValidateEditableShape` fires PARCH013/014/015 per offending member whether or not a token references it — lockstep with `EditableMap.Build` throwing unconditionally at docx registration. Docx targets only; the markdown flow never builds the map, so markdown targets stay silent. +3. Token rules run in a **separate body-scoped pass** (`ValidateEditableTokens`): `DocxArchiveReader`/`DocxData` carry `BodyParagraphs` alongside the all-parts list (zip entry order isn't body-first, so the body part is tracked explicitly). The pass tracks loop scope silently (no PARCH001/005 re-reports) and emits PARCH016 (non-plain), PARCH017 (duplicate in body — header mirrors don't count), PARCH018 warning (any editable-resolved token inside a `{% for %}` body, root-pathed or loop-var-pathed). +4. `AccessorEmission` emits the fifth dataset `_Editables` (`EditableFieldMapEntry`: path, kind, `typeof(underlying)`, nullability, getter, block-bodied parent-routed setter with nullability-aware cast — `(decimal?)v` vs `(string)v!` — and `CanReach` lambda) registered via `GeneratedRegistration.RegisterEditable`. Invalid members (kind null / no setter) are skipped so emitted source stays compilable; the error diagnostic fails the build anyway. +5. `[ParchmentModel(Protection = ...)]` arrives as the enum's underlying int (SG can't reference Parchment.dll), lands on `TargetInfo` as the SG-side `ProtectionMode`, and is emitted as a third `RegisterDocxTemplate` argument **only when non-default** — keeping pre-existing generated output (and snapshots) byte-identical. + +**Lockstep**: runtime `EditableMap` walk/rules ↔ `ShapeBuilder`+`ValidateEditableShape`; runtime `EditableTokenValidator` ↔ `ValidateEditableTokens`; runtime `EditableMap.BuildSetter` parent-routing ↔ `AccessorEmission.EmitSetter`. Tighten in the same PR. Tests: `EditableFieldTests` + `ExtractTests` (runtime), `EditableFieldGeneratorTests` (SG). Scenario: `src/Parchment.Tests/Scenarios/editable-fields/`. + ### Determinism guarantee -See `readme.md` → "Determinism". Implementation discipline: avoid `w:rsid` randomness, never set `PackageProperties.Created`, no timestamps. `DeterminismTests.cs` renders a sample twice and asserts byte equality — don't break it. +See `readme.md` → "Determinism". Implementation discipline: avoid `w:rsid` randomness, never set `PackageProperties.Created`, no timestamps. Editable fields: sequential sdt/perm-range ids (`EditableState`), passwordless `w:documentProtection` (a password would need a random salt). `DeterminismTests.cs` renders samples twice (including an editable-field case) and asserts byte equality — don't break it. ### Scenario directories (`src/Parchment.Tests/Scenarios/`) @@ -279,6 +304,14 @@ Full rationale and alternatives in `readme.md` → "Source generator (recommende - **`ScopeTreeRunner.ProcessLoopAsync` attaches each iteration's clones to a scratch `Body` before running the nested scope tree**. Without this, nested `{% for %}`/`{% if %}` silently no-op: `open.Parent` returns null on a detached clone, `CaptureBetween` captures nothing, the inner block-tag text lands as literal `{% for ... %}`. Reverting to `parent.InsertAfter(clone, insertAnchor)` *before* the nested run breaks nested loops in a way only `LoopTests.NestedLoop` catches. +- **`ScopeTreeRunner.ProcessIfAsync` keeps the chosen branch positionally, not via anchors**: static paragraphs and tables inside a branch have no anchor bookmarks and no scope-tree nodes (`TokenScanner.Scan` skips token-free paragraphs), so the runner captures the chosen branch's physical range — everything between its tag paragraph (`IfBranch.TagAnchorName` / `IfNode.ElseAnchorName`) and the next boundary tag (`{% elsif %}` / `{% else %}` / `{% endif %}`). An anchor-derived keep-set silently drops static/table content in the chosen branch; `ConditionalTests.StaticParagraphInChosenBranchIsKept` and `TableInsideChosenBranchIsKeptAndSubstituted` guard against regressing. Same reason `IfNode.ElseAnchorName` (not `ElseBody.Count`) signals an else branch: a static-only else has an empty body but still owns a physical range. + +- **`SettingsProtection` must respect the CT_Settings sequence**: `w:settings` is a strict xsd:sequence — appending `w:documentProtection` at the end produces a file Word "repairs". Insertion goes before the first child not in the preceding-types set. Two naming traps when touching that set: the settings-level `w:trackChanges` element is SDK class `TrackRevisions` (NOT `TrackChanges`, which doesn't exist), and `RemoveDateAndTime` sits between `RemovePersonalInformation` and `DoNotDisplayPageBoundaries`. `EditableFieldTests.OutputValidates` runs `OpenXmlValidator` over the output — it catches ordering mistakes. + +- **`EditableSplicer` exists because `ParagraphSplicer` duplicates zero-width elements**: the clone-and-trim approach distributes elements with no text (bookmarks, `permStart`/`permEnd`, emptied sdt shells) into BOTH halves — harmless for anchor bookmarks (StripAll removes them), fatal for editable fields (duplicate perm ids, stray control shells) when a paragraph hosts more than one. `EditableSplicer` splits runs at exact token boundaries instead. Don't "simplify" editable splicing back onto `ParagraphSplicer.SpliceInline`; `EditableFieldTests.TwoEditableFieldsInOneParagraph` and `TokenStraddlingRunsSplicesCorrectly` guard it. + +- **SG-side `EditableFieldKind` / `ProtectionMode` are copies matched by member name**: the generator can't reference Parchment.dll, so emission writes `global::Parchment.Generated.EditableFieldKind.{name}` / `global::Parchment.ProtectionMode.{name}` from its own enum's `ToString()`. Renaming or reordering members on either side silently breaks generated code or protection pass-through — change both in the same PR (same class of trap as the `[ExcelsiorTable]` FQN-string match). + - **`OpenXmlMarkdownRenderer` is not thread-safe** — one instance per render. The `Stack` and `ObjectRenderers` are mutable. The `RegisteredTemplate` (cached canonical bytes + scope tree) IS immutable — concurrent renders work, each gets its own renderer. - **`appveyor.yml` font validation** — every TTF/OTF in `src/Fonts/` is loaded through `PrivateFontCollection` BEFORE copy to `%WINDIR%\Fonts`, catching Git CRLF corruption upfront. When adding a font, mark it binary in `.gitattributes` (already present for `*.ttf`, `*.otf`). diff --git a/readme.md b/readme.md index 93bc7a4..164da91 100644 --- a/readme.md +++ b/readme.md @@ -846,6 +846,145 @@ Behavior: - Numbered output: opt out of the auto path with `{{ Tags | numbered_list }}`. +## Editable fields (two-way binding) + +Mark model members with `[EditableField]` and the rendered docx becomes a fillable, locked form: + +- Each marked member renders as a Word **content control** (`w:sdt`) tagged with the member's dotted model path and typed per the member — plain text, checkbox, date picker, or dropdown. +- The rest of the document is locked: `w:documentProtection w:edit="readOnly"` is written into the output's settings, with an editable-range exception (`w:permStart`/`w:permEnd`, everyone) punched around each control. Users can fill the fields but not edit anything else; the controls themselves are locked against deletion (`w:lock="sdtLocked"`). +- `ParchmentExtractor` reads the values back out of the returned document and applies them onto a model instance — two-way binding for the editable subset. The document is a lossy projection of the model (loops expanded, formatting applied), so it is the *editable subset* that round-trips, merged onto a caller-supplied model. + +Model: + + + +```cs +public class OrderForm +{ + public required string Number; + + [EditableField] + public required string PurchaseOrder { get; set; } + + [EditableField] + public bool Approved { get; set; } + + [EditableField] + public OrderStatus Status { get; set; } + + [EditableField(MultiLine = true)] + public string? Notes { get; set; } +} + +public enum OrderStatus +{ + Draft, + Submitted, + Accepted +} +``` +snippet source | anchor + + +The template ([input.docx](/src/Parchment.Tests/Scenarios/editable-fields/input.docx)): + +![Template before render](/src/Parchment.Tests/Scenarios/editable-fields/input.png) + +Register and render normally: + + + +```cs +var templatePath = Path.Combine(ScenarioPath("editable-fields"), "input.docx"); + +var store = new TemplateStore(); +store.RegisterDocxTemplate("order-form", templatePath); + +var model = new OrderForm +{ + Number = "ORD-2026-042", + PurchaseOrder = "PO-77041", + Approved = true, + Status = OrderStatus.Submitted, + Notes = null +}; + +using var stream = new MemoryStream(); +await store.Render("order-form", model, stream); +``` +snippet source | anchor + + +The rendered output — editable fields as content controls, everything else read-only: + +![Rendered output](/src/Parchment.Tests/Scenarios/editable-fields/output.verified.png) + +After the user fills in the form in Word, extract the edited values back onto a model: + + + +```cs +var result = ParchmentExtractor.Extract(stream); + +result.ApplyTo(model); +``` +snippet source | anchor + + +`ExtractResult.Fields` reports a per-field outcome — `Extracted`, `Empty` (placeholder showing), `Missing` (control deleted, or the document wasn't produced from a template bound to this model), `ParseFailed` (with the raw text for diagnostics), or `Duplicate` — and `AllExtracted` is true when every field read cleanly. `ApplyTo` assigns `Extracted` values, applies `Empty` as null to nullable members only, and validates that every intermediate object on nested paths is non-null *before* mutating anything (throwing `ParchmentExtractionException` otherwise). Extraction needs no `TemplateStore` — controls are matched by tag, so a service that only receives filled-in documents never has to register the template. + +### Control kinds + +| Member type | Control | Round-trip value | +| --- | --- | --- | +| `string` | plain text (`MultiLine = true` allows line breaks) | the text | +| `bool` | checkbox | `w14:checked` — no text parsing | +| `DateOnly` / `DateTime` | date picker (`DateFormat` option, default `yyyy-MM-dd`) | canonical `w:fullDate`, maintained by Word's picker | +| `DateTimeOffset` | plain text (round-trippable ISO, default `yyyy-MM-ddTHH:mm:sszzz`) | the ISO text, parsed offset-preserving | +| `TimeOnly` | plain text (default `HH:mm:ss`) | the text | +| enums | dropdown, one item per member | `w:listItem` value | +| numeric types | plain text | text parsed with the extraction culture | + +Nullable variants are supported except `bool?` — a checkbox cannot represent null. Null values render as the control's grey placeholder and extract as `Empty`. Members must have a public non-init setter so extraction can write back. + +**Why the temporal split.** `DateOnly` and `DateTime` use Word's native date picker, whose `w:fullDate` is a canonical value the picker maintains — so those never depend on parsing display text. `DateTimeOffset` and `TimeOnly` get **plain-text** controls instead: Word has no offset-aware or time-only picker, and `w:fullDate` is a bare `DateTime` that cannot carry an offset. Their run text is therefore the source of truth, rendered in a round-trippable ISO format and parsed back with the offset intact. Two consequences worth knowing: + +- A `DateTime` comes back with `DateTimeKind.Unspecified` (`w:fullDate` carries no zone) — re-stamp the `Kind` where a specific one is required. Its time-of-day survives in `w:fullDate` even though the default `yyyy-MM-dd` format hides it; a time-bearing `DateFormat` (e.g. `"yyyy-MM-dd HH:mm"`) makes the time visible and editable as text. +- The `DateTimeOffset` / `TimeOnly` defaults keep seconds but not sub-second precision; a `DateFormat` such as `"o"` preserves it. Because these parse display text, the extraction culture must match the render culture (as with numerics). + +### Protection is cooperative, not security + +Word enforces `w:documentProtection` in its UI only. No password is set (a password would add no real protection while forcing non-deterministic salt generation into the output), so any user can lift the protection via Review → Restrict Editing, and any program can edit the underlying XML regardless. Editable fields are the right tool for *guiding* users to fill in specific values — not for tamper-proofing. + +Protection is applied at registration whenever the model declares at least one `[EditableField]` member. Opt out per template: + +```csharp +store.RegisterDocxTemplate("order-form", path, ProtectionMode.None); +``` + +or via the source-generator attribute: + +```csharp +[ParchmentModel("Templates/order-form.docx", Protection = ProtectionMode.None)] +``` + +With `ProtectionMode.None` the fields still render as tagged controls (and still extract); the rest of the document stays editable too. + +### Culture + +Checkbox, date, and dropdown values round-trip through canonical control state and never depend on display text. String content is taken verbatim. Only free-text *numerics* are culture-sensitive: they render via the Fluid template culture (invariant by default) and parse back with `Extract`'s `culture` parameter — which defaults to `CultureInfo.InvariantCulture` to match. If renders are configured for another culture, pass the same culture to `Extract`. + +### Rules and limitations + +- **Docx flow only.** Markdown templates substitute into text before parsing, so substitution sites don't exist in the output; `[EditableField]` is ignored there — same rule as `[ExcelsiorTable]` and `[Html]`/`[Markdown]`. +- **Document body only.** A token referencing an editable member in a header or footer renders as a plain read-only substitution — deliberate, so an editable body field can be mirrored read-only in a footer. Extraction reads the single body control. +- **Loops render read-only.** An editable token inside `{% for %}` would stamp one control per iteration and produce duplicate tags, so loop bodies render editable members as plain text (`PARCH018` warns at compile time). +- **One token per member** in the body (`PARCH017`) — the dotted path is the control's tag and must be unique for extraction. Plain member access only; filters are rejected (`PARCH016`). +- **No structural mixing.** An editable token cannot share a paragraph with an `[ExcelsiorTable]` / `[Html]` / `[Markdown]` token — structural replacement clones paragraph halves and would corrupt the editable range. Rejected at registration. +- `[EditableField]` cannot be combined with `[ExcelsiorTable]`, `[Html]`, `[Markdown]`, or `[StringSyntax]` on the same member (`PARCH015`); unsupported member types are rejected (`PARCH013`); a missing setter is rejected (`PARCH014`). All three also fail fast at runtime registration. +- **Static members are skipped**, matching the other per-template maps — see [Model binding limitations](#model-binding-limitations). + + ## Markdown template A markdown template is a `.md` file containing the full body of the document plus liquid tokens for substitution, looping, and conditional content. The template below combines headings, emphasis, a pipe table driven by a loop, an ordered list driven by a loop, and a blockquote chosen by an `{% if %}`: @@ -1458,11 +1597,55 @@ public partial class Outer // <-- partial **Docx only. Warning, not error.** The template's `word/settings.xml` does not include the `` element, which corresponds to Word's "Remove personal information from file properties on save" privacy option. Renders inherit the template's `settings.xml`, so any author / lastModifiedBy / revision metadata on the template propagates to every generated docx. To silence the warning, open the template in Word, go to File → Options → Trust Center → Trust Center Settings → Privacy Options, tick "Remove personal information from file properties on save", and re-save. +### `PARCH013` — `[EditableField]` member has an unsupported type + +**Docx only.** Supported types: `string`, `bool`, `DateOnly`, `DateTime`, `DateTimeOffset`, `TimeOnly`, enums, and the numeric primitives / `decimal` — plus nullable variants, except `bool?` (a checkbox cannot represent null). + +```csharp +public partial class Order +{ + [EditableField] + public List Items { get; set; } // collections are not editable fields +} +``` + + +### `PARCH014` — `[EditableField]` member has no usable setter + +**Docx only.** Extraction writes values back onto the model, so the member needs a public non-init setter (or a non-readonly field). `{ get; }`, `{ get; init; }`, expression-bodied getters, and readonly fields are rejected. + + +### `PARCH015` — `[EditableField]` combined with a conflicting attribute + +**Docx only.** An editable field is plain typed content, not rendered markup — combining it with `[ExcelsiorTable]`, `[Html]`, `[Markdown]`, or `[StringSyntax("html"/"markdown")]` on the same member is rejected. + + +### `PARCH016` — `[EditableField]` token with filters or complex expression + +**Docx only.** The editable render path is selected by attribute rather than via Fluid, so filter chains would be silently ignored. Use plain member access (`{{ PurchaseOrder }}` or `{{ Customer.Notes }}`). + + +### `PARCH017` — `[EditableField]` member referenced more than once in the body + +**Docx only.** The dotted path is the content control's tag, and extraction requires tags to be unique. Reference each editable member once in the document body. (A header or footer occurrence is fine — it renders as a plain read-only mirror and doesn't count.) + + +### `PARCH018` — `[EditableField]` token inside a loop + +**Docx only. Warning, not error.** Loop iterations would stamp one control per iteration and produce duplicate tags, so editable tokens inside `{% for %}` bodies render as plain read-only text instead of editable fields. + +``` +{% for line in Lines %} +{{ PurchaseOrder }} ← renders read-only inside the loop +{% endfor %} +``` + + ## Model binding limitations Parchment binds tokens by reflecting on the model type. Public properties and public fields — both **instance** and **static** — are bindable at every depth, including nested traversal like `{{ Customer.Address.City }}` whether each hop is a property or a field. The source generator's `ShapeBuilder` mirrors the same rules. The following kinds of members are **not** bound — a token referencing them fails registration (`ParchmentRegistrationException`) or compile-time validation (`PARCH001`). -**Static-member caveat**: static members participate in Fluid substitution (`{{ Logo }}` against `public static string Logo`) but **do not** participate in the per-template maps. `[ExcelsiorTable]` on a static collection, `[Html]` / `[Markdown]` on a static string, and auto-bullet-list dispatch on a static `IEnumerable` are silently treated as no-ops — the runtime map walkers and the SG dotted-path walker both skip static members. Mark the member instance, or wrap it in an instance computed property, when attribute-driven dispatch is required. +**Static-member caveat**: static members participate in Fluid substitution (`{{ Logo }}` against `public static string Logo`) but **do not** participate in the per-template maps. `[ExcelsiorTable]` on a static collection, `[Html]` / `[Markdown]` on a static string, `[EditableField]` on a static member, and auto-bullet-list dispatch on a static `IEnumerable` are silently treated as no-ops — the runtime map walkers and the SG dotted-path walker both skip static members. Mark the member instance, or wrap it in an instance computed property, when attribute-driven dispatch is required. ### Interfaces as the binding model @@ -1531,6 +1714,8 @@ dotnet run --project src/Parchment.Benchmarks --configuration Release Rendering the same template with the same model produces a byte-identical output. Useful for hash-based caching, dedup, and legal sign-off workflows. +[Editable fields](#editable-fields-two-way-binding) preserve the guarantee: content-control and editable-range ids are assigned sequentially in processing order, and document protection is passwordless by design — a password would require a random salt, breaking byte-identical output while adding no real security. + ## Icon diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 63941bf..c5f0de5 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;NU1608;NU1109;NU1901;PolyfillTargetsForNuget;SC023 - 3.0.3 + 3.1.0-beta.1 14.0 1.0.0 Parchment is a Word document generation library that combines docx templates or markdown with a .NET data model via liquid templating. diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index e3ed8bc..ad6ddf3 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -6,7 +6,7 @@ - + diff --git a/src/Parchment.SourceGenerator.Tests/EditableFieldGeneratorTests.AllKinds_EmitsEditableEntries#Sample_Order_ParchmentModel.g.verified.cs b/src/Parchment.SourceGenerator.Tests/EditableFieldGeneratorTests.AllKinds_EmitsEditableEntries#Sample_Order_ParchmentModel.g.verified.cs new file mode 100644 index 0000000..8e102b2 --- /dev/null +++ b/src/Parchment.SourceGenerator.Tests/EditableFieldGeneratorTests.AllKinds_EmitsEditableEntries#Sample_Order_ParchmentModel.g.verified.cs @@ -0,0 +1,56 @@ +//HintName: Sample_Order_ParchmentModel.g.cs +// +#nullable enable + +using System.Collections.Generic; +namespace Sample; +partial class Order +{ + public static string TemplatePath => "template.docx"; + public static string TemplateName => "Order"; + + static readonly KeyValuePair[] _Accessors_global__Sample_Order = + { + new("Number", new global::Fluid.Accessors.DelegateAccessor((o, _) => ((global::Sample.Order)o).Number)), + new("PurchaseOrder", new global::Fluid.Accessors.DelegateAccessor((o, _) => ((global::Sample.Order)o).PurchaseOrder)), + new("Approved", new global::Fluid.Accessors.DelegateAccessor((o, _) => ((global::Sample.Order)o).Approved)), + new("Delivery", new global::Fluid.Accessors.DelegateAccessor((o, _) => ((global::Sample.Order)o).Delivery)), + new("SignedAt", new global::Fluid.Accessors.DelegateAccessor((o, _) => ((global::Sample.Order)o).SignedAt)), + new("PickupTime", new global::Fluid.Accessors.DelegateAccessor((o, _) => ((global::Sample.Order)o).PickupTime)), + new("Status", new global::Fluid.Accessors.DelegateAccessor((o, _) => ((global::Sample.Order)o).Status)), + new("Discount", new global::Fluid.Accessors.DelegateAccessor((o, _) => ((global::Sample.Order)o).Discount)), + new("Instructions", new global::Fluid.Accessors.DelegateAccessor((o, _) => ((global::Sample.Order)o).Instructions)), + new("Customer", new global::Fluid.Accessors.DelegateAccessor((o, _) => ((global::Sample.Order)o).Customer)), + }; + static readonly KeyValuePair[] _Accessors_global__Sample_Status = + { + new("Draft", new global::Fluid.Accessors.DelegateAccessor((_, _) => global::Sample.Status.Draft)), + new("Final", new global::Fluid.Accessors.DelegateAccessor((_, _) => global::Sample.Status.Final)), + }; + static readonly KeyValuePair[] _Accessors_global__Sample_Customer = + { + new("Email", new global::Fluid.Accessors.DelegateAccessor((o, _) => ((global::Sample.Customer)o).Email)), + }; + static readonly global::Parchment.Generated.EditableFieldMapEntry[] _Editables = + { + new("PurchaseOrder", global::Parchment.Generated.EditableFieldKind.Text, typeof(string), false, o => ((global::Sample.Order)o).PurchaseOrder, (o, v) => { ((global::Sample.Order)o).PurchaseOrder = (string)v!; }, static o => true, false, null), + new("Approved", global::Parchment.Generated.EditableFieldKind.Checkbox, typeof(bool), false, o => ((global::Sample.Order)o).Approved, (o, v) => { ((global::Sample.Order)o).Approved = (bool)v!; }, static o => true, false, null), + new("Delivery", global::Parchment.Generated.EditableFieldKind.Date, typeof(global::System.DateTime), false, o => ((global::Sample.Order)o).Delivery, (o, v) => { ((global::Sample.Order)o).Delivery = (global::System.DateTime)v!; }, static o => true, false, "dd MMM yyyy"), + new("SignedAt", global::Parchment.Generated.EditableFieldKind.DateTimeOffset, typeof(global::System.DateTimeOffset), false, o => ((global::Sample.Order)o).SignedAt, (o, v) => { ((global::Sample.Order)o).SignedAt = (global::System.DateTimeOffset)v!; }, static o => true, false, null), + new("PickupTime", global::Parchment.Generated.EditableFieldKind.Time, typeof(global::System.TimeOnly), false, o => ((global::Sample.Order)o).PickupTime, (o, v) => { ((global::Sample.Order)o).PickupTime = (global::System.TimeOnly)v!; }, static o => true, false, null), + new("Status", global::Parchment.Generated.EditableFieldKind.DropDown, typeof(global::Sample.Status), false, o => ((global::Sample.Order)o).Status, (o, v) => { ((global::Sample.Order)o).Status = (global::Sample.Status)v!; }, static o => true, false, null), + new("Discount", global::Parchment.Generated.EditableFieldKind.Number, typeof(decimal), true, o => ((global::Sample.Order)o).Discount, (o, v) => { ((global::Sample.Order)o).Discount = (decimal?)v; }, static o => true, false, null), + new("Instructions", global::Parchment.Generated.EditableFieldKind.Text, typeof(string), false, o => ((global::Sample.Order)o).Instructions, (o, v) => { ((global::Sample.Order)o).Instructions = (string)v!; }, static o => true, true, null), + new("Customer.Email", global::Parchment.Generated.EditableFieldKind.Text, typeof(string), true, o => ((global::Sample.Order)o).Customer?.Email, (o, v) => { var p = ((global::Sample.Order)o).Customer; if (p != null) { p.Email = (string?)v; } }, o => ((global::Sample.Order)o).Customer != null, false, null), + }; + + public static void RegisterWith(global::Parchment.TemplateStore store, string? basePath = null) + { + global::Parchment.Generated.GeneratedRegistration.RegisterFluidAccessors(typeof(global::Sample.Order), _Accessors_global__Sample_Order); + global::Parchment.Generated.GeneratedRegistration.RegisterFluidAccessors(typeof(global::Sample.Status), _Accessors_global__Sample_Status); + global::Parchment.Generated.GeneratedRegistration.RegisterFluidAccessors(typeof(global::Sample.Customer), _Accessors_global__Sample_Customer); + global::Parchment.Generated.GeneratedRegistration.RegisterEditable(typeof(global::Sample.Order), _Editables); + var path = basePath is null ? TemplatePath : global::System.IO.Path.Combine(basePath, TemplatePath); + store.RegisterDocxTemplate(TemplateName, path); + } +} diff --git a/src/Parchment.SourceGenerator.Tests/EditableFieldGeneratorTests.UnsupportedType_ReportsParch013#Sample_Order_ParchmentModel.g.verified.cs b/src/Parchment.SourceGenerator.Tests/EditableFieldGeneratorTests.UnsupportedType_ReportsParch013#Sample_Order_ParchmentModel.g.verified.cs new file mode 100644 index 0000000..8455d51 --- /dev/null +++ b/src/Parchment.SourceGenerator.Tests/EditableFieldGeneratorTests.UnsupportedType_ReportsParch013#Sample_Order_ParchmentModel.g.verified.cs @@ -0,0 +1,28 @@ +//HintName: Sample_Order_ParchmentModel.g.cs +// +#nullable enable + +using System.Collections.Generic; +namespace Sample; +partial class Order +{ + public static string TemplatePath => "template.docx"; + public static string TemplateName => "Order"; + + static readonly KeyValuePair[] _Accessors_global__Sample_Order = + { + new("Items", new global::Fluid.Accessors.DelegateAccessor((o, _) => ((global::Sample.Order)o).Items)), + }; + static readonly global::Parchment.Generated.StringListMapEntry[] _StringLists = + { + new("Items", o => ((global::Sample.Order)o).Items), + }; + + public static void RegisterWith(global::Parchment.TemplateStore store, string? basePath = null) + { + global::Parchment.Generated.GeneratedRegistration.RegisterFluidAccessors(typeof(global::Sample.Order), _Accessors_global__Sample_Order); + global::Parchment.Generated.GeneratedRegistration.RegisterStringList(typeof(global::Sample.Order), _StringLists); + var path = basePath is null ? TemplatePath : global::System.IO.Path.Combine(basePath, TemplatePath); + store.RegisterDocxTemplate(TemplateName, path); + } +} diff --git a/src/Parchment.SourceGenerator.Tests/EditableFieldGeneratorTests.UnsupportedType_ReportsParch013.verified.txt b/src/Parchment.SourceGenerator.Tests/EditableFieldGeneratorTests.UnsupportedType_ReportsParch013.verified.txt new file mode 100644 index 0000000..112cd98 --- /dev/null +++ b/src/Parchment.SourceGenerator.Tests/EditableFieldGeneratorTests.UnsupportedType_ReportsParch013.verified.txt @@ -0,0 +1,17 @@ +{ + Diagnostics: [ + { + Location: : (5,1)-(5,32), + Message: Model 'Order' member 'Sample.Order.Items' is [EditableField] but its type 'System.Collections.Generic.List' is not supported. Supported: string, bool, DateOnly, DateTime, DateTimeOffset, TimeOnly, enums, and numeric types (nullable variants except bool? — a checkbox cannot represent null)., + Severity: Error, + Descriptor: { + Id: PARCH013, + Title: [EditableField] member has an unsupported type, + MessageFormat: Model '{0}' member '{1}' is [EditableField] but its type '{2}' is not supported. Supported: string, bool, DateOnly, DateTime, DateTimeOffset, TimeOnly, enums, and numeric types (nullable variants except bool? — a checkbox cannot represent null)., + Category: Parchment, + DefaultSeverity: Error, + IsEnabledByDefault: true + } + } + ] +} \ No newline at end of file diff --git a/src/Parchment.SourceGenerator.Tests/EditableFieldGeneratorTests.cs b/src/Parchment.SourceGenerator.Tests/EditableFieldGeneratorTests.cs new file mode 100644 index 0000000..db54a88 --- /dev/null +++ b/src/Parchment.SourceGenerator.Tests/EditableFieldGeneratorTests.cs @@ -0,0 +1,336 @@ +public class EditableFieldGeneratorTests +{ + const string editableModel = + """ + using System; + using Parchment; + + namespace Sample; + + public enum Status + { + Draft, + Final + } + + public class Customer + { + [EditableField] + public string? Email { get; set; } + } + + [ParchmentModel("template.docx")] + public partial class Order + { + public string Number { get; set; } = ""; + + [EditableField] + public string PurchaseOrder { get; set; } = ""; + + [EditableField] + public bool Approved { get; set; } + + [EditableField(DateFormat = "dd MMM yyyy")] + public DateTime Delivery { get; set; } + + [EditableField] + public DateTimeOffset SignedAt { get; set; } + + [EditableField] + public TimeOnly PickupTime { get; set; } + + [EditableField] + public Status Status { get; set; } + + [EditableField] + public decimal? Discount { get; set; } + + [EditableField(MultiLine = true)] + public string Instructions { get; set; } = ""; + + public Customer Customer { get; set; } = new(); + } + """; + + [Test] + public Task AllKinds_EmitsEditableEntries() + { + // Covers per-kind mapping, nullable value type (decimal?), nullable annotated reference + // (Customer.Email), MultiLine / DateFormat options, and the parent-routed setter + + // CanReach emission for the nested path. + var result = GeneratorDriver.Run( + editableModel, + "{{ PurchaseOrder }}", + "{{ Approved }}", + "{{ Delivery }}", + "{{ SignedAt }}", + "{{ PickupTime }}", + "{{ Status }}", + "{{ Discount }}", + "{{ Instructions }}", + "{{ Customer.Email }}"); + return Verify(result); + } + + [Test] + public Task UnsupportedType_ReportsParch013() + { + var source = + """ + using System.Collections.Generic; + using Parchment; + + namespace Sample; + + [ParchmentModel("template.docx")] + public partial class Order + { + [EditableField] + public List Items { get; set; } = new(); + } + """; + var result = GeneratorDriver.Run(source, "x"); + return Verify(result); + } + + [Test] + public async Task NullableBool_ReportsParch013() + { + var source = + """ + using Parchment; + + namespace Sample; + + [ParchmentModel("template.docx")] + public partial class Order + { + [EditableField] + public bool? Maybe { get; set; } + } + """; + var result = GeneratorDriver.Run(source, "x"); + var ids = result.Results.Single().Diagnostics.Select(_ => _.Id).ToList(); + await Assert.That(ids).Contains("PARCH013"); + } + + [Test] + public async Task NoUsableSetter_ReportsParch014() + { + var source = + """ + using Parchment; + + namespace Sample; + + [ParchmentModel("template.docx")] + public partial class Order + { + public string Prefix = ""; + + [EditableField] + public string Computed => Prefix; + + [EditableField] + public string InitOnly { get; init; } = ""; + } + """; + var result = GeneratorDriver.Run(source, "x"); + var ids = result.Results.Single().Diagnostics.Select(_ => _.Id).ToList(); + await Assert.That(ids.Count(_ => _ == "PARCH014")).IsEqualTo(2); + } + + [Test] + public async Task ConflictingAttribute_ReportsParch015() + { + var source = + """ + using Parchment; + + namespace Sample; + + [System.AttributeUsage(System.AttributeTargets.Property)] + public sealed class HtmlAttribute : System.Attribute + { + } + + [ParchmentModel("template.docx")] + public partial class Order + { + [EditableField] + [Html] + public string Body { get; set; } = ""; + } + """; + var result = GeneratorDriver.Run(source, "x"); + var ids = result.Results.Single().Diagnostics.Select(_ => _.Id).ToList(); + await Assert.That(ids).Contains("PARCH015"); + } + + [Test] + public async Task TokenWithFilter_ReportsParch016() + { + var result = GeneratorDriver.Run(editableModel, "{{ PurchaseOrder | upcase }}"); + var ids = result.Results.Single().Diagnostics.Select(_ => _.Id).ToList(); + await Assert.That(ids).Contains("PARCH016"); + } + + [Test] + public async Task DuplicateBodyToken_ReportsParch017() + { + var result = GeneratorDriver.Run( + editableModel, + "{{ PurchaseOrder }}", + "again: {{ PurchaseOrder }}"); + var ids = result.Results.Single().Diagnostics.Select(_ => _.Id).ToList(); + await Assert.That(ids.Count(_ => _ == "PARCH017")).IsEqualTo(1); + } + + [Test] + public async Task HeaderMirror_DoesNotReportDuplicate() + { + // The runtime dispatches editable fields only in the document body; a header occurrence + // renders as a plain read-only mirror. Duplicate detection must be body-scoped. + var setup = GeneratorDriver.CreateDriverWithDocxes( + editableModel, + ("template.docx", GeneratorDriver.BuildDocxBytesWithHeader( + bodyParagraphs: ["{{ PurchaseOrder }}"], + headerParagraphs: ["PO: {{ PurchaseOrder }}"]))); + var result = setup.Driver.RunGenerators(setup.Compilation).GetRunResult(); + var ids = result.Results.Single().Diagnostics.Select(_ => _.Id).ToList(); + await Assert.That(ids).IsEmpty(); + } + + [Test] + public async Task RootPathTokenInsideLoop_ReportsParch018Warning() + { + var source = + """ + using System.Collections.Generic; + using Parchment; + + namespace Sample; + + public class Line + { + public string Description { get; set; } = ""; + } + + [ParchmentModel("template.docx")] + public partial class Order + { + [EditableField] + public string PurchaseOrder { get; set; } = ""; + + public List Lines { get; set; } = new(); + } + """; + var result = GeneratorDriver.Run( + source, + "{% for line in Lines %}", + "{{ PurchaseOrder }}", + "{% endfor %}"); + var diagnostics = result.Results.Single().Diagnostics; + var warning = diagnostics.Single(_ => _.Id == "PARCH018"); + await Assert.That(warning.Severity).IsEqualTo(DiagnosticSeverity.Warning); + } + + [Test] + public async Task LoopVariablePathToken_ReportsParch018Warning() + { + var source = + """ + using System.Collections.Generic; + using Parchment; + + namespace Sample; + + public class Line + { + [EditableField] + public string Note { get; set; } = ""; + } + + [ParchmentModel("template.docx")] + public partial class Order + { + public List Lines { get; set; } = new(); + } + """; + var result = GeneratorDriver.Run( + source, + "{% for line in Lines %}", + "{{ line.Note }}", + "{% endfor %}"); + var ids = result.Results.Single().Diagnostics.Select(_ => _.Id).ToList(); + await Assert.That(ids).Contains("PARCH018"); + } + + [Test] + public async Task StaticEditableMember_IsIgnored() + { + var source = + """ + using Parchment; + + namespace Sample; + + [ParchmentModel("template.docx")] + public partial class Order + { + [EditableField] + public static string Logo { get; set; } = ""; + } + """; + var result = GeneratorDriver.Run(source, "{{ Logo }}"); + var runResult = result.Results.Single(); + await Assert.That(runResult.Diagnostics).IsEmpty(); + + var generated = runResult.GeneratedSources.Single().SourceText.ToString(); + await Assert.That(generated).DoesNotContain("_Editables"); + } + + [Test] + public async Task ProtectionNone_IsPassedToRegistration() + { + var source = + """ + using Parchment; + + namespace Sample; + + [ParchmentModel("template.docx", Protection = ProtectionMode.None)] + public partial class Order + { + [EditableField] + public string PurchaseOrder { get; set; } = ""; + } + """; + var result = GeneratorDriver.Run(source, "{{ PurchaseOrder }}"); + var generated = result.Results.Single().GeneratedSources.Single().SourceText.ToString(); + await Assert.That(generated).Contains("store.RegisterDocxTemplate(TemplateName, path, global::Parchment.ProtectionMode.None);"); + } + + [Test] + public async Task MarkdownTarget_IgnoresEditableMembers() + { + // Editable fields are docx-only, mirroring [Html]/[ExcelsiorTable]: the runtime markdown + // flow never builds the editable map, so the SG stays silent too. + var source = + """ + using Parchment; + + namespace Sample; + + [ParchmentModel("template.md")] + public partial class Order + { + [EditableField] + public string PurchaseOrder { get; set; } = ""; + } + """; + var result = GeneratorDriver.RunMarkdown(source, "PO: {{ PurchaseOrder | upcase }}"); + var ids = result.Results.Single().Diagnostics.Select(_ => _.Id).ToList(); + await Assert.That(ids).IsEmpty(); + } +} diff --git a/src/Parchment.SourceGenerator.Tests/GeneratorDriver.cs b/src/Parchment.SourceGenerator.Tests/GeneratorDriver.cs index d38ea6a..86ada83 100644 --- a/src/Parchment.SourceGenerator.Tests/GeneratorDriver.cs +++ b/src/Parchment.SourceGenerator.Tests/GeneratorDriver.cs @@ -13,12 +13,28 @@ public ParchmentModelAttribute(string templatePath) } public string TemplatePath { get; } + + public ProtectionMode Protection { get; set; } + } + + public enum ProtectionMode + { + WhenEditable, + None } public sealed class TemplateStore { } [System.AttributeUsage(System.AttributeTargets.Property, AllowMultiple = false, Inherited = false)] public sealed class ExcelsiorTableAttribute : System.Attribute { } + + [System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Field, AllowMultiple = false, Inherited = false)] + public sealed class EditableFieldAttribute : System.Attribute + { + public bool MultiLine { get; set; } + + public string? DateFormat { get; set; } + } } """; @@ -58,7 +74,9 @@ public static DriverSetup CreateDriverWithDocxes( "GeneratorTest", syntaxTrees, BuildReferences(), - new(OutputKind.DynamicallyLinkedLibrary)); + // Nullable context on so `string?` members carry NullableAnnotation.Annotated — + // ShapeBuilder reads it to decide editable-field nullability. + new(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); var additionalTexts = texts.ToImmutable(); var driver = CSharpGeneratorDriver.Create( @@ -73,6 +91,58 @@ public static DriverSetup CreateDriverWithDocxes( public static byte[] BuildDocxBytes(params string[] paragraphs) => BuildDocx(paragraphs); + /// + /// A docx whose body and default header both carry paragraphs — for validating that + /// body-scoped editable rules ignore header occurrences (the read-only mirror pattern). + /// + public static byte[] BuildDocxBytesWithHeader(string[] bodyParagraphs, string[] headerParagraphs) + { + using var stream = new MemoryStream(); + using (var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document)) + { + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new(new Body()); + var body = mainPart.Document.Body!; + + var headerPart = mainPart.AddNewPart("rIdHeader"); + var header = new Header(); + foreach (var text in headerParagraphs) + { + header.Append(BuildParagraph(text)); + } + + headerPart.Header = header; + + foreach (var text in bodyParagraphs) + { + body.Append(BuildParagraph(text)); + } + + body.Append( + new SectionProperties( + new HeaderReference + { + Type = HeaderFooterValues.Default, + Id = "rIdHeader" + })); + + var settingsPart = mainPart.AddNewPart(); + settingsPart.Settings = new( + new RemovePersonalInformation(), + new RemoveDateAndTime()); + } + + return stream.ToArray(); + } + + static Paragraph BuildParagraph(string text) => + new( + new Run( + new Text(text) + { + Space = SpaceProcessingModeValues.Preserve + })); + public static byte[] BuildDocxBytesWithoutPrivacyFlag(params string[] paragraphs) => BuildDocx(paragraphs, removePersonalInformation: false); diff --git a/src/Parchment.SourceGenerator/AccessorEmission.cs b/src/Parchment.SourceGenerator/AccessorEmission.cs index 82d667c..b1a097a 100644 --- a/src/Parchment.SourceGenerator/AccessorEmission.cs +++ b/src/Parchment.SourceGenerator/AccessorEmission.cs @@ -16,6 +16,10 @@ /// String-list entries — dotted path + getter for every /// IEnumerable<string> property that isn't already owned by /// [ExcelsiorTable]. +/// Editable entries — dotted path + kind + getter + setter + reachability +/// check for every valid [EditableField] member. Setters route through the parent +/// object (never the root — the leaf lives on the object at the end of the path), matching +/// the runtime EditableMap.BuildSetter. /// /// /// Path-getters are emitted as inline cast-and-null-chain lambdas: @@ -42,6 +46,7 @@ public sealed record EmittedAccessors( var excelsiorEntries = new List<(List Path, string ElementFqn, string? HeadingParagraphStyle, string? BodyParagraphStyle)>(); var formatEntries = new List<(List Path, FormatMapKind Kind)>(); var stringListEntries = new List>(); + var editableEntries = new List<(List Path, MemberEntry Member)>(); // Fluid accessors: one block per non-empty type in the shape (System types & enums end // up with 0 members and are skipped). @@ -68,12 +73,14 @@ public sealed record EmittedAccessors( typesByFqn, excelsiorEntries, formatEntries, - stringListEntries); + stringListEntries, + editableEntries); if (fluidBlocks.Count == 0 && excelsiorEntries.Count == 0 && formatEntries.Count == 0 && - stringListEntries.Count == 0) + stringListEntries.Count == 0 && + editableEntries.Count == 0) { return null; } @@ -85,6 +92,7 @@ public sealed record EmittedAccessors( EmitExcelsiorBlock(fields, registrations, rootFqn, excelsiorEntries); EmitFormatBlock(fields, registrations, rootFqn, formatEntries); EmitStringListBlock(fields, registrations, rootFqn, stringListEntries); + EmitEditableBlock(fields, registrations, rootFqn, editableEntries); fields.TrimTrailingNewlines(); registrations.TrimTrailingNewlines(); @@ -99,7 +107,8 @@ static void WalkForMaps( Dictionary typesByFqn, List<(List, string, string?, string?)> excelsior, List<(List, FormatMapKind)> formats, - List> stringLists) + List> stringLists, + List<(List, MemberEntry)> editables) { if (!typesByFqn.TryGetValue(currentTypeFqn, out var typeEntry)) { @@ -152,6 +161,18 @@ static void WalkForMaps( continue; } + if (member.IsEditable) + { + // Invalid editable members (unsupported type, no setter) already carry an error + // diagnostic (PARCH013/014) — skip them so the emitted source stays compilable. + if (member is { EditableKind: not null, HasUsableSetter: true }) + { + editables.Add((nextPath, member)); + } + + continue; + } + // Descend only into POCO branches — types with members. Enumerables and system types // end up with 0 members in the shape, so this filter naturally skips them. if (!typesByFqn.TryGetValue(member.TypeFullyQualifiedName, out var nextType) || @@ -165,7 +186,7 @@ static void WalkForMaps( continue; } - WalkForMaps(member.TypeFullyQualifiedName, nextPath, visited, typesByFqn, excelsior, formats, stringLists); + WalkForMaps(member.TypeFullyQualifiedName, nextPath, visited, typesByFqn, excelsior, formats, stringLists, editables); visited.Remove(member.TypeFullyQualifiedName); } } @@ -328,6 +349,125 @@ static void EmitStringListBlock( registrations.AppendLine($" global::Parchment.Generated.GeneratedRegistration.RegisterStringList(typeof({rootFqn}), _StringLists);"); } + static void EmitEditableBlock( + StringBuilder fields, + StringBuilder registrations, + string rootFqn, + List<(List Path, MemberEntry Member)> entries) + { + if (entries.Count == 0) + { + return; + } + + fields.Append( + """ + static readonly global::Parchment.Generated.EditableFieldMapEntry[] _Editables = + { + + """); + foreach (var (path, member) in entries) + { + fields.Append(" new(\""); + fields.AppendJoin('.', path); + fields.Append("\", global::Parchment.Generated.EditableFieldKind."); + fields.Append(member.EditableKind!.Value); + fields.Append(", typeof("); + fields.Append(StripNullableSuffix(member.TypeFullyQualifiedName)); + fields.Append("), "); + fields.Append(member.EditableIsNullable ? "true" : "false"); + fields.Append(", "); + EmitGetter(fields, rootFqn, path); + fields.Append(", "); + EmitSetter(fields, rootFqn, path, member); + fields.Append(", "); + EmitCanReach(fields, rootFqn, path); + fields.Append(", "); + fields.Append(member.EditableMultiLine ? "true" : "false"); + fields.Append(", "); + fields.Append(ToStringLiteral(member.EditableDateFormat)); + fields.AppendLine("),"); + } + + fields.Append( + """ + }; + + """); + + registrations.AppendLine($" global::Parchment.Generated.GeneratedRegistration.RegisterEditable(typeof({rootFqn}), _Editables);"); + } + + // "decimal?" → "decimal" for typeof() — the runtime entry carries the underlying type + // (nullability travels separately as IsNullable). Char compare instead of EndsWith: the + // char overload doesn't exist on netstandard2.0. + static string StripNullableSuffix(string fqn) => + fqn.Length > 0 && fqn[fqn.Length - 1] == '?' ? fqn.Substring(0, fqn.Length - 1) : fqn; + + /// + /// Setter shape mirrors runtime EditableMap.BuildSetter: walk to the PARENT object + /// (null-conditional per hop), assign on it — never on the root. Callers validate + /// reachability first, so a null parent is a silent no-op rather than a throw. + /// + static void EmitSetter(StringBuilder sb, string rootFqn, List path, MemberEntry member) + { + var castFqn = member.TypeFullyQualifiedName; + if (member.EditableIsNullable && + castFqn[castFqn.Length - 1] != '?') + { + castFqn += "?"; + } + + var cast = member.EditableIsNullable ? $"({castFqn})v" : $"({castFqn})v!"; + + sb.Append("(o, v) => { "); + if (path.Count == 1) + { + sb.Append("(("); + sb.Append(rootFqn); + sb.Append(")o)."); + sb.Append(path[0]); + sb.Append(" = "); + sb.Append(cast); + sb.Append("; }"); + return; + } + + sb.Append("var p = (("); + sb.Append(rootFqn); + sb.Append(")o)"); + AppendParentPath(sb, path); + sb.Append("; if (p != null) { p."); + sb.Append(path[path.Count - 1]); + sb.Append(" = "); + sb.Append(cast); + sb.Append("; } }"); + } + + static void EmitCanReach(StringBuilder sb, string rootFqn, List path) + { + if (path.Count == 1) + { + sb.Append("static o => true"); + return; + } + + sb.Append("o => (("); + sb.Append(rootFqn); + sb.Append(")o)"); + AppendParentPath(sb, path); + sb.Append(" != null"); + } + + static void AppendParentPath(StringBuilder sb, List path) + { + for (var i = 0; i < path.Count - 1; i++) + { + sb.Append(i == 0 ? "." : "?."); + sb.Append(path[i]); + } + } + static void EmitGetter(StringBuilder sb, string rootFqn, List path) { sb.Append("o => (("); diff --git a/src/Parchment.SourceGenerator/Diagnostics.cs b/src/Parchment.SourceGenerator/Diagnostics.cs index e9ba98d..2eda5bd 100644 --- a/src/Parchment.SourceGenerator/Diagnostics.cs +++ b/src/Parchment.SourceGenerator/Diagnostics.cs @@ -92,4 +92,52 @@ static class Diagnostics category: "Parchment", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor EditableUnsupportedType = new( + id: "PARCH013", + title: "[EditableField] member has an unsupported type", + messageFormat: "Model '{0}' member '{1}' is [EditableField] but its type '{2}' is not supported. Supported: string, bool, DateOnly, DateTime, DateTimeOffset, TimeOnly, enums, and numeric types (nullable variants except bool? — a checkbox cannot represent null).", + category: "Parchment", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor EditableNoSetter = new( + id: "PARCH014", + title: "[EditableField] member has no usable setter", + messageFormat: "Model '{0}' member '{1}' is [EditableField] but has no public non-init setter; extraction writes values back onto the model, so the member must be settable", + category: "Parchment", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor EditableConflictingAttribute = new( + id: "PARCH015", + title: "[EditableField] combined with a conflicting attribute", + messageFormat: "Model '{0}' member '{1}' combines [EditableField] with [ExcelsiorTable] / [Html] / [Markdown] / [StringSyntax]; an editable field is plain typed content, not rendered markup", + category: "Parchment", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor EditableTokenNotPlainIdentifier = new( + id: "PARCH016", + title: "[EditableField] token must be a plain member-access expression", + messageFormat: "Template '{0}' token '{1}' references an [EditableField] member with filters or a non-plain expression; the editable render path is selected by attribute so filters would not be applied", + category: "Parchment", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor EditableTokenDuplicated = new( + id: "PARCH017", + title: "[EditableField] member referenced more than once in the document body", + messageFormat: "Template '{0}' token '{1}' references an [EditableField] member already referenced elsewhere in the document body; the dotted path is the content control's tag and must be unique for extraction", + category: "Parchment", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor EditableTokenInLoop = new( + id: "PARCH018", + title: "[EditableField] token inside a loop renders read-only", + messageFormat: "Template '{0}' token '{1}' references an [EditableField] member inside a '{{% for %}}' body; loop iterations would produce duplicate control tags, so the token renders as plain read-only text instead of an editable field", + category: "Parchment", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); } diff --git a/src/Parchment.SourceGenerator/DocxArchiveReader.cs b/src/Parchment.SourceGenerator/DocxArchiveReader.cs index 72ab295..5db259d 100644 --- a/src/Parchment.SourceGenerator/DocxArchiveReader.cs +++ b/src/Parchment.SourceGenerator/DocxArchiveReader.cs @@ -5,6 +5,7 @@ static class DocxArchiveReader public static DocxReadResult Read(string filePath) { var paragraphs = new List(); + var bodyParagraphs = new List(); var hasRemovePersonalInformation = false; var builder = new StringBuilder(); using var archive = ZipFile.OpenRead(filePath); @@ -23,6 +24,10 @@ public static DocxReadResult Read(string filePath) continue; } + // Zip entry order is not guaranteed body-first, so track the body part explicitly — + // editable-field validation is body-scoped. + var isBody = IsBodyPart(entry.FullName); + using var partStream = entry.Open(); var doc = XDocument.Load(partStream); @@ -36,14 +41,22 @@ public static DocxReadResult Read(string filePath) if (builder.Length > 0) { - paragraphs.Add(builder.ToString()); + var text = builder.ToString(); + paragraphs.Add(text); + if (isBody) + { + bodyParagraphs.Add(text); + } } } } - return new(paragraphs, hasRemovePersonalInformation); + return new(paragraphs, bodyParagraphs, hasRemovePersonalInformation); } + static bool IsBodyPart(string path) => + path.StartsWith("word/document.xml", StringComparison.OrdinalIgnoreCase); + static bool LooksLikeWordPart(string path) => path.StartsWith("word/document.xml", StringComparison.OrdinalIgnoreCase) || path.StartsWith("word/header", StringComparison.OrdinalIgnoreCase) || diff --git a/src/Parchment.SourceGenerator/DocxReadResult.cs b/src/Parchment.SourceGenerator/DocxReadResult.cs index ef58bc5..03ed7c7 100644 --- a/src/Parchment.SourceGenerator/DocxReadResult.cs +++ b/src/Parchment.SourceGenerator/DocxReadResult.cs @@ -1 +1,11 @@ -readonly record struct DocxReadResult(List Paragraphs, bool HasRemovePersonalInformation); \ No newline at end of file +/// +/// spans every scanned part (body, headers, footers, notes); +/// is the word/document.xml subset. Editable-field +/// token rules are body-scoped (duplicate tags, filter rejection, loop warnings) because the +/// runtime only dispatches editable fields in the body — the same token in a header renders as +/// plain read-only text by design. +/// +readonly record struct DocxReadResult( + List Paragraphs, + List BodyParagraphs, + bool HasRemovePersonalInformation); diff --git a/src/Parchment.SourceGenerator/EditableFieldKind.cs b/src/Parchment.SourceGenerator/EditableFieldKind.cs new file mode 100644 index 0000000..93d58f5 --- /dev/null +++ b/src/Parchment.SourceGenerator/EditableFieldKind.cs @@ -0,0 +1,14 @@ +/// +/// SG-side copy of Parchment.Generated.EditableFieldKind — the generator cannot reference +/// Parchment.dll. Values must stay in lockstep; emission writes the runtime enum by member name. +/// +enum EditableFieldKind +{ + Text, + Number, + Checkbox, + Date, + DateTimeOffset, + Time, + DropDown +} diff --git a/src/Parchment.SourceGenerator/IncrementalModels/DocxData.cs b/src/Parchment.SourceGenerator/IncrementalModels/DocxData.cs index 6b1db2c..4740d30 100644 --- a/src/Parchment.SourceGenerator/IncrementalModels/DocxData.cs +++ b/src/Parchment.SourceGenerator/IncrementalModels/DocxData.cs @@ -1,5 +1,6 @@ sealed record DocxData( string Path, EquatableArray Paragraphs, + EquatableArray BodyParagraphs, bool HasRemovePersonalInformation, string? ReadError); diff --git a/src/Parchment.SourceGenerator/IncrementalModels/MemberEntry.cs b/src/Parchment.SourceGenerator/IncrementalModels/MemberEntry.cs index d3868cc..c1ca08b 100644 --- a/src/Parchment.SourceGenerator/IncrementalModels/MemberEntry.cs +++ b/src/Parchment.SourceGenerator/IncrementalModels/MemberEntry.cs @@ -7,4 +7,10 @@ sealed record MemberEntry( bool IsStringList = false, bool IsStatic = false, string? ExcelsiorHeadingParagraphStyle = null, - string? ExcelsiorBodyParagraphStyle = null); + string? ExcelsiorBodyParagraphStyle = null, + bool IsEditable = false, + EditableFieldKind? EditableKind = null, + bool EditableIsNullable = false, + bool HasUsableSetter = false, + bool EditableMultiLine = false, + string? EditableDateFormat = null); diff --git a/src/Parchment.SourceGenerator/IncrementalModels/TargetInfo.cs b/src/Parchment.SourceGenerator/IncrementalModels/TargetInfo.cs index 95ab177..5ad3950 100644 --- a/src/Parchment.SourceGenerator/IncrementalModels/TargetInfo.cs +++ b/src/Parchment.SourceGenerator/IncrementalModels/TargetInfo.cs @@ -23,6 +23,7 @@ sealed record TargetInfo( string ModelFullyQualifiedName, string ModelDisplayName, string TemplatePath, + ProtectionMode Protection, EquatableLocation Location, ModelShape Shape, string? ExtractError); diff --git a/src/Parchment.SourceGenerator/Parchment.SourceGenerator.csproj b/src/Parchment.SourceGenerator/Parchment.SourceGenerator.csproj index bc28a47..171bb22 100644 --- a/src/Parchment.SourceGenerator/Parchment.SourceGenerator.csproj +++ b/src/Parchment.SourceGenerator/Parchment.SourceGenerator.csproj @@ -9,7 +9,12 @@ stale diagnostics/generated code after a consumer upgrades. Safe for a Roslyn component (never referenced at runtime), unlike the runtime Parchment assembly which stays pinned via src/Directory.Build.props. --> - $(Version) + + $(Version.Split('-')[0]) + $(Version.Split('-')[0]) $(NoWarn);RS1041;RS2008;RS1038;RS1036 true diff --git a/src/Parchment.SourceGenerator/ParchmentTemplateGenerator.cs b/src/Parchment.SourceGenerator/ParchmentTemplateGenerator.cs index 7af977c..17973ad 100644 --- a/src/Parchment.SourceGenerator/ParchmentTemplateGenerator.cs +++ b/src/Parchment.SourceGenerator/ParchmentTemplateGenerator.cs @@ -104,7 +104,21 @@ static bool IsMarkdownPath(string path) => var excelsiorTableType = context.SemanticModel.Compilation .GetTypeByMetadataName(ShapeBuilder.ExcelsiorTableAttributeFullName); - var shape = ShapeBuilder.Build(typeSymbol, excelsiorTableType, cancel); + var editableFieldType = context.SemanticModel.Compilation + .GetTypeByMetadataName(ShapeBuilder.EditableFieldAttributeFullName); + var shape = ShapeBuilder.Build(typeSymbol, excelsiorTableType, editableFieldType, cancel); + + var protection = ProtectionMode.WhenEditable; + foreach (var named in attribute.NamedArguments) + { + // The named argument arrives as the enum's underlying int — the SG cannot reference + // Parchment.dll's ProtectionMode type. + if (named.Key == "Protection" && + named.Value.Value is int protectionValue) + { + protection = (ProtectionMode)protectionValue; + } + } return new( declaringNamespace, @@ -114,6 +128,7 @@ static bool IsMarkdownPath(string path) => typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), displayName, path, + protection, EquatableLocation.From(rawLocation), shape, enclosingResult.Error); @@ -172,12 +187,13 @@ static DocxData ReadDocx(AdditionalText text) return new( text.Path, new(result.Paragraphs.ToImmutableArray()), + new(result.BodyParagraphs.ToImmutableArray()), result.HasRemovePersonalInformation, null); } catch (Exception exception) { - return new(text.Path, EquatableArray.Empty, false, exception.Message); + return new(text.Path, EquatableArray.Empty, EquatableArray.Empty, false, exception.Message); } } @@ -286,9 +302,203 @@ static void ProcessDocx( var tokens = TokenScanner.Scan(matched.Paragraphs); ValidateTokens(context, target, tokens, location); + // Editable fields are docx-only (like Excelsior / Format dispatch), so the shape rules + // fire here rather than in Process — the runtime lockstep is EditableMap.Build throwing + // at RegisterDocxTemplate regardless of whether a token references the member. + ValidateEditableShape(context, target, location); + if (HasEditableMembers(target.Shape)) + { + // Token rules are body-scoped: the runtime dispatches editable fields only in the + // document body, so a header/footer occurrence of the same member is a deliberate + // read-only mirror, not a duplicate. + var bodyTokens = TokenScanner.Scan(matched.BodyParagraphs); + ValidateEditableTokens(context, target, bodyTokens, location); + } + EmitRegistration(context, target, GenerateDocxRegistration(target)); } + static bool HasEditableMembers(ModelShape shape) + { + foreach (var type in shape.Types) + { + foreach (var member in type.Members) + { + if (member is { IsEditable: true, IsStatic: false }) + { + return true; + } + } + } + + return false; + } + + static void ValidateEditableShape( + SourceProductionContext context, + TargetInfo target, + Location location) + { + foreach (var type in target.Shape.Types) + { + foreach (var member in type.Members) + { + // Static members don't participate in dotted-path map dispatch (see the static + // caveat shared with Excelsior / Format / StringList) — silently ignored. + if (!member.IsEditable || + member.IsStatic) + { + continue; + } + + var memberDisplay = $"{Display(type.TypeFullyQualifiedName)}.{member.Name}"; + + if (member.IsExcelsiorTable || + member.IsHtml || + member.IsMarkdown) + { + context.ReportDiagnostic( + Diagnostic.Create( + Diagnostics.EditableConflictingAttribute, + location, + target.ModelDisplayName, + memberDisplay)); + continue; + } + + if (member.EditableKind == null) + { + context.ReportDiagnostic( + Diagnostic.Create( + Diagnostics.EditableUnsupportedType, + location, + target.ModelDisplayName, + memberDisplay, + Display(member.TypeFullyQualifiedName))); + continue; + } + + if (!member.HasUsableSetter) + { + context.ReportDiagnostic( + Diagnostic.Create( + Diagnostics.EditableNoSetter, + location, + target.ModelDisplayName, + memberDisplay)); + } + } + } + } + + static string Display(string fqn) => + fqn.StartsWith("global::", StringComparison.Ordinal) ? fqn["global::".Length..] : fqn; + + /// + /// Body-scoped editable-token rules. This pass tracks loop scope silently (no diagnostics — + /// already reported PARCH001/002/005 over all parts) so PARCH016 + /// / PARCH017 / PARCH018 don't double-report. + /// + static void ValidateEditableTokens( + SourceProductionContext context, + TargetInfo target, + IReadOnlyList tokens, + Location location) + { + var scope = new Dictionary(StringComparer.Ordinal); + var loopStack = new Stack<(string? Name, string? PriorBinding)>(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var token in tokens) + { + switch (token.Kind) + { + case TokenKind.ForOpen: + string? bound = null; + string? prior = null; + if (token.LoopVariable != null && + token.References.Count > 0) + { + var sourceFqn = ShapeResolver.Resolve(target.Shape, token.References[0], scope); + var elementFqn = sourceFqn == null ? null : ShapeResolver.GetElementType(target.Shape, sourceFqn); + if (elementFqn != null) + { + bound = token.LoopVariable; + prior = scope.TryGetValue(bound, out var existing) ? existing : null; + scope[bound] = elementFqn; + } + } + + loopStack.Push((bound, prior)); + break; + + case TokenKind.ForClose: + if (loopStack.Count > 0) + { + var (name, priorBinding) = loopStack.Pop(); + if (name != null) + { + if (priorBinding == null) + { + scope.Remove(name); + } + else + { + scope[name] = priorBinding; + } + } + } + + break; + + case TokenKind.Substitution: + if (token.References.Count == 0) + { + break; + } + + var member = ShapeResolver.ResolveMember(target.Shape, token.References[0], scope); + if (member is null or { IsEditable: false } or { IsStatic: true }) + { + break; + } + + if (loopStack.Count > 0) + { + context.ReportDiagnostic( + Diagnostic.Create( + Diagnostics.EditableTokenInLoop, + location, + target.TemplatePath, + token.Source)); + break; + } + + if (!token.IsPlainIdentifier) + { + context.ReportDiagnostic( + Diagnostic.Create( + Diagnostics.EditableTokenNotPlainIdentifier, + location, + target.TemplatePath, + token.Source)); + } + + if (!seen.Add(string.Join(".", token.References[0]))) + { + context.ReportDiagnostic( + Diagnostic.Create( + Diagnostics.EditableTokenDuplicated, + location, + target.TemplatePath, + token.Source)); + } + + break; + } + } + } + static void ProcessMarkdown( SourceProductionContext context, TargetInfo target, @@ -625,6 +835,11 @@ static void ValidateReferences( static string GenerateDocxRegistration(TargetInfo target) { var (templatePath, fieldsBlock, registrationsBlock) = PrepareCommon(target); + // Emit the protection argument only when it deviates from the default, so pre-existing + // registrations (and their snapshots) stay byte-identical. + var protection = target.Protection == ProtectionMode.WhenEditable + ? "" + : $", global::Parchment.ProtectionMode.{target.Protection}"; var body = $$""" public static string TemplatePath => {{templatePath}}; @@ -633,7 +848,7 @@ static string GenerateDocxRegistration(TargetInfo target) {{fieldsBlock}}public static void RegisterWith(global::Parchment.TemplateStore store, string? basePath = null) { {{registrationsBlock}} var path = basePath is null ? TemplatePath : global::System.IO.Path.Combine(basePath, TemplatePath); - store.RegisterDocxTemplate<{{target.ModelFullyQualifiedName}}>(TemplateName, path); + store.RegisterDocxTemplate<{{target.ModelFullyQualifiedName}}>(TemplateName, path{{protection}}); } """; diff --git a/src/Parchment.SourceGenerator/ProtectionMode.cs b/src/Parchment.SourceGenerator/ProtectionMode.cs new file mode 100644 index 0000000..12a6332 --- /dev/null +++ b/src/Parchment.SourceGenerator/ProtectionMode.cs @@ -0,0 +1,10 @@ +/// +/// SG-side copy of Parchment.ProtectionMode — the generator cannot reference +/// Parchment.dll. Values must stay in lockstep; the attribute's named argument arrives as the +/// underlying int and emission writes the runtime enum by member name. +/// +enum ProtectionMode +{ + WhenEditable, + None +} diff --git a/src/Parchment.SourceGenerator/ShapeBuilder.cs b/src/Parchment.SourceGenerator/ShapeBuilder.cs index f187890..9e61eea 100644 --- a/src/Parchment.SourceGenerator/ShapeBuilder.cs +++ b/src/Parchment.SourceGenerator/ShapeBuilder.cs @@ -10,10 +10,11 @@ static class ShapeBuilder { public const string ExcelsiorTableAttributeFullName = "Parchment.ExcelsiorTableAttribute"; + public const string EditableFieldAttributeFullName = "Parchment.EditableFieldAttribute"; static readonly SymbolDisplayFormat format = SymbolDisplayFormat.FullyQualifiedFormat; - public static ModelShape Build(INamedTypeSymbol root, INamedTypeSymbol? excelsiorTableType, Cancel cancel) + public static ModelShape Build(INamedTypeSymbol root, INamedTypeSymbol? excelsiorTableType, INamedTypeSymbol? editableFieldType, Cancel cancel) { var entries = ImmutableArray.CreateBuilder(); var visited = new HashSet(StringComparer.Ordinal); @@ -24,13 +25,13 @@ public static ModelShape Build(INamedTypeSymbol root, INamedTypeSymbol? excelsio { cancel.ThrowIfCancellationRequested(); var type = queue.Dequeue(); - entries.Add(BuildEntry(type, excelsiorTableType, visited, queue)); + entries.Add(BuildEntry(type, excelsiorTableType, editableFieldType, visited, queue)); } return new(Fqn(root), new(entries.ToImmutable())); } - static TypeEntry BuildEntry(ITypeSymbol type, INamedTypeSymbol? excelsiorTableType, HashSet visited, Queue queue) + static TypeEntry BuildEntry(ITypeSymbol type, INamedTypeSymbol? excelsiorTableType, INamedTypeSymbol? editableFieldType, HashSet visited, Queue queue) { string? elementFqn = null; if (type.SpecialType != SpecialType.System_String) @@ -66,7 +67,23 @@ static TypeEntry BuildEntry(ITypeSymbol type, INamedTypeSymbol? excelsiorTableTy var (isHtml, isMarkdown) = DetectFormat(member); var isStringList = !isExcelsior && IsEnumerableOfString(memberType); - members.Add(new(memberName, Fqn(memberType), isExcelsior, isHtml, isMarkdown, isStringList, isStatic, excelsiorHeadingStyle, excelsiorBodyStyle)); + var isEditable = TryGetEditableField(member, editableFieldType, out var editableMultiLine, out var editableDateFormat); + members.Add(new( + memberName, + Fqn(memberType), + isExcelsior, + isHtml, + isMarkdown, + isStringList, + isStatic, + excelsiorHeadingStyle, + excelsiorBodyStyle, + isEditable, + isEditable ? MapEditableKind(memberType) : null, + isEditable && IsNullableMember(memberType), + isEditable && HasUsableSetter(member), + editableMultiLine, + editableDateFormat)); Enqueue(memberType, visited, queue); } @@ -171,6 +188,107 @@ static bool TryGetExcelsiorTable(ISymbol member, INamedTypeSymbol? excelsiorTabl return false; } + static bool TryGetEditableField(ISymbol member, INamedTypeSymbol? editableFieldType, out bool multiLine, out string? dateFormat) + { + multiLine = false; + dateFormat = null; + if (editableFieldType is null) + { + return false; + } + + foreach (var attribute in member.GetAttributes()) + { + if (!SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, editableFieldType)) + { + continue; + } + + foreach (var named in attribute.NamedArguments) + { + if (named.Key == "MultiLine" && + named.Value.Value is bool multi) + { + multiLine = multi; + } + else if (named.Key == "DateFormat" && + named.Value.Value is string dateFormatValue) + { + dateFormat = dateFormatValue; + } + } + + return true; + } + + return false; + } + + /// + /// The runtime lockstep is EditableMap.MapKind + the bool? guard: null means + /// PARCH013 (unsupported type — including bool?, which a checkbox cannot represent). + /// + static EditableFieldKind? MapEditableKind(ITypeSymbol type) + { + var isNullableValue = false; + if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable) + { + isNullableValue = true; + type = nullable.TypeArguments[0]; + } + + switch (type.SpecialType) + { + case SpecialType.System_String: + return EditableFieldKind.Text; + case SpecialType.System_Boolean: + return isNullableValue ? null : EditableFieldKind.Checkbox; + case SpecialType.System_DateTime: + return EditableFieldKind.Date; + case SpecialType.System_Byte: + case SpecialType.System_SByte: + case SpecialType.System_Int16: + case SpecialType.System_UInt16: + case SpecialType.System_Int32: + case SpecialType.System_UInt32: + case SpecialType.System_Int64: + case SpecialType.System_UInt64: + case SpecialType.System_Single: + case SpecialType.System_Double: + case SpecialType.System_Decimal: + return EditableFieldKind.Number; + } + + if (type.TypeKind == TypeKind.Enum) + { + return EditableFieldKind.DropDown; + } + + // DateOnly + DateTime -> native date picker (canonical w:fullDate). DateTimeOffset and + // TimeOnly -> round-trippable plain text (w:fullDate has no offset; no time-only picker). + // Lockstep with runtime EditableMap.MapKind. + var fqn = Fqn(type); + return fqn switch + { + "global::System.DateOnly" => EditableFieldKind.Date, + "global::System.DateTimeOffset" => EditableFieldKind.DateTimeOffset, + "global::System.TimeOnly" => EditableFieldKind.Time, + _ => null + }; + } + + static bool IsNullableMember(ITypeSymbol type) => + type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } || + type.NullableAnnotation == NullableAnnotation.Annotated; + + static bool HasUsableSetter(ISymbol member) => + member switch + { + IPropertySymbol { SetMethod: { IsInitOnly: false, DeclaredAccessibility: Accessibility.Public } } => true, + IFieldSymbol { IsReadOnly: false, IsConst: false } => true, + _ => false + }; + static bool TryGetMemberType(ISymbol member, out ITypeSymbol type, out string name, out bool isStatic) { if (member is IPropertySymbol { DeclaredAccessibility: Accessibility.Public } property) diff --git a/src/Parchment.Tests/DeterminismTests.cs b/src/Parchment.Tests/DeterminismTests.cs index 1eb90c2..6736ffd 100644 --- a/src/Parchment.Tests/DeterminismTests.cs +++ b/src/Parchment.Tests/DeterminismTests.cs @@ -27,4 +27,34 @@ static async Task Render(TemplateStore store, Invoice model) await store.Render("determinism", model, stream); return stream.ToArray(); } + + [Test] + public async Task EditableFieldRenderIsByteIdentical() + { + // Editable fields introduce sdt ids, perm-range ids, and document protection — all must + // be deterministic (sequential ids, passwordless enforcement, no timestamps). + using var template = DocxTemplateBuilder.Build( + """ + PO: {{ PurchaseOrder }} + + {{ Approved }} {{ Delivery }} {{ Status }} {{ Discount }} + + {{ Notes }} + """); + + var store = new TemplateStore(); + store.RegisterDocxTemplate("editable-determinism", template); + + var first = await RenderEditable(store); + var second = await RenderEditable(store); + + await Assert.That(first).IsEquivalentTo(second); + } + + static async Task RenderEditable(TemplateStore store) + { + using var stream = new MemoryStream(); + await store.Render("editable-determinism", EditableFieldTests.NewOrder(), stream); + return stream.ToArray(); + } } diff --git a/src/Parchment.Tests/Docx/ConditionalTests.cs b/src/Parchment.Tests/Docx/ConditionalTests.cs index 7e152a5..d35be1d 100644 --- a/src/Parchment.Tests/Docx/ConditionalTests.cs +++ b/src/Parchment.Tests/Docx/ConditionalTests.cs @@ -69,6 +69,377 @@ await store.Render( await Verify(stream, "docx"); } + public class TwoFlagModel + { + public required bool Flag { get; init; } + public required bool Second { get; init; } + public required string Label { get; init; } + } + + [Test] + public async Task StaticParagraphInChosenBranchIsKept() + { + // Static (token-free) paragraphs get no anchors and no scope-tree nodes, so branch + // content must be kept positionally — an anchor-derived keep-set silently dropped them. + using var template = DocxTemplateBuilder.Build( + """ + Before + + {% if Flag %} + + Static kept text. + + {% endif %} + + After + """); + + var texts = await RenderToTexts( + template, + new FlagModel + { + Flag = true, + Label = "unused" + }); + await Assert.That(texts).IsEquivalentTo(["Before", "Static kept text.", "After"]); + } + + [Test] + public async Task StaticOnlyElseBranchIsKept() + { + // A static-only else branch has an empty scope-tree body; the else tag anchor alone must + // be enough to keep its physical range. + using var template = DocxTemplateBuilder.Build( + """ + {% if Flag %} + + Affirmative: {{ Label }} + + {% else %} + + Fallback static. + + {% endif %} + """); + + var texts = await RenderToTexts( + template, + new FlagModel + { + Flag = false, + Label = "unused" + }); + await Assert.That(texts).IsEquivalentTo(["Fallback static."]); + } + + [Test] + public async Task MixedStaticAndTokenBranches() + { + var content = + """ + {% if Flag %} + + Intro static. + + Value: {{ Label }} + + Outro static. + + {% else %} + + Else static. + + Else value: {{ Label }} + + {% endif %} + """; + + using (var template = DocxTemplateBuilder.Build(content)) + { + var texts = await RenderToTexts( + template, + new FlagModel + { + Flag = true, + Label = "yes" + }); + await Assert.That(texts).IsEquivalentTo(["Intro static.", "Value: yes", "Outro static."]); + } + + using (var template = DocxTemplateBuilder.Build(content)) + { + var texts = await RenderToTexts( + template, + new FlagModel + { + Flag = false, + Label = "no" + }); + await Assert.That(texts).IsEquivalentTo(["Else static.", "Else value: no"]); + } + } + + [Test] + public async Task ElsifChosenKeepsStaticAndRemovesSiblingBranches() + { + using var template = DocxTemplateBuilder.Build( + """ + {% if Flag %} + + First: {{ Label }} + + {% elsif Second %} + + Elsif static. + + {% else %} + + Else: {{ Label }} + + {% endif %} + """); + + var texts = await RenderToTexts( + template, + new TwoFlagModel + { + Flag = false, + Second = true, + Label = "unused" + }); + await Assert.That(texts).IsEquivalentTo(["Elsif static."]); + } + + [Test] + public async Task NestedIfStaticParagraphs() + { + var content = + """ + {% if Flag %} + + Outer before. + + {% if Second %} + + Inner static. + + {% endif %} + + Outer after. + + {% endif %} + """; + + using (var template = DocxTemplateBuilder.Build(content)) + { + var texts = await RenderToTexts( + template, + new TwoFlagModel + { + Flag = true, + Second = true, + Label = "unused" + }); + await Assert.That(texts).IsEquivalentTo(["Outer before.", "Inner static.", "Outer after."]); + } + + using (var template = DocxTemplateBuilder.Build(content)) + { + var texts = await RenderToTexts( + template, + new TwoFlagModel + { + Flag = true, + Second = false, + Label = "unused" + }); + await Assert.That(texts).IsEquivalentTo(["Outer before.", "Outer after."]); + } + } + + [Test] + public async Task StaticParagraphInEliminatedBranchIsRemoved() + { + using var template = DocxTemplateBuilder.Build( + """ + Before + + {% if Flag %} + + Eliminated static. + + {% endif %} + + After + """); + + var texts = await RenderToTexts( + template, + new FlagModel + { + Flag = false, + Label = "unused" + }); + await Assert.That(texts).IsEquivalentTo(["Before", "After"]); + } + + [Test] + public async Task TableInsideChosenBranchIsKeptAndSubstituted() + { + // Tables are captured positionally like static paragraphs (they have no anchor of their + // own), and tokens inside table cells must still substitute. + using var template = BuildTemplateWithTableInIf(); + var store = new TemplateStore(); + store.RegisterDocxTemplate("if-table-kept", template); + + using var stream = new MemoryStream(); + await store.Render( + "if-table-kept", + new FlagModel + { + Flag = true, + Label = "cell value" + }, + stream); + stream.Position = 0; + + using var doc = WordprocessingDocument.Open(stream, false); + var body = doc.MainDocumentPart!.Document!.Body!; + var table = body.Elements().SingleOrDefault(); + await Assert.That(table).IsNotNull(); + await Assert.That(table!.InnerText).IsEqualTo("Cell: cell value"); + } + + [Test] + public async Task TableInsideEliminatedBranchIsRemoved() + { + using var template = BuildTemplateWithTableInIf(); + var store = new TemplateStore(); + store.RegisterDocxTemplate("if-table-removed", template); + + using var stream = new MemoryStream(); + await store.Render( + "if-table-removed", + new FlagModel + { + Flag = false, + Label = "unused" + }, + stream); + stream.Position = 0; + + using var doc = WordprocessingDocument.Open(stream, false); + var body = doc.MainDocumentPart!.Document!.Body!; + await Assert.That(body.Elements
().Any()).IsFalse(); + } + + [Test] + public async Task DuplicateElseIsRejected() + { + using var template = DocxTemplateBuilder.Build( + """ + {% if Flag %} + + A + + {% else %} + + B + + {% else %} + + C + + {% endif %} + """); + + var store = new TemplateStore(); + var exception = await Assert.That( + () => store.RegisterDocxTemplate("double-else", template)) + .Throws(); + await Assert.That(exception!.Message).Contains("after '{% else %}'"); + } + + [Test] + public async Task ElsifAfterElseIsRejected() + { + using var template = DocxTemplateBuilder.Build( + """ + {% if Flag %} + + A + + {% else %} + + B + + {% elsif Second %} + + C + + {% endif %} + """); + + var store = new TemplateStore(); + var exception = await Assert.That( + () => store.RegisterDocxTemplate("elsif-after-else", template)) + .Throws(); + await Assert.That(exception!.Message).Contains("after '{% else %}'"); + } + + static async Task> RenderToTexts( + Stream template, + TModel model, + [CallerMemberName] string name = "") + { + var store = new TemplateStore(); + store.RegisterDocxTemplate(name, template); + using var stream = new MemoryStream(); + await store.Render(name, model!, stream); + stream.Position = 0; + + using var doc = WordprocessingDocument.Open(stream, false); + return doc.MainDocumentPart!.Document!.Body! + .Elements() + .Select(_ => string.Concat(_.Descendants().Select(t => t.Text))) + .Where(_ => _.Length > 0) + .ToList(); + } + + static MemoryStream BuildTemplateWithTableInIf() + { + var stream = new MemoryStream(); + using (var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document)) + { + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new( + new Body( + BuildParagraph("{% if Flag %}"), + new Table( + new TableRow( + new TableCell( + BuildParagraph("Cell: {{ Label }}")))), + BuildParagraph("{% endif %}"), + new SectionProperties( + new PageSize + { + Width = 6500, + Height = 8000 + }))); + } + + stream.Position = 0; + return stream; + } + + static Paragraph BuildParagraph(string text) => + new( + new Run( + new Text(text) + { + Space = SpaceProcessingModeValues.Preserve + })); + [Test] public async Task NoMatchingBranch_NoElse_AllBranchParagraphsRemoved() { diff --git a/src/Parchment.Tests/Docx/EditableFieldTests.RenderAllKinds#00.verified.txt b/src/Parchment.Tests/Docx/EditableFieldTests.RenderAllKinds#00.verified.txt new file mode 100644 index 0000000..3bc169d --- /dev/null +++ b/src/Parchment.Tests/Docx/EditableFieldTests.RenderAllKinds#00.verified.txt @@ -0,0 +1,10 @@ +{ + Text: +Order ORD-100 +PO: +Approved: +Delivery: +Status: +Discount: +Notes: +} \ No newline at end of file diff --git a/src/Parchment.Tests/Docx/EditableFieldTests.RenderAllKinds#01.verified.txt b/src/Parchment.Tests/Docx/EditableFieldTests.RenderAllKinds#01.verified.txt new file mode 100644 index 0000000..7503015 --- /dev/null +++ b/src/Parchment.Tests/Docx/EditableFieldTests.RenderAllKinds#01.verified.txt @@ -0,0 +1,7 @@ +Order ORD-100 +PO: +Approved: +Delivery: +Status: +Discount: +Notes: \ No newline at end of file diff --git a/src/Parchment.Tests/Docx/EditableFieldTests.RenderAllKinds.verified.docx b/src/Parchment.Tests/Docx/EditableFieldTests.RenderAllKinds.verified.docx new file mode 100644 index 0000000..4c82cd0 Binary files /dev/null and b/src/Parchment.Tests/Docx/EditableFieldTests.RenderAllKinds.verified.docx differ diff --git a/src/Parchment.Tests/Docx/EditableFieldTests.RenderAllKinds.verified.png b/src/Parchment.Tests/Docx/EditableFieldTests.RenderAllKinds.verified.png new file mode 100644 index 0000000..b15a18f Binary files /dev/null and b/src/Parchment.Tests/Docx/EditableFieldTests.RenderAllKinds.verified.png differ diff --git a/src/Parchment.Tests/Docx/EditableFieldTests.cs b/src/Parchment.Tests/Docx/EditableFieldTests.cs new file mode 100644 index 0000000..e8f729d --- /dev/null +++ b/src/Parchment.Tests/Docx/EditableFieldTests.cs @@ -0,0 +1,846 @@ +using DocumentFormat.OpenXml.Validation; +using W14 = DocumentFormat.OpenXml.Office2010.Word; +using SdtLock = DocumentFormat.OpenXml.Wordprocessing.Lock; + +public class EditableFieldTests +{ + static string ScenarioPath(string scenarioName) => + Path.Combine( + ProjectFiles.ProjectDirectory, + "Scenarios", + scenarioName); + + #region EditableFieldsModel + public class OrderForm + { + public required string Number; + + [EditableField] + public required string PurchaseOrder { get; set; } + + [EditableField] + public bool Approved { get; set; } + + [EditableField] + public OrderStatus Status { get; set; } + + [EditableField(MultiLine = true)] + public string? Notes { get; set; } + } + + public enum OrderStatus + { + Draft, + Submitted, + Accepted + } + #endregion + + [Test] + public async Task Scenario() + { + #region EditableFieldsUsage + var templatePath = Path.Combine(ScenarioPath("editable-fields"), "input.docx"); + + var store = new TemplateStore(); + store.RegisterDocxTemplate("order-form", templatePath); + + var model = new OrderForm + { + Number = "ORD-2026-042", + PurchaseOrder = "PO-77041", + Approved = true, + Status = OrderStatus.Submitted, + Notes = null + }; + + using var stream = new MemoryStream(); + await store.Render("order-form", model, stream); + #endregion + + var settings = new VerifySettings(); + settings.UseDirectory(ScenarioPath("editable-fields")); + settings.UseFileName("output"); + + stream.Position = 0; + await Verify(stream, "docx", settings); + } + + [Test] + public async Task ScenarioRoundTrip() + { + var templatePath = Path.Combine(ScenarioPath("editable-fields"), "input.docx"); + var store = new TemplateStore(); + store.RegisterDocxTemplate("order-form-roundtrip", templatePath); + + var model = new OrderForm + { + Number = "ORD-2026-042", + PurchaseOrder = "PO-77041", + Approved = false, + Status = OrderStatus.Draft, + Notes = null + }; + + using var stream = new MemoryStream(); + await store.Render("order-form-roundtrip", model, stream); + stream.Position = 0; + + // Simulate the user filling in the form in Word. + using (var doc = WordprocessingDocument.Open(stream, true)) + { + var body = doc.MainDocumentPart!.Document!.Body!; + SetSdtText(FindSdt(body, "PurchaseOrder"), "PO-91000"); + SetSdtChecked(FindSdt(body, "Approved"), true); + SetSdtText(FindSdt(body, "Status"), "Accepted"); + SetSdtText(FindSdt(body, "Notes"), "Ship to the loading dock."); + doc.Save(); + } + + stream.Position = 0; + + #region EditableFieldsExtract + var result = ParchmentExtractor.Extract(stream); + + result.ApplyTo(model); + #endregion + + await Assert.That(result.AllExtracted).IsTrue(); + await Assert.That(model.PurchaseOrder).IsEqualTo("PO-91000"); + await Assert.That(model.Approved).IsTrue(); + await Assert.That(model.Status).IsEqualTo(OrderStatus.Accepted); + await Assert.That(model.Notes).IsEqualTo("Ship to the loading dock."); + } + + static void SetSdtText(SdtRun sdt, string text) + { + sdt.SdtProperties!.RemoveAllChildren(); + var content = sdt.ChildElements.First(_ => _.LocalName == "sdtContent"); + content.RemoveAllChildren(); + content.AppendChild( + new Run( + new Text(text) + { + Space = SpaceProcessingModeValues.Preserve + })); + } + + static void SetSdtChecked(SdtRun sdt, bool value) + { + sdt.SdtProperties! + .GetFirstChild()! + .GetFirstChild()! + .Val = value ? W14.OnOffValues.One : W14.OnOffValues.Zero; + sdt.Descendants().First().Text = value ? "☒" : "☐"; + } + + public enum QuoteStatus + { + Draft, + Submitted, + Accepted + } + + public class EditableOrder + { + public required string Number; + public required List Tags; + public required bool IncludeNotes; + + [EditableField] + public required string PurchaseOrder { get; set; } + + [EditableField] + public bool Approved { get; set; } + + [EditableField] + public Date Delivery { get; set; } + + [EditableField(DateFormat = "yyyy-MM-dd HH:mm")] + public DateTime DispatchedAt { get; set; } + + [EditableField] + public DateTimeOffset SignedAt { get; set; } + + [EditableField] + public Time PickupTime { get; set; } + + [EditableField] + public QuoteStatus Status { get; set; } + + [EditableField] + public decimal Discount { get; set; } + + [EditableField] + public string? Notes { get; set; } + + [EditableField(MultiLine = true)] + public string? Instructions { get; set; } + } + + public static EditableOrder NewOrder() => + new() + { + Number = "ORD-100", + Tags = ["a", "b"], + IncludeNotes = true, + PurchaseOrder = "PO-2026-17", + Approved = true, + Delivery = new(2026, 7, 6), + DispatchedAt = new(2026, 7, 6, 14, 30, 0), + SignedAt = new(2026, 7, 6, 9, 0, 0, TimeSpan.FromHours(10)), + PickupTime = new(16, 45, 0), + Status = QuoteStatus.Submitted, + Discount = 10m, + Notes = null, + Instructions = "Line one\nLine two" + }; + + [Test] + public async Task RenderAllKinds() + { + using var template = DocxTemplateBuilder.Build( + """ + Order {{ Number }} + + PO: {{ PurchaseOrder }} + + Approved: {{ Approved }} + + Delivery: {{ Delivery }} + + Status: {{ Status }} + + Discount: {{ Discount }} + + Notes: {{ Notes }} + + {{ Instructions }} + """); + + var store = new TemplateStore(); + store.RegisterDocxTemplate("editable-all", template); + + using var stream = new MemoryStream(); + await store.Render("editable-all", NewOrder(), stream); + + stream.Position = 0; + await Verify(stream, "docx"); + } + + [Test] + public async Task SdtStructurePerKind() + { + using var stream = await Render( + """ + PO: {{ PurchaseOrder }} + + {{ Approved }} + + {{ Delivery }} + + {{ Status }} + + {{ Discount }} + + {{ Notes }} + + {{ Instructions }} + """, + NewOrder()); + + using var doc = WordprocessingDocument.Open(stream, false); + var body = doc.MainDocumentPart!.Document!.Body!; + + await Assert.That(body.Descendants().Count()).IsEqualTo(7); + + var po = FindSdt(body, "PurchaseOrder"); + await Assert.That(po.SdtProperties!.GetFirstChild()!.Val!.Value).IsEqualTo("PurchaseOrder"); + await Assert.That(po.SdtProperties.GetFirstChild()!.Val!.Value).IsEqualTo(LockingValues.SdtLocked); + await Assert.That(po.SdtProperties.GetFirstChild()).IsNotNull(); + await Assert.That(po.InnerText).IsEqualTo("PO-2026-17"); + + var approved = FindSdt(body, "Approved"); + var checkbox = approved.SdtProperties!.GetFirstChild(); + await Assert.That(checkbox).IsNotNull(); + await Assert.That(checkbox!.GetFirstChild()!.Val!.Value).IsEqualTo(W14.OnOffValues.One); + await Assert.That(approved.InnerText).IsEqualTo("☒"); + + var delivery = FindSdt(body, "Delivery"); + var date = delivery.SdtProperties!.GetFirstChild(); + await Assert.That(date).IsNotNull(); + await Assert.That(date!.FullDate!.Value).IsEqualTo(new DateTime(2026, 7, 6)); + await Assert.That(date.GetFirstChild()!.Val!.Value).IsEqualTo("yyyy-MM-dd"); + await Assert.That(delivery.InnerText).IsEqualTo("2026-07-06"); + + var status = FindSdt(body, "Status"); + var dropDown = status.SdtProperties!.GetFirstChild(); + await Assert.That(dropDown).IsNotNull(); + var items = dropDown!.Elements().Select(_ => _.Value!.Value!).ToList(); + await Assert.That(items).IsEquivalentTo(["Draft", "Submitted", "Accepted"]); + await Assert.That(status.InnerText).IsEqualTo("Submitted"); + + var discount = FindSdt(body, "Discount"); + await Assert.That(discount.InnerText).IsEqualTo("10"); + + // Null nullable value renders as a placeholder. + var notes = FindSdt(body, "Notes"); + await Assert.That(notes.SdtProperties!.GetFirstChild()).IsNotNull(); + + var instructions = FindSdt(body, "Instructions"); + var text = instructions.SdtProperties!.GetFirstChild(); + await Assert.That(text!.MultiLine!.Value).IsTrue(); + await Assert.That(instructions.Descendants().Count()).IsEqualTo(1); + await Assert.That(instructions.InnerText).IsEqualTo("Line oneLine two"); + } + + [Test] + public async Task TemporalKindsRenderWithCorrectControls() + { + using var stream = await Render( + """ + {{ DispatchedAt }} + + {{ SignedAt }} + + {{ PickupTime }} + """, + NewOrder()); + + using var doc = WordprocessingDocument.Open(stream, false); + var body = doc.MainDocumentPart!.Document!.Body!; + + // DateTime keeps the native date picker; its time-of-day survives in w:fullDate even + // though the (custom) format also shows it in the visible text. + var dispatched = FindSdt(body, "DispatchedAt"); + var date = dispatched.SdtProperties!.GetFirstChild(); + await Assert.That(date).IsNotNull(); + await Assert.That(date!.FullDate!.Value).IsEqualTo(new DateTime(2026, 7, 6, 14, 30, 0)); + await Assert.That(dispatched.InnerText).IsEqualTo("2026-07-06 14:30"); + + // DateTimeOffset has no offset-aware picker and w:fullDate can't hold an offset, so it + // renders as plain text carrying a round-trippable ISO value (offset included). + var signed = FindSdt(body, "SignedAt"); + await Assert.That(signed.SdtProperties!.GetFirstChild()).IsNotNull(); + await Assert.That(signed.SdtProperties.GetFirstChild()).IsNull(); + await Assert.That(signed.InnerText).IsEqualTo("2026-07-06T09:00:00+10:00"); + + // TimeOnly has no picker either — plain text. + var pickup = FindSdt(body, "PickupTime"); + await Assert.That(pickup.SdtProperties!.GetFirstChild()).IsNotNull(); + await Assert.That(pickup.SdtProperties.GetFirstChild()).IsNull(); + await Assert.That(pickup.InnerText).IsEqualTo("16:45:00"); + } + + [Test] + public async Task EveryFieldGetsAPairedEditableRange() + { + using var stream = await Render( + """ + {{ PurchaseOrder }} + + {{ Approved }} + """, + NewOrder()); + + using var doc = WordprocessingDocument.Open(stream, false); + var body = doc.MainDocumentPart!.Document!.Body!; + + var starts = body.Descendants().ToList(); + var ends = body.Descendants().ToList(); + await Assert.That(starts.Count).IsEqualTo(2); + await Assert.That(ends.Count).IsEqualTo(2); + + var startIds = starts.Select(_ => _.Id!.Value).ToList(); + await Assert.That(startIds.Distinct().Count()).IsEqualTo(2); + await Assert.That(ends.Select(_ => _.Id!.Value)).IsEquivalentTo(startIds); + foreach (var start in starts) + { + await Assert.That(start.EditorGroup!.Value).IsEqualTo(RangePermissionEditingGroupValues.Everyone); + } + + // Deterministic ids also key the sdt controls. + var sdtIds = body.Descendants() + .Select(_ => _.SdtProperties!.GetFirstChild()!.Val!.Value) + .ToList(); + await Assert.That(sdtIds).IsEquivalentTo(startIds); + } + + [Test] + public async Task ProtectionAppliedWhenEditable() + { + using var stream = await Render("{{ PurchaseOrder }}", NewOrder()); + + using var doc = WordprocessingDocument.Open(stream, false); + var protection = doc.MainDocumentPart!.DocumentSettingsPart?.Settings?.GetFirstChild(); + await Assert.That(protection).IsNotNull(); + await Assert.That(protection!.Edit!.Value).IsEqualTo(DocumentProtectionValues.ReadOnly); + await Assert.That(protection.Enforcement!.Value).IsTrue(); + } + + [Test] + public async Task ProtectionSkippedWhenModeNone() + { + using var template = DocxTemplateBuilder.Build("{{ PurchaseOrder }}"); + var store = new TemplateStore(); + store.RegisterDocxTemplate("editable-unprotected", template, ProtectionMode.None); + + using var stream = new MemoryStream(); + await store.Render("editable-unprotected", NewOrder(), stream); + stream.Position = 0; + + using var doc = WordprocessingDocument.Open(stream, false); + var protection = doc.MainDocumentPart!.DocumentSettingsPart?.Settings?.GetFirstChild(); + await Assert.That(protection).IsNull(); + + // The field itself still renders as a tagged control. + await Assert.That(doc.MainDocumentPart.Document!.Body!.Descendants().Count()).IsEqualTo(1); + } + + [Test] + public async Task ProtectionNotAppliedWithoutEditableFields() + { + using var template = DocxTemplateBuilder.Build("Invoice {{ Number }}"); + var store = new TemplateStore(); + store.RegisterDocxTemplate("no-editable", template); + + using var stream = new MemoryStream(); + await store.Render("no-editable", SampleData.Invoice(), stream); + stream.Position = 0; + + using var doc = WordprocessingDocument.Open(stream, false); + var protection = doc.MainDocumentPart!.DocumentSettingsPart?.Settings?.GetFirstChild(); + await Assert.That(protection).IsNull(); + } + + [Test] + public async Task NonSoloTokenPreservesSurroundingText() + { + using var stream = await Render("PO: {{ PurchaseOrder }} (required)", NewOrder()); + + using var doc = WordprocessingDocument.Open(stream, false); + var body = doc.MainDocumentPart!.Document!.Body!; + await Assert.That(body.InnerText).IsEqualTo("PO: PO-2026-17 (required)"); + await Assert.That(FindSdt(body, "PurchaseOrder").InnerText).IsEqualTo("PO-2026-17"); + } + + [Test] + public async Task TwoEditableFieldsInOneParagraph() + { + using var stream = await Render("{{ PurchaseOrder }} / {{ Discount }}", NewOrder()); + + using var doc = WordprocessingDocument.Open(stream, false); + var body = doc.MainDocumentPart!.Document!.Body!; + await Assert.That(body.InnerText).IsEqualTo("PO-2026-17 / 10"); + await Assert.That(body.Descendants().Count()).IsEqualTo(2); + await Assert.That(body.Descendants().Count()).IsEqualTo(2); + await Assert.That(body.Descendants().Count()).IsEqualTo(2); + } + + [Test] + public async Task TokenStraddlingRunsSplicesCorrectly() + { + // Word splits text into multiple runs when formatting changes mid-token. The splicer + // must reassemble the token span across runs and keep the surrounding halves. + using var template = BuildStraddledTemplate(); + var store = new TemplateStore(); + store.RegisterDocxTemplate("editable-straddle", template); + + using var stream = new MemoryStream(); + await store.Render("editable-straddle", NewOrder(), stream); + stream.Position = 0; + + using var doc = WordprocessingDocument.Open(stream, false); + var body = doc.MainDocumentPart!.Document!.Body!; + await Assert.That(body.InnerText).IsEqualTo("PO: PO-2026-17 end"); + await Assert.That(FindSdt(body, "PurchaseOrder").InnerText).IsEqualTo("PO-2026-17"); + } + + [Test] + public async Task NestedPathBindsAndTags() + { + using var template = DocxTemplateBuilder.Build( + """ + Ref {{ Reference }} + + Email: {{ Customer.ContactEmail }} + """); + var store = new TemplateStore(); + store.RegisterDocxTemplate("editable-nested", template); + + var model = new EditableQuote + { + Reference = "Q-7", + Customer = new() + { + ContactEmail = "ada@example.com" + } + }; + using var stream = new MemoryStream(); + await store.Render("editable-nested", model, stream); + stream.Position = 0; + + using var doc = WordprocessingDocument.Open(stream, false); + var body = doc.MainDocumentPart!.Document!.Body!; + await Assert.That(FindSdt(body, "Customer.ContactEmail").InnerText).IsEqualTo("ada@example.com"); + } + + [Test] + public async Task EditableInsideChosenIfBranchRenders() + { + var template = + """ + {% if IncludeNotes %} + + Notes: {{ Notes }} + + {% else %} + + No notes on {{ Number }}. + + {% endif %} + """; + + var model = NewOrder(); + model.Notes = "call before delivery"; + using var chosen = await Render(template, model); + using (var doc = WordprocessingDocument.Open(chosen, false)) + { + await Assert.That(FindSdt(doc.MainDocumentPart!.Document!.Body!, "Notes").InnerText) + .IsEqualTo("call before delivery"); + } + + var without = NewOrder(); + without.IncludeNotes = false; + using var eliminated = await Render(template, without); + using (var doc = WordprocessingDocument.Open(eliminated, false)) + { + var body = doc.MainDocumentPart!.Document!.Body!; + await Assert.That(body.Descendants().Any()).IsFalse(); + await Assert.That(body.InnerText).IsEqualTo("No notes on ORD-100."); + } + } + + [Test] + public async Task EditableInsideLoopRendersPlainText() + { + using var stream = await Render( + """ + {% for tag in Tags %} + + {{ PurchaseOrder }} + + {% endfor %} + """, + NewOrder()); + + using var doc = WordprocessingDocument.Open(stream, false); + var body = doc.MainDocumentPart!.Document!.Body!; + await Assert.That(body.Descendants().Any()).IsFalse(); + await Assert.That(body.InnerText).IsEqualTo("PO-2026-17PO-2026-17"); + } + + [Test] + public async Task HeaderOccurrenceRendersPlainText() + { + // Word does not reliably honor editable-range exceptions outside the body, so header / + // footer occurrences of an editable member render as plain read-only substitutions. + // This also means the body occurrence stays the single tagged control for extraction. + using var template = BuildTemplateWithHeader( + bodyText: "{{ PurchaseOrder }}", + headerText: "PO: {{ PurchaseOrder }}"); + var store = new TemplateStore(); + store.RegisterDocxTemplate("editable-header", template); + + using var stream = new MemoryStream(); + await store.Render("editable-header", NewOrder(), stream); + stream.Position = 0; + + using var doc = WordprocessingDocument.Open(stream, false); + await Assert.That(doc.MainDocumentPart!.Document!.Body!.Descendants().Count()).IsEqualTo(1); + + var header = doc.MainDocumentPart.HeaderParts.Single().Header!; + await Assert.That(header.Descendants().Any()).IsFalse(); + await Assert.That(header.InnerText).IsEqualTo("PO: PO-2026-17"); + } + + [Test] + public async Task OutputValidates() + { + using var stream = await Render( + """ + PO: {{ PurchaseOrder }} ({{ Discount }}) + + {{ Approved }} {{ Delivery }} {{ Status }} + + {{ Notes }} + """, + NewOrder()); + + using var doc = WordprocessingDocument.Open(stream, false); + var validator = new OpenXmlValidator(FileFormatVersions.Office2013); + var errors = validator.Validate(doc) + .Select(_ => $"{_.Description} @ {_.Path?.XPath}") + .ToList(); + await Assert.That(errors).IsEmpty(); + } + + [Test] + public async Task FilterOnEditableTokenIsRejected() + { + using var template = DocxTemplateBuilder.Build("{{ PurchaseOrder | upcase }}"); + var store = new TemplateStore(); + var exception = await Assert.That( + () => store.RegisterDocxTemplate("editable-filter", template)) + .Throws(); + await Assert.That(exception!.Message).Contains("plain member-access"); + } + + [Test] + public async Task DuplicateEditableTokenIsRejected() + { + using var template = DocxTemplateBuilder.Build( + """ + {{ PurchaseOrder }} + + Again: {{ PurchaseOrder }} + """); + var store = new TemplateStore(); + var exception = await Assert.That( + () => store.RegisterDocxTemplate("editable-duplicate", template)) + .Throws(); + await Assert.That(exception!.Message).Contains("more than once"); + } + + public class UnsupportedTypeModel + { + [EditableField] + public List Items { get; set; } = []; + } + + [Test] + public async Task UnsupportedMemberTypeIsRejected() + { + using var template = DocxTemplateBuilder.Build("x"); + var store = new TemplateStore(); + var exception = await Assert.That( + () => store.RegisterDocxTemplate("editable-unsupported", template)) + .Throws(); + await Assert.That(exception!.Message).Contains("unsupported type"); + } + + public class NullableBoolModel + { + [EditableField] + public bool? Maybe { get; set; } + } + + [Test] + public async Task NullableBoolIsRejected() + { + using var template = DocxTemplateBuilder.Build("x"); + var store = new TemplateStore(); + var exception = await Assert.That( + () => store.RegisterDocxTemplate("editable-nullable-bool", template)) + .Throws(); + await Assert.That(exception!.Message).Contains("bool?"); + } + + public class InitOnlyModel + { + [EditableField] + public string PurchaseOrder { get; init; } = ""; + } + + [Test] + public async Task InitOnlySetterIsRejected() + { + using var template = DocxTemplateBuilder.Build("x"); + var store = new TemplateStore(); + var exception = await Assert.That( + () => store.RegisterDocxTemplate("editable-init-only", template)) + .Throws(); + await Assert.That(exception!.Message).Contains("setter"); + } + + public class GetOnlyModel + { + public string Prefix = "x"; + + [EditableField] + public string Computed => Prefix; + } + + [Test] + public async Task GetOnlyMemberIsRejected() + { + using var template = DocxTemplateBuilder.Build("x"); + var store = new TemplateStore(); + var exception = await Assert.That( + () => store.RegisterDocxTemplate("editable-get-only", template)) + .Throws(); + await Assert.That(exception!.Message).Contains("setter"); + } + + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] + sealed class MarkdownAttribute : Attribute; + + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] + sealed class HtmlAttribute : Attribute; + + public class ConflictModel + { + [EditableField] + [Markdown] + public string Body { get; set; } = ""; + } + + [Test] + public async Task ConflictingFormatAttributeIsRejected() + { + using var template = DocxTemplateBuilder.Build("x"); + var store = new TemplateStore(); + var exception = await Assert.That( + () => store.RegisterDocxTemplate("editable-conflict", template)) + .Throws(); + await Assert.That(exception!.Message).Contains("cannot be combined"); + } + + public class MixedStructuralModel + { + [Html] + public string Body { get; set; } = ""; + + [EditableField] + public string PurchaseOrder { get; set; } = ""; + } + + [Test] + public async Task EditableSharingParagraphWithStructuralTokenIsRejected() + { + using var template = DocxTemplateBuilder.Build("{{ Body }} and {{ PurchaseOrder }}"); + var store = new TemplateStore(); + var exception = await Assert.That( + () => store.RegisterDocxTemplate("editable-mixed", template)) + .Throws(); + await Assert.That(exception!.Message).Contains("own paragraph"); + } + + public class EditableQuote + { + [EditableField] + public required string Reference { get; set; } + + public required EditableCustomer Customer; + } + + public class EditableCustomer + { + [EditableField] + public required string ContactEmail { get; set; } + } + + static async Task Render(string templateContent, object model, [CallerMemberName] string name = "") + { + using var template = DocxTemplateBuilder.Build(templateContent); + var store = new TemplateStore(); + store.RegisterDocxTemplate(name, template); + + var stream = new MemoryStream(); + await store.Render(name, model, stream); + stream.Position = 0; + return stream; + } + + static SdtRun FindSdt(OpenXmlCompositeElement root, string tag) + { + var sdt = root.Descendants() + .FirstOrDefault(_ => _.SdtProperties?.GetFirstChild()?.Val?.Value == tag); + if (sdt == null) + { + throw new InvalidOperationException($"No sdt with tag '{tag}' found"); + } + + return sdt; + } + + static MemoryStream BuildStraddledTemplate() + { + var stream = new MemoryStream(); + using (var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document)) + { + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new( + new Body( + new Paragraph( + new Run( + new Text("PO: {{ Purch") + { + Space = SpaceProcessingModeValues.Preserve + }), + new Run( + new RunProperties(new Bold()), + new Text("aseOrder }}") + { + Space = SpaceProcessingModeValues.Preserve + }), + new Run( + new Text(" end") + { + Space = SpaceProcessingModeValues.Preserve + })), + new SectionProperties( + new PageSize + { + Width = 6500, + Height = 8000 + }))); + } + + stream.Position = 0; + return stream; + } + + static MemoryStream BuildTemplateWithHeader(string bodyText, string headerText) + { + var stream = new MemoryStream(); + using (var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document)) + { + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new(new Body()); + var body = mainPart.Document.Body!; + + var headerPart = mainPart.AddNewPart("rIdHeader"); + headerPart.Header = new( + new Paragraph( + new Run( + new Text(headerText) + { + Space = SpaceProcessingModeValues.Preserve + }))); + + body.Append( + new Paragraph( + new Run( + new Text(bodyText) + { + Space = SpaceProcessingModeValues.Preserve + }))); + body.Append( + new SectionProperties( + new HeaderReference + { + Type = HeaderFooterValues.Default, + Id = "rIdHeader" + }, + new PageSize + { + Width = 6500, + Height = 8000 + })); + } + + stream.Position = 0; + return stream; + } +} diff --git a/src/Parchment.Tests/Extraction/ExtractTests.cs b/src/Parchment.Tests/Extraction/ExtractTests.cs new file mode 100644 index 0000000..63057cd --- /dev/null +++ b/src/Parchment.Tests/Extraction/ExtractTests.cs @@ -0,0 +1,421 @@ +using System.Globalization; +using W14 = DocumentFormat.OpenXml.Office2010.Word; + +public class ExtractTests +{ + [Test] + public async Task NoEditRoundTrip() + { + using var stream = await RenderOrder(EditableFieldTests.NewOrder()); + + var result = ParchmentExtractor.Extract(stream); + + await Assert.That(result.AllExtracted).IsTrue(); + await Assert.That(Field(result, "PurchaseOrder").Value).IsEqualTo("PO-2026-17"); + await Assert.That((bool)Field(result, "Approved").Value!).IsTrue(); + await Assert.That(Field(result, "Delivery").Value).IsEqualTo(new Date(2026, 7, 6)); + await Assert.That(Field(result, "Status").Value).IsEqualTo(EditableFieldTests.QuoteStatus.Submitted); + await Assert.That(Field(result, "Discount").Value).IsEqualTo(10m); + await Assert.That(Field(result, "Notes").State).IsEqualTo(FieldState.Empty); + await Assert.That(Field(result, "Instructions").Value).IsEqualTo("Line one\nLine two"); + + // Apply onto a model with divergent editable values — every editable member converges + // on the document's values, including null for the placeholder field. + var target = EditableFieldTests.NewOrder(); + target.PurchaseOrder = "OTHER"; + target.Approved = false; + target.Delivery = new(2000, 1, 1); + target.Status = EditableFieldTests.QuoteStatus.Draft; + target.Discount = 0m; + target.Notes = "prefilled"; + target.Instructions = null; + + result.ApplyTo(target); + + await Assert.That(target.PurchaseOrder).IsEqualTo("PO-2026-17"); + await Assert.That(target.Approved).IsTrue(); + await Assert.That(target.Delivery).IsEqualTo(new Date(2026, 7, 6)); + await Assert.That(target.Status).IsEqualTo(EditableFieldTests.QuoteStatus.Submitted); + await Assert.That(target.Discount).IsEqualTo(10m); + await Assert.That(target.Notes).IsNull(); + await Assert.That(target.Instructions).IsEqualTo("Line one\nLine two"); + } + + [Test] + public async Task EditedValuesRoundTrip() + { + using var stream = await RenderOrder(EditableFieldTests.NewOrder()); + + Edit(stream, body => + { + EditSdtText(body, "PurchaseOrder", "PO-EDITED"); + SetCheckbox(body, "Approved", false); + SetFullDate(body, "Delivery", new(2026, 8, 1)); + EditSdtText(body, "Status", "Accepted"); + EditSdtText(body, "Discount", "12.5"); + EditSdtText(body, "Notes", "call before delivery"); + EditSdtText(body, "Instructions", "single line"); + }); + + var result = ParchmentExtractor.Extract(stream); + + await Assert.That(result.AllExtracted).IsTrue(); + + var target = EditableFieldTests.NewOrder(); + result.ApplyTo(target); + + await Assert.That(target.PurchaseOrder).IsEqualTo("PO-EDITED"); + await Assert.That(target.Approved).IsFalse(); + await Assert.That(target.Delivery).IsEqualTo(new Date(2026, 8, 1)); + await Assert.That(target.Status).IsEqualTo(EditableFieldTests.QuoteStatus.Accepted); + await Assert.That(target.Discount).IsEqualTo(12.5m); + await Assert.That(target.Notes).IsEqualTo("call before delivery"); + await Assert.That(target.Instructions).IsEqualTo("single line"); + } + + [Test] + public async Task DeletedControlReportsMissing() + { + using var stream = await RenderOrder(EditableFieldTests.NewOrder()); + + Edit(stream, body => FindSdt(body, "PurchaseOrder").Remove()); + + var result = ParchmentExtractor.Extract(stream); + + await Assert.That(result.AllExtracted).IsFalse(); + await Assert.That(Field(result, "PurchaseOrder").State).IsEqualTo(FieldState.Missing); + + var target = EditableFieldTests.NewOrder(); + target.PurchaseOrder = "KEEP"; + result.ApplyTo(target); + await Assert.That(target.PurchaseOrder).IsEqualTo("KEEP"); + } + + [Test] + public async Task UnparseableNumberReportsParseFailedWithRawText() + { + using var stream = await RenderOrder(EditableFieldTests.NewOrder()); + + Edit(stream, body => EditSdtText(body, "Discount", "about twelve")); + + var result = ParchmentExtractor.Extract(stream); + + var discount = Field(result, "Discount"); + await Assert.That(result.AllExtracted).IsFalse(); + await Assert.That(discount.State).IsEqualTo(FieldState.ParseFailed); + await Assert.That(discount.RawText).IsEqualTo("about twelve"); + + var target = EditableFieldTests.NewOrder(); + target.Discount = 99m; + result.ApplyTo(target); + await Assert.That(target.Discount).IsEqualTo(99m); + } + + [Test] + public async Task ClearedNumberIsEmptyAndSkippedForNonNullable() + { + using var stream = await RenderOrder(EditableFieldTests.NewOrder()); + + Edit(stream, body => EditSdtText(body, "Discount", "")); + + var result = ParchmentExtractor.Extract(stream); + + await Assert.That(Field(result, "Discount").State).IsEqualTo(FieldState.Empty); + + var target = EditableFieldTests.NewOrder(); + target.Discount = 99m; + result.ApplyTo(target); + await Assert.That(target.Discount).IsEqualTo(99m); + } + + [Test] + public async Task DuplicateTagIsReportedAndFirstOccurrenceWins() + { + using var stream = await RenderOrder(EditableFieldTests.NewOrder()); + + Edit(stream, body => + { + var sdt = FindSdt(body, "PurchaseOrder"); + var clone = (SdtRun)sdt.CloneNode(true); + var text = clone.Descendants().First(); + text.Text = "SECOND"; + sdt.Parent!.AppendChild(clone); + }); + + var result = ParchmentExtractor.Extract(stream); + + var occurrences = result.Fields.Where(_ => _.Path == "PurchaseOrder").ToList(); + await Assert.That(occurrences.Count).IsEqualTo(2); + await Assert.That(occurrences[0].State).IsEqualTo(FieldState.Extracted); + await Assert.That(occurrences[0].Value).IsEqualTo("PO-2026-17"); + await Assert.That(occurrences[1].State).IsEqualTo(FieldState.Duplicate); + await Assert.That(occurrences[1].RawText).IsEqualTo("SECOND"); + await Assert.That(result.AllExtracted).IsFalse(); + } + + [Test] + public async Task NumberParsingHonorsCulture() + { + using var stream = await RenderOrder(EditableFieldTests.NewOrder()); + + Edit(stream, body => EditSdtText(body, "Discount", "12,5")); + + var german = ParchmentExtractor.Extract( + stream, + CultureInfo.GetCultureInfo("de-DE")); + await Assert.That(Field(german, "Discount").Value).IsEqualTo(12.5m); + } + + [Test] + public async Task NestedPathAppliesAndNullIntermediateThrowsBeforeApplying() + { + using var template = DocxTemplateBuilder.Build( + """ + Ref {{ Reference }} + + Email: {{ Customer.ContactEmail }} + """); + var store = new TemplateStore(); + store.RegisterDocxTemplate("extract-nested", template); + + using var stream = new MemoryStream(); + await store.Render( + "extract-nested", + new EditableFieldTests.EditableQuote + { + Reference = "Q-7", + Customer = new() + { + ContactEmail = "ada@example.com" + } + }, + stream); + stream.Position = 0; + + var result = ParchmentExtractor.Extract(stream); + await Assert.That(result.AllExtracted).IsTrue(); + + var good = new EditableFieldTests.EditableQuote + { + Reference = "OLD", + Customer = new() + { + ContactEmail = "old@example.com" + } + }; + result.ApplyTo(good); + await Assert.That(good.Reference).IsEqualTo("Q-7"); + await Assert.That(good.Customer.ContactEmail).IsEqualTo("ada@example.com"); + + // Null intermediate: reachability is validated before anything is written, so the + // reachable Reference field must remain untouched after the throw. + var bad = new EditableFieldTests.EditableQuote + { + Reference = "KEEP", + Customer = null! + }; + var exception = await Assert.That(() => result.ApplyTo(bad)) + .Throws(); + await Assert.That(exception!.Message).Contains("Customer.ContactEmail"); + await Assert.That(bad.Reference).IsEqualTo("KEEP"); + } + + [Test] + public async Task ModelWithoutEditableMembersIsRejected() + { + using var stream = new MemoryStream(); + var exception = await Assert.That(() => ParchmentExtractor.Extract(stream)) + .Throws(); + await Assert.That(exception!.Message).Contains("no [EditableField]"); + } + + [Test] + public async Task NonDocxStreamIsRejected() + { + using var stream = new MemoryStream([1, 2, 3, 4]); + await Assert.That(() => ParchmentExtractor.Extract(stream)) + .Throws(); + } + + [Test] + public async Task DocumentWithoutMatchingControlsReportsAllMissing() + { + using var foreign = DocxTemplateBuilder.Build("Unrelated document."); + + var result = ParchmentExtractor.Extract(foreign); + + await Assert.That(result.AllExtracted).IsFalse(); + await Assert.That(result.Fields.Count).IsEqualTo(10); + foreach (var field in result.Fields) + { + await Assert.That(field.State).IsEqualTo(FieldState.Missing); + } + } + + [Test] + public async Task TemporalTypesRoundTrip() + { + using var stream = await RenderOrder(EditableFieldTests.NewOrder()); + + var result = ParchmentExtractor.Extract(stream); + await Assert.That(result.AllExtracted).IsTrue(); + + // DateOnly — canonical w:fullDate at midnight. + await Assert.That(Field(result, "Delivery").Value).IsEqualTo(new Date(2026, 7, 6)); + + // DateTime — time-of-day preserved through w:fullDate; Kind normalizes to Unspecified. + await Assert.That(Field(result, "DispatchedAt").Value).IsEqualTo(new DateTime(2026, 7, 6, 14, 30, 0)); + + // DateTimeOffset — the +10:00 offset survives (parsed from the ISO run text, not a + // zeroed fullDate). This is the case the old code corrupted to +00:00. + var signed = (DateTimeOffset)Field(result, "SignedAt").Value!; + await Assert.That(signed).IsEqualTo(new DateTimeOffset(2026, 7, 6, 9, 0, 0, TimeSpan.FromHours(10))); + await Assert.That(signed.Offset).IsEqualTo(TimeSpan.FromHours(10)); + + // TimeOnly — round-trips via plain text. + await Assert.That(Field(result, "PickupTime").Value).IsEqualTo(new Time(16, 45, 0)); + + var target = new EditableFieldTests.EditableOrder + { + Number = "x", + Tags = [], + IncludeNotes = false, + PurchaseOrder = "" + }; + result.ApplyTo(target); + + await Assert.That(target.DispatchedAt).IsEqualTo(new DateTime(2026, 7, 6, 14, 30, 0)); + await Assert.That(target.SignedAt).IsEqualTo(new DateTimeOffset(2026, 7, 6, 9, 0, 0, TimeSpan.FromHours(10))); + await Assert.That(target.PickupTime).IsEqualTo(new Time(16, 45, 0)); + } + + [Test] + public async Task EditedDateTimeOffsetPreservesNewOffset() + { + using var stream = await RenderOrder(EditableFieldTests.NewOrder()); + + // User types a different instant with a different offset into the plain-text control. + Edit(stream, body => EditSdtText(body, "SignedAt", "2026-12-01T23:15:00-05:00")); + + var result = ParchmentExtractor.Extract(stream); + var signed = (DateTimeOffset)Field(result, "SignedAt").Value!; + + await Assert.That(signed).IsEqualTo(new DateTimeOffset(2026, 12, 1, 23, 15, 0, TimeSpan.FromHours(-5))); + } + + [Test] + public async Task UnparseableTimeReportsParseFailed() + { + using var stream = await RenderOrder(EditableFieldTests.NewOrder()); + + Edit(stream, body => EditSdtText(body, "PickupTime", "half four")); + + var result = ParchmentExtractor.Extract(stream); + var pickup = Field(result, "PickupTime"); + + await Assert.That(pickup.State).IsEqualTo(FieldState.ParseFailed); + await Assert.That(pickup.RawText).IsEqualTo("half four"); + await Assert.That(result.AllExtracted).IsFalse(); + } + + static async Task RenderOrder( + EditableFieldTests.EditableOrder model, + [CallerMemberName] string name = "") + { + using var template = DocxTemplateBuilder.Build( + """ + Order {{ Number }} + + {{ PurchaseOrder }} + + {{ Approved }} + + {{ Delivery }} + + {{ DispatchedAt }} + + {{ SignedAt }} + + {{ PickupTime }} + + {{ Status }} + + {{ Discount }} + + {{ Notes }} + + {{ Instructions }} + """); + var store = new TemplateStore(); + store.RegisterDocxTemplate(name, template); + + var stream = new MemoryStream(); + await store.Render(name, model, stream); + stream.Position = 0; + return stream; + } + + static ExtractedField Field(ExtractResult result, string path) => + result.Fields.Single(_ => _.Path == path); + + /// + /// Simulates a user editing the document: opens the stream writable, mutates, saves. + /// + static void Edit(MemoryStream stream, Action edit) + { + using (var doc = WordprocessingDocument.Open(stream, true)) + { + edit(doc.MainDocumentPart!.Document!.Body!); + doc.Save(); + } + + stream.Position = 0; + } + + static SdtRun FindSdt(Body body, string tag) + { + var sdt = body.Descendants() + .FirstOrDefault(_ => _.SdtProperties?.GetFirstChild()?.Val?.Value == tag); + if (sdt == null) + { + throw new InvalidOperationException($"No sdt with tag '{tag}' found"); + } + + return sdt; + } + + /// + /// Replaces the control's content the way Word does when a user types: placeholder flag + /// removed, content replaced with a plain run. + /// + static void EditSdtText(Body body, string tag, string text) + { + var sdt = FindSdt(body, tag); + sdt.SdtProperties!.RemoveAllChildren(); + var content = sdt.ChildElements.First(_ => _.LocalName == "sdtContent"); + content.RemoveAllChildren(); + content.AppendChild( + new Run( + new Text(text) + { + Space = SpaceProcessingModeValues.Preserve + })); + } + + static void SetCheckbox(Body body, string tag, bool value) + { + var sdt = FindSdt(body, tag); + var isChecked = sdt.SdtProperties! + .GetFirstChild()! + .GetFirstChild()!; + isChecked.Val = value ? W14.OnOffValues.One : W14.OnOffValues.Zero; + var text = sdt.Descendants().First(); + text.Text = value ? "☒" : "☐"; + } + + static void SetFullDate(Body body, string tag, DateTime value) + { + var sdt = FindSdt(body, tag); + sdt.SdtProperties!.GetFirstChild()!.FullDate = value; + } +} diff --git a/src/Parchment.Tests/Scenarios/editable-fields/input.docx b/src/Parchment.Tests/Scenarios/editable-fields/input.docx new file mode 100644 index 0000000..d8e3071 Binary files /dev/null and b/src/Parchment.Tests/Scenarios/editable-fields/input.docx differ diff --git a/src/Parchment.Tests/Scenarios/editable-fields/input.png b/src/Parchment.Tests/Scenarios/editable-fields/input.png new file mode 100644 index 0000000..1f419a2 Binary files /dev/null and b/src/Parchment.Tests/Scenarios/editable-fields/input.png differ diff --git a/src/Parchment.Tests/Scenarios/editable-fields/output#00.verified.txt b/src/Parchment.Tests/Scenarios/editable-fields/output#00.verified.txt new file mode 100644 index 0000000..514778b --- /dev/null +++ b/src/Parchment.Tests/Scenarios/editable-fields/output#00.verified.txt @@ -0,0 +1,8 @@ +{ + Text: +Order ORD-2026-042 +Purchase order: +Approved: +Status: +Notes: +} \ No newline at end of file diff --git a/src/Parchment.Tests/Scenarios/editable-fields/output#01.verified.txt b/src/Parchment.Tests/Scenarios/editable-fields/output#01.verified.txt new file mode 100644 index 0000000..e21b3d1 --- /dev/null +++ b/src/Parchment.Tests/Scenarios/editable-fields/output#01.verified.txt @@ -0,0 +1,5 @@ +Order ORD-2026-042 +Purchase order: +Approved: +Status: +Notes: \ No newline at end of file diff --git a/src/Parchment.Tests/Scenarios/editable-fields/output.verified.docx b/src/Parchment.Tests/Scenarios/editable-fields/output.verified.docx new file mode 100644 index 0000000..1c56687 Binary files /dev/null and b/src/Parchment.Tests/Scenarios/editable-fields/output.verified.docx differ diff --git a/src/Parchment.Tests/Scenarios/editable-fields/output.verified.png b/src/Parchment.Tests/Scenarios/editable-fields/output.verified.png new file mode 100644 index 0000000..c6d58c2 Binary files /dev/null and b/src/Parchment.Tests/Scenarios/editable-fields/output.verified.png differ diff --git a/src/Parchment/Attributes/ParchmentModelAttribute.cs b/src/Parchment/Attributes/ParchmentModelAttribute.cs index 03a7b43..667bb32 100644 --- a/src/Parchment/Attributes/ParchmentModelAttribute.cs +++ b/src/Parchment/Attributes/ParchmentModelAttribute.cs @@ -18,4 +18,12 @@ public sealed class ParchmentModelAttribute(string templatePath) : Attribute { public string TemplatePath { get; } = templatePath; + + /// + /// Controls document lockdown for templates whose model declares + /// members. Passed through to + /// + /// by the generated RegisterWith helper. Ignored for markdown templates. + /// + public ProtectionMode Protection { get; set; } = ProtectionMode.WhenEditable; } diff --git a/src/Parchment/Editable/EditableEntry.cs b/src/Parchment/Editable/EditableEntry.cs new file mode 100644 index 0000000..023abd3 --- /dev/null +++ b/src/Parchment/Editable/EditableEntry.cs @@ -0,0 +1,30 @@ +/// +/// One editable member reachable from the root model. reads the current +/// value at render time; writes an extracted value back onto a model +/// instance; reports whether every intermediate object on the dotted path +/// is non-null so extraction can validate before mutating. +/// +sealed record EditableEntry( + string DottedPath, + EditableFieldKind Kind, + Type ClrType, + bool IsNullable, + Func Getter, + Action Setter, + Func CanReach, + bool MultiLine, + string? DateFormat) +{ + /// + /// Friendly name surfaced in Word's content-control chrome (w:alias) — the leaf + /// member name. + /// + public string Alias { get; } = DottedPath[(DottedPath.LastIndexOf('.') + 1)..]; +} + +/// +/// Marker returned by ScopeTreeRunner.TryResolveEditable so the substitution loop can +/// route the token through the editable-field splice instead of plain text replacement. +/// Internal-only — never surfaced to user code (unlike ). +/// +sealed record EditableToken(EditableEntry Entry, object? Value); diff --git a/src/Parchment/Editable/EditableFieldBuilder.cs b/src/Parchment/Editable/EditableFieldBuilder.cs new file mode 100644 index 0000000..00abbfd --- /dev/null +++ b/src/Parchment/Editable/EditableFieldBuilder.cs @@ -0,0 +1,306 @@ +using W14 = DocumentFormat.OpenXml.Office2010.Word; +using SdtLock = DocumentFormat.OpenXml.Wordprocessing.Lock; + +/// +/// Builds the inline element triple for one editable field: +/// [w:permStart, w:sdt, w:permEnd]. The perm range (edGrp="everyone") punches an editable +/// exception through the document's read-only protection; the content control carries the dotted +/// model path as its w:tag (the round-trip key), w:lock="sdtLocked" so users can +/// edit the content but not delete the control, and a kind element per +/// . Checkbox / date / dropdown controls store canonical values +/// (w14:checked, w:fullDate, w:listItem/@w:value) so extraction does not +/// depend on parsing display text. +/// +static class EditableFieldBuilder +{ + const string placeholderText = "Click or tap here to enter text."; + + public static IReadOnlyList Build( + EditableEntry entry, + object? value, + RunProperties? sitePr, + EditableState state, + CultureInfo culture) + { + var id = state.NextId(); + + var sdtPr = new SdtProperties(); + if (sitePr != null) + { + sdtPr.AppendChild((RunProperties)sitePr.CloneNode(true)); + } + + sdtPr.AppendChild(new SdtAlias + { + Val = entry.Alias + }); + sdtPr.AppendChild(new Tag + { + Val = entry.DottedPath + }); + sdtPr.AppendChild(new SdtId + { + Val = id + }); + sdtPr.AppendChild(new SdtLock + { + Val = LockingValues.SdtLocked + }); + + var (kindElement, content, isPlaceholder) = BuildKind(entry, value, sitePr, culture); + if (isPlaceholder) + { + sdtPr.AppendChild(new ShowingPlaceholder()); + } + + sdtPr.AppendChild(kindElement); + + return + [ + new PermStart + { + Id = id, + EditorGroup = RangePermissionEditingGroupValues.Everyone + }, + new SdtRun(sdtPr, new SdtContentRun(content)), + new PermEnd + { + Id = id + } + ]; + } + + static (OpenXmlElement KindElement, Run Content, bool IsPlaceholder) BuildKind( + EditableEntry entry, + object? value, + RunProperties? sitePr, + CultureInfo culture) => + entry.Kind switch + { + EditableFieldKind.Text => BuildText(entry, value as string, sitePr), + EditableFieldKind.Number => BuildNumber(value, sitePr, culture), + EditableFieldKind.Checkbox => BuildCheckbox(value, sitePr), + EditableFieldKind.Date => BuildDate(entry, value, sitePr, culture), + EditableFieldKind.DateTimeOffset => BuildTextValue(FormatDateTimeOffset(entry, value, culture), sitePr), + EditableFieldKind.Time => BuildTextValue(FormatTime(entry, value, culture), sitePr), + EditableFieldKind.DropDown => BuildDropDown(entry, value, sitePr), + _ => throw new InvalidOperationException($"Unknown editable kind {entry.Kind}") + }; + + static (OpenXmlElement, Run, bool) BuildText(EditableEntry entry, string? value, RunProperties? sitePr) + { + var kind = new SdtContentText(); + if (entry.MultiLine) + { + kind.MultiLine = true; + } + + if (string.IsNullOrEmpty(value)) + { + return (kind, PlaceholderRun(sitePr), true); + } + + return (kind, TextRun(value, sitePr, entry.MultiLine), false); + } + + static (OpenXmlElement, Run, bool) BuildNumber(object? value, RunProperties? sitePr, CultureInfo culture) + { + var kind = new SdtContentText(); + if (value == null) + { + return (kind, PlaceholderRun(sitePr), true); + } + + var text = ((IFormattable)value).ToString(null, culture); + return (kind, TextRun(text, sitePr, multiLine: false), false); + } + + static (OpenXmlElement, Run, bool) BuildCheckbox(object? value, RunProperties? sitePr) + { + // bool? is rejected at map build, so value is always a non-null bool here. + var isChecked = (bool)value!; + var kind = new W14.SdtContentCheckBox( + new W14.Checked + { + Val = isChecked ? W14.OnOffValues.One : W14.OnOffValues.Zero + }, + new W14.CheckedState + { + Font = "MS Gothic", + Val = "2612" + }, + new W14.UncheckedState + { + Font = "MS Gothic", + Val = "2610" + }); + + // The glyph must render from a font that has it; force MS Gothic on the content run, + // matching what Word itself emits when a checkbox is toggled. + var pr = ClonePr(sitePr); + pr.RemoveAllChildren(); + InsertAfterRunStyle( + pr, + new RunFonts + { + Ascii = "MS Gothic", + HighAnsi = "MS Gothic", + EastAsia = "MS Gothic" + }); + + var run = new Run(pr); + run.AppendChild(new Text(isChecked ? "☒" : "☐")); + return (kind, run, false); + } + + static (OpenXmlElement, Run, bool) BuildDate(EditableEntry entry, object? value, RunProperties? sitePr, CultureInfo culture) + { + var format = entry.DateFormat ?? "yyyy-MM-dd"; + var kind = new SdtContentDate( + new DateFormat + { + Val = format + }); + + if (value == null) + { + return (kind, PlaceholderRun(sitePr), true); + } + + // Only DateOnly and DateTime reach here (see BuildKind). DateTime's time-of-day is + // preserved in w:fullDate even though the default format shows date only. + var dateTime = value switch + { + Date date => date.ToDateTime(Time.MinValue), + _ => (DateTime)value + }; + kind.FullDate = dateTime; + return (kind, TextRun(dateTime.ToString(format, culture), sitePr, multiLine: false), false); + } + + // Round-trippable ISO 8601 defaults for the text-backed temporal kinds. DateTimeOffset keeps + // its offset (zzz); both are re-parsed verbatim by EditableFieldReader. Seconds precision by + // default — a caller needing sub-seconds sets DateFormat (e.g. "o"). + const string dateTimeOffsetFormat = "yyyy-MM-ddTHH:mm:sszzz"; + const string timeFormat = "HH:mm:ss"; + + static string? FormatDateTimeOffset(EditableEntry entry, object? value, CultureInfo culture) => + value is DateTimeOffset offset + ? offset.ToString(entry.DateFormat ?? dateTimeOffsetFormat, culture) + : null; + + static string? FormatTime(EditableEntry entry, object? value, CultureInfo culture) => + value is Time time + ? time.ToString(entry.DateFormat ?? timeFormat, culture) + : null; + + static (OpenXmlElement, Run, bool) BuildTextValue(string? text, RunProperties? sitePr) + { + var kind = new SdtContentText(); + if (string.IsNullOrEmpty(text)) + { + return (kind, PlaceholderRun(sitePr), true); + } + + return (kind, TextRun(text, sitePr, multiLine: false), false); + } + + static (OpenXmlElement, Run, bool) BuildDropDown(EditableEntry entry, object? value, RunProperties? sitePr) + { + var kind = new SdtContentDropDownList(); + foreach (var name in Enum.GetNames(entry.ClrType)) + { + kind.AppendChild( + new ListItem + { + DisplayText = name, + Value = name + }); + } + + if (value == null) + { + return (kind, PlaceholderRun(sitePr), true); + } + + return (kind, TextRun(value.ToString()!, sitePr, multiLine: false), false); + } + + static Run TextRun(string value, RunProperties? sitePr, bool multiLine) + { + var run = new Run(); + if (sitePr != null) + { + run.AppendChild((RunProperties)sitePr.CloneNode(true)); + } + + var cleaned = XmlCharSanitizer.Strip(value).ToString(); + if (multiLine) + { + var first = true; + foreach (var line in cleaned.Split('\n')) + { + if (!first) + { + run.AppendChild(new Break()); + } + + first = false; + if (line.Length > 0) + { + run.AppendChild( + new Text(line.TrimEnd('\r')) + { + Space = SpaceProcessingModeValues.Preserve + }); + } + } + } + else + { + run.AppendChild( + new Text(cleaned.Replace("\r\n", " ").Replace('\n', ' ').Replace('\r', ' ')) + { + Space = SpaceProcessingModeValues.Preserve + }); + } + + return run; + } + + static Run PlaceholderRun(RunProperties? sitePr) + { + var pr = ClonePr(sitePr); + // Word's built-in grey placeholder look. The style may not exist in the template's + // styles part — Word tolerates the dangling reference and falls back to plain text. + pr.InsertAt( + new RunStyle + { + Val = "PlaceholderText" + }, + 0); + var run = new Run(pr); + run.AppendChild( + new Text(placeholderText) + { + Space = SpaceProcessingModeValues.Preserve + }); + return run; + } + + static RunProperties ClonePr(RunProperties? sitePr) => + sitePr == null ? new() : (RunProperties)sitePr.CloneNode(true); + + static void InsertAfterRunStyle(RunProperties pr, OpenXmlElement element) + { + // rPr schema order: rStyle first, rFonts immediately after. + if (pr.GetFirstChild() is { } style) + { + pr.InsertAfter(element, style); + } + else + { + pr.InsertAt(element, 0); + } + } +} diff --git a/src/Parchment/Editable/EditableMap.cs b/src/Parchment/Editable/EditableMap.cs new file mode 100644 index 0000000..7a3c00d --- /dev/null +++ b/src/Parchment/Editable/EditableMap.cs @@ -0,0 +1,318 @@ +/// +/// Cache of [EditableField]-marked members reachable from a model type, keyed by dotted +/// path from the root model. Built once at template registration time (and consulted by +/// ParchmentExtractor); render-time lookup is a single dictionary hit. Model-shape rules +/// (supported type, usable setter, no conflicting attributes) are enforced here so misuse fails +/// at registration — the source generator mirrors the same rules as PARCH013–PARCH015. +/// +sealed class EditableMap +{ + static ConcurrentDictionary precompiledCache = new(); + + Dictionary entries; + + EditableMap(Dictionary entries) => + this.entries = entries; + + public static EditableMap Empty { get; } = new(new(StringComparer.OrdinalIgnoreCase)); + + public bool IsEmpty => entries.Count == 0; + + public IReadOnlyCollection Entries => entries.Values; + + public bool TryGet(string dottedPath, [NotNullWhen(true)] out EditableEntry? entry) => + entries.TryGetValue(dottedPath, out entry); + + public static EditableMap Build(Type modelType, string templateName) + { + if (precompiledCache.TryGetValue(modelType, out var cached)) + { + return cached; + } + + var entries = new Dictionary(StringComparer.OrdinalIgnoreCase); + var visited = new HashSet + { + modelType + }; + // NullabilityInfoContext is not thread-safe — one per build. + var nullability = new NullabilityInfoContext(); + WalkType(modelType, [], static root => root, entries, visited, nullability, templateName); + return new(entries); + } + + internal static void RegisterPrecompiled(Type modelType, IEnumerable entries) + { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in entries) + { + dict[entry.DottedPath] = new( + entry.DottedPath, + entry.Kind, + entry.ClrType, + entry.IsNullable, + entry.Getter, + entry.Setter, + entry.CanReach, + entry.MultiLine, + entry.DateFormat); + } + + precompiledCache[modelType] = new(dict); + } + + static void WalkType( + Type type, + List pathSegments, + Func parentGetter, + Dictionary entries, + HashSet visited, + NullabilityInfoContext nullability, + string templateName) + { + foreach (var (name, memberType, memberGetter, member) in FormatMap.EnumerateMembers(type)) + { + var nextSegments = new List(pathSegments) + { + name + }; + var getter = ChainGetter(parentGetter, memberGetter); + + var attribute = member.GetCustomAttribute(true); + if (attribute != null) + { + var dottedPath = string.Join('.', nextSegments); + entries[dottedPath] = BuildEntry( + dottedPath, + type, + member, + memberType, + attribute, + parentGetter, + getter, + nullability, + templateName); + continue; + } + + var memberUnderlying = Nullable.GetUnderlyingType(memberType) ?? memberType; + if (!ShouldDescend(memberUnderlying)) + { + continue; + } + + if (!visited.Add(memberUnderlying)) + { + continue; + } + + WalkType(memberUnderlying, nextSegments, getter, entries, visited, nullability, templateName); + visited.Remove(memberUnderlying); + } + } + + static EditableEntry BuildEntry( + string dottedPath, + Type owner, + MemberInfo member, + Type memberType, + EditableFieldAttribute attribute, + Func parentGetter, + Func getter, + NullabilityInfoContext nullability, + string templateName) + { + GuardConflictingAttributes(owner, member, templateName); + + var underlying = Nullable.GetUnderlyingType(memberType); + var effective = underlying ?? memberType; + var kind = MapKind(effective); + if (kind == null) + { + throw new ParchmentRegistrationException( + templateName, + $"[EditableField] member '{owner.Name}.{member.Name}' has unsupported type '{memberType.Name}'. Supported: string, bool, DateOnly, DateTime, DateTimeOffset, TimeOnly, enums, and numeric types (nullable variants except bool?)."); + } + + if (kind == EditableFieldKind.Checkbox && + underlying != null) + { + throw new ParchmentRegistrationException( + templateName, + $"[EditableField] member '{owner.Name}.{member.Name}' is bool? — a checkbox cannot represent null. Use a non-nullable bool."); + } + + var setter = BuildSetter(owner, member, parentGetter, templateName); + var isNullable = underlying != null || IsNullableReference(member, nullability); + + return new( + dottedPath, + kind.Value, + effective, + isNullable, + getter, + setter, + root => parentGetter(root) != null, + attribute.MultiLine, + attribute.DateFormat); + } + + static void GuardConflictingAttributes(Type owner, MemberInfo member, string templateName) + { + string? conflict = null; + foreach (var attribute in member.GetCustomAttributes(true)) + { + var name = attribute.GetType().Name; + if (attribute is ExcelsiorTableAttribute) + { + conflict = "ExcelsiorTable"; + } + else if (name is "HtmlAttribute" or "MarkdownAttribute") + { + conflict = name[..^"Attribute".Length]; + } + else if (attribute is StringSyntaxAttribute syntax && + syntax.Syntax.ToLowerInvariant() is "html" or "markdown") + { + conflict = $"StringSyntax(\"{syntax.Syntax}\")"; + } + } + + if (conflict != null) + { + throw new ParchmentRegistrationException( + templateName, + $"Member '{owner.Name}.{member.Name}': [EditableField] cannot be combined with [{conflict}] — an editable field is plain typed content, not rendered markup."); + } + } + + static Action BuildSetter(Type owner, MemberInfo member, Func parentGetter, string templateName) + { + // The setter receives the ROOT model; the member lives on the object at the end of the + // parent path. Callers (ExtractResult.ApplyTo) validate reachability via CanReach before + // invoking, so the parent is non-null here. + if (member is PropertyInfo property) + { + var set = property.SetMethod; + if (set is not { IsPublic: true } || IsInitOnly(set)) + { + throw new ParchmentRegistrationException( + templateName, + $"[EditableField] member '{owner.Name}.{member.Name}' has no public non-init setter. Extraction writes values back onto the model, so the member must be settable."); + } + + return (root, value) => property.SetValue(parentGetter(root), value); + } + + var field = (FieldInfo)member; + if (field.IsInitOnly) + { + throw new ParchmentRegistrationException( + templateName, + $"[EditableField] member '{owner.Name}.{member.Name}' is a readonly field. Extraction writes values back onto the model, so the member must be settable."); + } + + return (root, value) => field.SetValue(parentGetter(root), value); + } + + static bool IsInitOnly(MethodInfo setMethod) => + setMethod.ReturnParameter + .GetRequiredCustomModifiers() + .Contains(typeof(System.Runtime.CompilerServices.IsExternalInit)); + + static bool IsNullableReference(MemberInfo member, NullabilityInfoContext nullability) + { + var info = member switch + { + PropertyInfo property => nullability.Create(property), + FieldInfo field => nullability.Create(field), + _ => null + }; + return info?.WriteState == NullabilityState.Nullable; + } + + static EditableFieldKind? MapKind(Type type) + { + if (type == typeof(string)) + { + return EditableFieldKind.Text; + } + + if (type == typeof(bool)) + { + return EditableFieldKind.Checkbox; + } + + // DateOnly (aliased Date) and DateTime map to the native date picker, which stores a + // canonical w:fullDate. DateTimeOffset and TimeOnly cannot: w:fullDate is a bare DateTime + // with no offset, and Word has no time-only picker — so those render as round-trippable + // plain text instead (see EditableFieldBuilder / EditableFieldReader). + if (type == typeof(Date) || + type == typeof(DateTime)) + { + return EditableFieldKind.Date; + } + + if (type == typeof(DateTimeOffset)) + { + return EditableFieldKind.DateTimeOffset; + } + + if (type == typeof(Time)) + { + return EditableFieldKind.Time; + } + + if (type.IsEnum) + { + return EditableFieldKind.DropDown; + } + + if (type == typeof(byte) || + type == typeof(sbyte) || + type == typeof(short) || + type == typeof(ushort) || + type == typeof(int) || + type == typeof(uint) || + type == typeof(long) || + type == typeof(ulong) || + type == typeof(float) || + type == typeof(double) || + type == typeof(decimal)) + { + return EditableFieldKind.Number; + } + + return null; + } + + static Func ChainGetter(Func upstream, Func memberGetter) => + root => + { + var parent = upstream(root); + return parent == null ? null : memberGetter(parent); + }; + + static bool ShouldDescend(Type type) + { + if (type.IsPrimitive || type.IsEnum) + { + return false; + } + + if (type == typeof(string) || + type == typeof(decimal) || + type == typeof(DateTime) || + type == typeof(DateTimeOffset) || + type == typeof(Date) || + type == typeof(Time) || + type == typeof(TimeSpan) || + type == typeof(Guid) || + type == typeof(Uri)) + { + return false; + } + + return !typeof(IEnumerable).IsAssignableFrom(type); + } +} diff --git a/src/Parchment/Editable/EditableSplicer.cs b/src/Parchment/Editable/EditableSplicer.cs new file mode 100644 index 0000000..ed6f389 --- /dev/null +++ b/src/Parchment/Editable/EditableSplicer.cs @@ -0,0 +1,227 @@ +/// +/// Replaces a token's character range inside a live paragraph with a sequence of inline elements +/// (the editable-field permStart / sdt / permEnd triple), preserving every sibling in place. +/// +/// 's clone-and-trim approach is unsuitable here: it distributes +/// zero-width elements (bookmarks, perm-range markers, emptied sdt shells) into both +/// halves, which corrupts previously-spliced editable fields when a paragraph hosts more than +/// one. This splicer instead splits runs at the exact token boundaries and inserts between them, +/// touching nothing outside the token's character range. +/// +/// Tokens are processed in reverse-offset order (see ScopeTreeRunner), so any previously +/// inserted field sits at a higher offset than the current token and is never walked into. +/// +static class EditableSplicer +{ + public static void Insert( + Paragraph host, + int offset, + int length, + Func> factory) + { + // Formatting rule: the run owning the first character of the token wins — same as + // ParagraphText.Replace. + var firstRun = RunAt(host, offset); + var sitePr = (RunProperties?)firstRun?.RunProperties?.CloneNode(true); + var produced = factory(sitePr); + + var insertAfter = EnsureBoundary(host, offset); + EnsureBoundary(host, offset + length); + RemoveCoveredTexts(host, offset, offset + length); + + var cursor = insertAfter ?? host.ParagraphProperties; + foreach (var element in produced) + { + cursor = cursor == null + ? host.InsertAt(element, 0) + : host.InsertAfter(element, cursor); + } + } + + static Run? RunAt(Paragraph host, int offset) + { + var consumed = 0; + foreach (var text in host.Descendants()) + { + var end = consumed + text.Text.Length; + if (offset >= consumed && offset < end) + { + return text.Ancestors().FirstOrDefault(); + } + + consumed = end; + } + + return null; + } + + /// + /// Ensures a paragraph-level element boundary exists at and + /// returns the direct child of after which content at that offset + /// begins (null when the offset is at the very start of the paragraph's content). + /// + static OpenXmlElement? EnsureBoundary(Paragraph host, int absOffset) + { + var consumed = 0; + OpenXmlElement? lastTopLevelBefore = null; + foreach (var text in host.Descendants().ToList()) + { + var start = consumed; + var end = consumed + text.Text.Length; + consumed = end; + + if (end <= absOffset) + { + lastTopLevelBefore = TopLevel(host, text); + continue; + } + + var run = (Run)text.Parent!; + if (start >= absOffset) + { + // Boundary falls exactly before this text. If the run holds earlier content, + // split the run so the boundary is at paragraph level. + if (HasContentBefore(run, text)) + { + SplitRunBefore(host, run, text); + return TopLevel(host, run); + } + + return TopLevel(host, run).PreviousSibling(); + } + + // Boundary falls inside this text — split the text, then the run. + var local = absOffset - start; + var value = text.Text; + var tail = new Text(value[local..]); + text.Text = value[..local]; + if (text.Text.Length > 0) + { + text.Space = SpaceProcessingModeValues.Preserve; + } + + if (tail.Text.Length > 0) + { + tail.Space = SpaceProcessingModeValues.Preserve; + } + + run.InsertAfter(tail, text); + SplitRunBefore(host, run, tail); + return TopLevel(host, run); + } + + // Offset is at (or past) the end of the paragraph's text. + return lastTopLevelBefore ?? host.ChildElements.LastOrDefault(_ => _ is not ParagraphProperties); + } + + static bool HasContentBefore(Run run, Text text) + { + foreach (var child in run.ChildElements) + { + if (child == text) + { + return false; + } + + if (child is not RunProperties) + { + return true; + } + } + + return false; + } + + /// + /// Moves and every following sibling out of + /// into a fresh run (cloning run properties) inserted immediately after, so a paragraph-level + /// boundary exists just before . When the run is nested (inside a + /// hyperlink etc.) the split happens within that container — the boundary is then not at + /// paragraph level, which is fine: token scanning never produces tokens straddling such + /// containers and the host paragraphs Parchment mutates keep runs as direct children. + /// + static void SplitRunBefore(Paragraph host, Run run, OpenXmlElement from) + { + var second = new Run(); + if (run.RunProperties != null) + { + second.AppendChild((RunProperties)run.RunProperties.CloneNode(true)); + } + + var moving = new List(); + var found = false; + foreach (var child in run.ChildElements) + { + if (child == from) + { + found = true; + } + + if (found) + { + moving.Add(child); + } + } + + foreach (var element in moving) + { + element.Remove(); + second.AppendChild(element); + } + + run.Parent!.InsertAfter(second, run); + } + + static void RemoveCoveredTexts(Paragraph host, int from, int to) + { + var consumed = 0; + var toRemove = new List(); + foreach (var text in host.Descendants().ToList()) + { + var start = consumed; + var end = consumed + text.Text.Length; + consumed = end; + + // Boundary splits already ran, so covered texts are whole texts. + if (start >= from && end <= to && text.Text.Length > 0) + { + toRemove.Add(text); + } + else if (start >= to) + { + break; + } + } + + var owners = new HashSet(); + foreach (var text in toRemove) + { + if (text.Parent is Run run) + { + owners.Add(run); + } + + text.Remove(); + } + + foreach (var run in owners) + { + if (!run.Descendants().Any()) + { + run.Remove(); + } + } + } + + static OpenXmlElement TopLevel(Paragraph host, OpenXmlElement element) + { + var current = element; + while (current.Parent != null && + current.Parent != host) + { + current = current.Parent; + } + + return current; + } +} diff --git a/src/Parchment/Editable/EditableState.cs b/src/Parchment/Editable/EditableState.cs new file mode 100644 index 0000000..a1fbda9 --- /dev/null +++ b/src/Parchment/Editable/EditableState.cs @@ -0,0 +1,11 @@ +/// +/// Per-render allocator for the ids shared by editable-field elements: w:sdt ids and the +/// w:permStart/w:permEnd pair ids. Sequential in processing order, which keeps +/// renders deterministic (same template + same model → same ids) without any randomness. +/// +sealed class EditableState +{ + int next = 1; + + public int NextId() => next++; +} diff --git a/src/Parchment/Editable/EditableTokenValidator.cs b/src/Parchment/Editable/EditableTokenValidator.cs new file mode 100644 index 0000000..430716e --- /dev/null +++ b/src/Parchment/Editable/EditableTokenValidator.cs @@ -0,0 +1,128 @@ +/// +/// Registration-time validator for substitution tokens that resolve to an +/// [EditableField] member. Rules: +/// +/// plain member-access expression only — the editable render path bypasses Fluid, so a +/// filter chain would be silently ignored; +/// each editable path may appear at most once in the document body — the dotted path is +/// the content control's tag, and extraction needs tags to be unique; +/// an editable token may not share its paragraph with a structural token +/// ([ExcelsiorTable] / [Html] / [Markdown]) — structural splice/split +/// machinery clones paragraph halves, which would corrupt the field's perm-range markers. +/// +/// +static class EditableTokenValidator +{ + public static void Validate( + IReadOnlyList classifications, + EditableMap editables, + ExcelsiorTableMap excelsiorTables, + FormatMap formats, + string templateName, + string partUri, + bool isBody) + { + if (editables.IsEmpty) + { + return; + } + + var seen = isBody ? new HashSet(StringComparer.OrdinalIgnoreCase) : null; + + foreach (var classification in classifications) + { + if (classification.Kind != ParagraphKind.Substitution) + { + continue; + } + + List? editableTokens = null; + foreach (var token in classification.Substitutions) + { + if (token.References.Count == 0) + { + continue; + } + + if (!editables.TryGet(token.References[0].Dotted, out _)) + { + continue; + } + + (editableTokens ??= []).Add(token); + } + + if (editableTokens == null) + { + continue; + } + + foreach (var token in editableTokens) + { + RequirePlainIdentifier(token, templateName, partUri); + + if (seen != null && + !seen.Add(token.References[0].Dotted)) + { + throw new ParchmentRegistrationException( + templateName, + $"[EditableField] token '{token.Source}' appears more than once in the document body. The dotted path is the content control's tag and must be unique for extraction — reference each editable member once.", + partUri, + token.Source); + } + } + + if (classification.Substitutions.Count == editableTokens.Count) + { + continue; + } + + foreach (var token in classification.Substitutions) + { + if (token.References.Count == 0) + { + continue; + } + + var dotted = token.References[0].Dotted; + if (excelsiorTables.TryGet(dotted, out _) || + formats.TryGet(dotted, out _)) + { + throw new ParchmentRegistrationException( + templateName, + $"Token '{token.Source}' produces structural content but shares its paragraph with an editable field ('{editableTokens[0].Source}'). Structural replacement clones paragraph halves and would corrupt the field's editable range. Move one of the tokens to its own paragraph.", + partUri, + token.Source); + } + } + } + } + + static void RequirePlainIdentifier(DocxTokenSite token, string templateName, string partUri) + { + var statements = ((Fluid.Parser.FluidTemplate)token.Template).Statements; + if (statements.Count == 0) + { + return; + } + + if (statements[0] is not OutputStatement output) + { + return; + } + + if (output.Expression is MemberExpression) + { + return; + } + + throw new ParchmentRegistrationException( + templateName, + $$$""" + [EditableField] token '{{{token.Source}}}' must be a plain member-access expression (for example '{{ PurchaseOrder }}' or '{{ Customer.Notes }}'). + Filters, arithmetic, and literal expressions are not supported — the editable render path is selected by attribute, so filters would not be applied. + """, + partUri, + token.Source); + } +} diff --git a/src/Parchment/Editable/ProtectionMode.cs b/src/Parchment/Editable/ProtectionMode.cs new file mode 100644 index 0000000..3654771 --- /dev/null +++ b/src/Parchment/Editable/ProtectionMode.cs @@ -0,0 +1,24 @@ +namespace Parchment; + +/// +/// Controls whether a registered docx template's output is locked down with +/// w:documentProtection w:edit="readOnly" (with editable-range exceptions around each +/// site). Protection is cooperative, not security: Word +/// enforces it in the UI, but anyone can remove it (no password is set) or edit the underlying +/// XML directly. +/// +public enum ProtectionMode +{ + /// + /// Apply read-only protection when the model declares at least one + /// member. Templates without editable fields are left + /// unprotected. This is the default. + /// + WhenEditable, + + /// + /// Never apply protection. Editable fields still render as tagged content controls (and + /// still extract), but the rest of the document remains editable too. + /// + None +} diff --git a/src/Parchment/EditableFieldAttribute.cs b/src/Parchment/EditableFieldAttribute.cs new file mode 100644 index 0000000..ad61736 --- /dev/null +++ b/src/Parchment/EditableFieldAttribute.cs @@ -0,0 +1,35 @@ +namespace Parchment; + +/// +/// Marks a model property or field as user-editable in the rendered docx. The substitution site +/// is wrapped in a Word content control (w:sdt) tagged with the member's dotted path, and +/// an editable-range exception (w:permStart/w:permEnd, everyone) is punched around +/// it. When the registered template contains at least one editable field, the output document is +/// locked read-only via w:documentProtection — see . +/// Values entered by users are read back via ParchmentExtractor. +/// +/// Supported member types: string, bool (checkbox), DateOnly / +/// DateTime (date picker), DateTimeOffset / TimeOnly (round-trippable plain +/// text — Word has no offset or time-only picker), enums (dropdown), and the numeric primitives / +/// decimal. Nullable variants are supported except bool? — a checkbox cannot +/// represent null. The member must have a public non-init setter so extraction can write back. +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public sealed class EditableFieldAttribute : + Attribute +{ + /// + /// For string members: allow multi-line input (w:text w:multiLine="1"). Newlines in + /// the rendered value become soft line breaks; extraction maps breaks back to \n. + /// Non-multiline string values have newlines collapsed to spaces. + /// + public bool MultiLine { get; set; } + + /// + /// For date members: the display format of the date picker (w:dateFormat), also used + /// to format the rendered text. Defaults to yyyy-MM-dd. The canonical value read back + /// by extraction comes from the control's w:fullDate, so the display format does not + /// need to be round-trippable. + /// + public string? DateFormat { get; set; } +} diff --git a/src/Parchment/Errors/ParchmentExtractionException.cs b/src/Parchment/Errors/ParchmentExtractionException.cs new file mode 100644 index 0000000..500eb2d --- /dev/null +++ b/src/Parchment/Errors/ParchmentExtractionException.cs @@ -0,0 +1,10 @@ +namespace Parchment; + +/// +/// Raised by when a document cannot be read (not a docx, no +/// main body), when the model declares no editable members, or by +/// ExtractResult<TModel>.ApplyTo when extracted values cannot be written back +/// because intermediate objects on a dotted path are null. +/// +public sealed class ParchmentExtractionException(string message, Exception? inner = null) : + ParchmentException(message, inner); diff --git a/src/Parchment/Extraction/EditableFieldReader.cs b/src/Parchment/Extraction/EditableFieldReader.cs new file mode 100644 index 0000000..f1d4246 --- /dev/null +++ b/src/Parchment/Extraction/EditableFieldReader.cs @@ -0,0 +1,214 @@ +using W14 = DocumentFormat.OpenXml.Office2010.Word; + +/// +/// Reads one editable content control back into a typed value. Checkbox / date / dropdown +/// controls carry canonical values in their sdtPr (w14:checked, w:fullDate, +/// w:listItem/@w:value), so those never depend on parsing display text. Numerics, and the +/// text-backed temporal kinds (DateTimeOffset, TimeOnly, which have no canonical +/// control slot), parse the run text with the caller's culture — which must match the render +/// culture. +/// +static class EditableFieldReader +{ + public static ExtractedField Read(SdtElement sdt, EditableEntry entry, CultureInfo culture) + { + var raw = RawText(sdt); + if (entry.Kind == EditableFieldKind.Checkbox) + { + return ReadCheckbox(sdt, entry, raw); + } + + var isPlaceholder = sdt.SdtProperties?.GetFirstChild() != null; + return entry.Kind switch + { + EditableFieldKind.Text when isPlaceholder || raw.Length == 0 => + new(entry.DottedPath, FieldState.Empty, null, raw), + EditableFieldKind.Text => + new(entry.DottedPath, FieldState.Extracted, raw, raw), + + EditableFieldKind.Number when isPlaceholder || raw.Length == 0 => + new(entry.DottedPath, FieldState.Empty, null, raw), + EditableFieldKind.Number => + ReadNumber(entry, raw, culture), + + EditableFieldKind.Date when isPlaceholder => + new(entry.DottedPath, FieldState.Empty, null, raw), + EditableFieldKind.Date => + ReadDate(sdt, entry, raw, culture), + + EditableFieldKind.DateTimeOffset when isPlaceholder || raw.Length == 0 => + new(entry.DottedPath, FieldState.Empty, null, raw), + EditableFieldKind.DateTimeOffset => + ReadDateTimeOffset(entry, raw, culture), + + EditableFieldKind.Time when isPlaceholder || raw.Length == 0 => + new(entry.DottedPath, FieldState.Empty, null, raw), + EditableFieldKind.Time => + ReadTime(entry, raw, culture), + + EditableFieldKind.DropDown when isPlaceholder || raw.Length == 0 => + new(entry.DottedPath, FieldState.Empty, null, raw), + EditableFieldKind.DropDown => + ReadDropDown(sdt, entry, raw), + + _ => throw new InvalidOperationException($"Unknown editable kind {entry.Kind}") + }; + } + + public static string RawText(SdtElement sdt) + { + var content = sdt.ChildElements.FirstOrDefault(_ => _.LocalName == "sdtContent"); + if (content == null) + { + return string.Empty; + } + + var builder = new StringBuilder(); + foreach (var element in content.Descendants()) + { + if (element is Text text) + { + builder.Append(text.Text); + } + else if (element is Break) + { + builder.Append('\n'); + } + } + + return builder.ToString(); + } + + static ExtractedField ReadCheckbox(SdtElement sdt, EditableEntry entry, string raw) + { + var isChecked = sdt.SdtProperties + ?.GetFirstChild() + ?.GetFirstChild() + ?.Val; + if (isChecked is { HasValue: true }) + { + return new(entry.DottedPath, FieldState.Extracted, isChecked.Value == W14.OnOffValues.One, raw); + } + + // Fall back to the rendered glyph when w14:checked is absent. + return raw switch + { + "☒" => new(entry.DottedPath, FieldState.Extracted, true, raw), + "☐" => new(entry.DottedPath, FieldState.Extracted, false, raw), + _ => new(entry.DottedPath, FieldState.ParseFailed, null, raw) + }; + } + + static ExtractedField ReadNumber(EditableEntry entry, string raw, CultureInfo culture) + { + if (TryParseNumber(raw, entry.ClrType, culture, out var value)) + { + return new(entry.DottedPath, FieldState.Extracted, value, raw); + } + + return new(entry.DottedPath, FieldState.ParseFailed, null, raw); + } + + static bool TryParseNumber(string raw, Type type, CultureInfo culture, out object? value) + { + value = Type.GetTypeCode(type) switch + { + TypeCode.Byte when byte.TryParse(raw, NumberStyles.Integer, culture, out var b) => b, + TypeCode.SByte when sbyte.TryParse(raw, NumberStyles.Integer, culture, out var sb) => sb, + TypeCode.Int16 when short.TryParse(raw, NumberStyles.Integer, culture, out var s) => s, + TypeCode.UInt16 when ushort.TryParse(raw, NumberStyles.Integer, culture, out var us) => us, + TypeCode.Int32 when int.TryParse(raw, NumberStyles.Integer, culture, out var i) => i, + TypeCode.UInt32 when uint.TryParse(raw, NumberStyles.Integer, culture, out var ui) => ui, + TypeCode.Int64 when long.TryParse(raw, NumberStyles.Integer, culture, out var l) => l, + TypeCode.UInt64 when ulong.TryParse(raw, NumberStyles.Integer, culture, out var ul) => ul, + TypeCode.Single when float.TryParse(raw, NumberStyles.Float | NumberStyles.AllowThousands, culture, out var f) => f, + TypeCode.Double when double.TryParse(raw, NumberStyles.Float | NumberStyles.AllowThousands, culture, out var d) => d, + TypeCode.Decimal when decimal.TryParse(raw, NumberStyles.Number, culture, out var m) => m, + _ => null + }; + return value != null; + } + + static ExtractedField ReadDate(SdtElement sdt, EditableEntry entry, string raw, CultureInfo culture) + { + var fullDate = sdt.SdtProperties + ?.GetFirstChild() + ?.FullDate; + DateTime dateTime; + if (fullDate is { HasValue: true }) + { + dateTime = fullDate.Value; + } + else if (raw.Length == 0) + { + return new(entry.DottedPath, FieldState.Empty, null, raw); + } + else + { + // Word maintains w:fullDate for picker selections; typed-in text may leave it stale + // or absent, so parse the display text with the declared format. + var format = entry.DateFormat ?? "yyyy-MM-dd"; + if (!DateTime.TryParseExact(raw, format, culture, DateTimeStyles.None, out dateTime) && + !DateTime.TryParse(raw, culture, DateTimeStyles.None, out dateTime)) + { + return new(entry.DottedPath, FieldState.ParseFailed, null, raw); + } + } + + // Only DateOnly and DateTime map to the Date kind. DateTime comes back with Kind + // Unspecified (w:fullDate carries no zone) — documented; callers needing a specific Kind + // re-stamp it. + object value = entry.ClrType == typeof(Date) + ? Date.FromDateTime(dateTime) + : dateTime; + + return new(entry.DottedPath, FieldState.Extracted, value, raw); + } + + static ExtractedField ReadDateTimeOffset(EditableEntry entry, string raw, CultureInfo culture) + { + // The run text is the source of truth (w:fullDate cannot hold an offset). Parsing the + // offset-bearing ISO text preserves the original offset — DateTimeOffset.TryParse keeps + // whatever offset the string carries. + DateTimeOffset value; + var parsed = entry.DateFormat is { } format + ? DateTimeOffset.TryParseExact(raw, format, culture, DateTimeStyles.None, out value) + : DateTimeOffset.TryParse(raw, culture, DateTimeStyles.None, out value); + + return parsed + ? new(entry.DottedPath, FieldState.Extracted, value, raw) + : new(entry.DottedPath, FieldState.ParseFailed, null, raw); + } + + static ExtractedField ReadTime(EditableEntry entry, string raw, CultureInfo culture) + { + Time value; + var parsed = entry.DateFormat is { } format + ? Time.TryParseExact(raw, format, culture, DateTimeStyles.None, out value) + : Time.TryParse(raw, culture, DateTimeStyles.None, out value); + + return parsed + ? new(entry.DottedPath, FieldState.Extracted, value, raw) + : new(entry.DottedPath, FieldState.ParseFailed, null, raw); + } + + static ExtractedField ReadDropDown(SdtElement sdt, EditableEntry entry, string raw) + { + // Display text → w:listItem/@w:val, falling back to the text itself (our controls emit + // displayText == value == enum member name, but a hand-edited doc may differ). + var candidate = sdt.SdtProperties + ?.GetFirstChild() + ?.Elements() + .FirstOrDefault(_ => _.DisplayText?.Value == raw) + ?.Value?.Value ?? + raw; + + if (Enum.TryParse(entry.ClrType, candidate, ignoreCase: true, out var parsed) && + Enum.IsDefined(entry.ClrType, parsed!)) + { + return new(entry.DottedPath, FieldState.Extracted, parsed, raw); + } + + return new(entry.DottedPath, FieldState.ParseFailed, null, raw); + } +} diff --git a/src/Parchment/Extraction/ExtractResult.cs b/src/Parchment/Extraction/ExtractResult.cs new file mode 100644 index 0000000..15dc2e3 --- /dev/null +++ b/src/Parchment/Extraction/ExtractResult.cs @@ -0,0 +1,86 @@ +namespace Parchment; + +/// +/// The editable-field values read from one document by . +/// The document is a lossy projection of the model (loops expanded, formatting applied), so +/// extraction covers the [EditableField] subset only — merges that +/// subset onto a caller-supplied model instance. +/// +public sealed class ExtractResult +{ + EditableMap map; + + internal ExtractResult(IReadOnlyList fields, EditableMap map) + { + Fields = fields; + this.map = map; + AllExtracted = fields.All(_ => _.State is FieldState.Extracted or FieldState.Empty); + } + + /// + /// One entry per editable member of (plus a + /// entry per extra occurrence), in document order with + /// missing members appended. + /// + public IReadOnlyList Fields { get; } + + /// + /// True when every field read cleanly ( or + /// ) — no missing controls, parse failures, or duplicates. + /// + public bool AllExtracted { get; } + + /// + /// Writes the extracted values onto : + /// values are assigned; + /// assigns null to nullable members and leaves non-nullable + /// members untouched; all other states are skipped. Reachability is validated first — if any + /// applicable path has a null intermediate object, a + /// is thrown before anything is mutated. + /// + public void ApplyTo(TModel model) + { + ArgumentNullException.ThrowIfNull(model); + + List? unreachable = null; + var applicable = new List<(EditableEntry Entry, object? Value)>(); + foreach (var field in Fields) + { + if (!map.TryGet(field.Path, out var entry)) + { + continue; + } + + var apply = field.State switch + { + FieldState.Extracted => true, + FieldState.Empty when entry.IsNullable => true, + _ => false + }; + if (!apply) + { + continue; + } + + if (entry.CanReach(model)) + { + applicable.Add((entry, field.State == FieldState.Extracted ? field.Value : null)); + } + else + { + (unreachable ??= []).Add(entry.DottedPath); + } + } + + if (unreachable != null) + { + throw new ParchmentExtractionException( + $"Cannot apply extracted values to '{typeof(TModel).Name}': intermediate objects are null on path(s) {string.Join(", ", unreachable)}. Construct the target model with non-null intermediates first — nothing was applied."); + } + + foreach (var (entry, value) in applicable) + { + entry.Setter(model, value); + } + } +} diff --git a/src/Parchment/Extraction/ExtractedField.cs b/src/Parchment/Extraction/ExtractedField.cs new file mode 100644 index 0000000..ed8ff7e --- /dev/null +++ b/src/Parchment/Extraction/ExtractedField.cs @@ -0,0 +1,15 @@ +namespace Parchment; + +/// +/// One editable field read from a document. is the dotted model path +/// (the content control's tag). is typed as the model member +/// (string / bool / Date / enum / numeric) when is +/// , otherwise null. is the +/// control's visible text, kept for diagnostics — most useful on +/// . +/// +public sealed record ExtractedField( + string Path, + FieldState State, + object? Value, + string? RawText); diff --git a/src/Parchment/Extraction/FieldState.cs b/src/Parchment/Extraction/FieldState.cs new file mode 100644 index 0000000..e546f69 --- /dev/null +++ b/src/Parchment/Extraction/FieldState.cs @@ -0,0 +1,37 @@ +namespace Parchment; + +/// +/// Outcome of reading one editable field from a document. Only values +/// (and for nullable members) are written back by +/// ExtractResult<TModel>.ApplyTo; the other states surface problems for the caller +/// to inspect. +/// +public enum FieldState +{ + /// The control held a value that parsed as the member's type. + Extracted, + + /// + /// The control shows its placeholder (or was cleared). Applied as null to nullable members; + /// skipped for non-nullable members. + /// + Empty, + + /// + /// No content control with the member's tag exists in the document — the control was + /// deleted, or the document was not produced from a template bound to this model. + /// + Missing, + + /// + /// The control's content could not be parsed as the member's type (see + /// ExtractedField.RawText). Typically a culture mismatch or free-typed text in a + /// numeric / date field. + /// + ParseFailed, + + /// + /// A second control with an already-seen tag. The first occurrence won; this one is ignored. + /// + Duplicate +} diff --git a/src/Parchment/Extraction/ParchmentExtractor.cs b/src/Parchment/Extraction/ParchmentExtractor.cs new file mode 100644 index 0000000..08362a9 --- /dev/null +++ b/src/Parchment/Extraction/ParchmentExtractor.cs @@ -0,0 +1,108 @@ +namespace Parchment; + +/// +/// Reads [EditableField] values back out of a docx produced by a Parchment template — +/// the other half of two-way binding. Content controls are matched by their w:tag +/// (the dotted model path), so no or template registration is +/// required. Checkbox / date / dropdown controls are read from canonical control state; string +/// and numeric fields read display text, with numerics parsed using the culture argument — +/// which must match the render culture (Parchment renders with Fluid's default invariant +/// culture unless customized, so the default here is also ). +/// +public static class ParchmentExtractor +{ + // Extract is called per-document (often per-request); cache the reflection-built map per + // model type. The SG-precompiled cache inside EditableMap.Build makes this a double layer on + // the SG path, but it also covers the reflection fallback path, which Build alone does not. + static ConcurrentDictionary maps = new(); + + public static ExtractResult Extract(string path, CultureInfo? culture = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + using var file = File.OpenRead(path); + return Extract(file, culture); + } + + public static ExtractResult Extract(Stream docx, CultureInfo? culture = null) + { + ArgumentNullException.ThrowIfNull(docx); + + var map = maps.GetOrAdd(typeof(TModel), static type => EditableMap.Build(type, type.Name)); + if (map.IsEmpty) + { + throw new ParchmentExtractionException( + $"Model type '{typeof(TModel).Name}' declares no [EditableField] members — there is nothing to extract."); + } + + var effectiveCulture = culture ?? CultureInfo.InvariantCulture; + + // The package API needs a seekable stream; buffer non-seekable input. + var source = docx; + if (!docx.CanSeek) + { + source = new MemoryStream(); + docx.CopyTo(source); + source.Position = 0; + } + + try + { + using var doc = OpenReadOnly(source); + var body = doc.MainDocumentPart?.Document?.Body; + if (body == null) + { + throw new ParchmentExtractionException( + "The document has no main body — not a WordprocessingML document."); + } + + var fields = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var sdt in body.Descendants()) + { + var tag = sdt.SdtProperties?.GetFirstChild()?.Val?.Value; + if (tag == null || + !map.TryGet(tag, out var entry)) + { + continue; + } + + if (!seen.Add(entry.DottedPath)) + { + fields.Add(new(entry.DottedPath, FieldState.Duplicate, null, EditableFieldReader.RawText(sdt))); + continue; + } + + fields.Add(EditableFieldReader.Read(sdt, entry, effectiveCulture)); + } + + foreach (var entry in map.Entries) + { + if (!seen.Contains(entry.DottedPath)) + { + fields.Add(new(entry.DottedPath, FieldState.Missing, null, null)); + } + } + + return new(fields, map); + } + finally + { + if (!ReferenceEquals(source, docx)) + { + source.Dispose(); + } + } + } + + static WordprocessingDocument OpenReadOnly(Stream stream) + { + try + { + return WordprocessingDocument.Open(stream, false); + } + catch (Exception exception) when (exception is FileFormatException or InvalidDataException or OpenXmlPackageException) + { + throw new ParchmentExtractionException("The stream is not a valid docx package.", exception); + } + } +} diff --git a/src/Parchment/Generated/EditableFieldKind.cs b/src/Parchment/Generated/EditableFieldKind.cs new file mode 100644 index 0000000..7cf0792 --- /dev/null +++ b/src/Parchment/Generated/EditableFieldKind.cs @@ -0,0 +1,39 @@ +namespace Parchment.Generated; + +/// +/// The Word content-control flavour an [EditableField] member renders as. Public because +/// source-generator-emitted registration code references it; not intended for hand-written use. +/// +public enum EditableFieldKind +{ + /// Plain-text control (w:text) for string members. + Text, + + /// Plain-text control whose text is parsed numerically at extraction. + Number, + + /// Checkbox control (w14:checkbox) for bool members. + Checkbox, + + /// + /// Date-picker control (w:date) carrying a canonical w:fullDate. Used for + /// DateOnly and DateTime members. + /// + Date, + + /// + /// Plain-text control (w:text) holding a round-trippable ISO 8601 value for + /// DateTimeOffset members. Word has no offset-aware picker, and w:fullDate + /// cannot carry an offset, so the run text is the source of truth. + /// + DateTimeOffset, + + /// + /// Plain-text control (w:text) holding a time value for TimeOnly members. + /// Word has no time-only picker, so the run text is the source of truth. + /// + Time, + + /// Dropdown control (w:dropDownList) with one item per enum member. + DropDown +} diff --git a/src/Parchment/Generated/EditableFieldMapEntry.cs b/src/Parchment/Generated/EditableFieldMapEntry.cs new file mode 100644 index 0000000..27d876e --- /dev/null +++ b/src/Parchment/Generated/EditableFieldMapEntry.cs @@ -0,0 +1,20 @@ +namespace Parchment.Generated; + +/// +/// Pre-compiled editable-field registration data emitted by the source generator. Public data +/// carrier for GeneratedRegistration.RegisterEditable; not intended for hand-written use. +/// reads the value from the root model at render time; +/// writes an extracted value back; reports +/// whether every intermediate object on the path is non-null (so extraction can validate before +/// mutating). +/// +public sealed record EditableFieldMapEntry( + string DottedPath, + EditableFieldKind Kind, + Type ClrType, + bool IsNullable, + Func Getter, + Action Setter, + Func CanReach, + bool MultiLine, + string? DateFormat); diff --git a/src/Parchment/Generated/GeneratedRegistration.cs b/src/Parchment/Generated/GeneratedRegistration.cs index bf483ad..dd83601 100644 --- a/src/Parchment/Generated/GeneratedRegistration.cs +++ b/src/Parchment/Generated/GeneratedRegistration.cs @@ -4,11 +4,11 @@ namespace Parchment.Generated; /// Public entry points called from source-generator-emitted RegisterWith helpers. /// Pre-populates the runtime's per-type registration caches so the reflection-based /// / *Map.Build walks short-circuit when -/// runs. +/// runs. /// /// Not intended for hand-written consumption — call sites are emitted by the /// Parchment.ParchmentModelAttribute source generator. The runtime -/// path stays +/// path stays /// fully functional for callers that can't use the source generator (POCO models, dynamic /// template paths, etc.). /// @@ -33,4 +33,9 @@ public static void RegisterStringList( Type modelType, IEnumerable entries) => StringListMap.RegisterPrecompiled(modelType, entries); + + public static void RegisterEditable( + Type modelType, + IEnumerable entries) => + EditableMap.RegisterPrecompiled(modelType, entries); } \ No newline at end of file diff --git a/src/Parchment/RegisteredDocxTemplate.cs b/src/Parchment/RegisteredDocxTemplate.cs index 13a7e3f..71590d8 100644 --- a/src/Parchment/RegisteredDocxTemplate.cs +++ b/src/Parchment/RegisteredDocxTemplate.cs @@ -6,6 +6,7 @@ class RegisteredDocxTemplate( ExcelsiorTableMap excelsiorTables, FormatMap formats, StringListMap stringLists, + EditableMap editables, ImagePolicies imagePolicies) : RegisteredTemplate(name, modelType) { @@ -19,6 +20,7 @@ public override async Task Render(object model, Stream output, Cancel cancel) { var mainPart = doc.MainDocumentPart!; var numberingState = new WordNumberingState(mainPart); + var editableState = new EditableState(); // Cache the StyleSet per render — Excelsior / Format / OpenXml / Mutate tokens all // need it, but it changes only across registrations, not within a render. var styles = new Lazy(() => StyleSet.Read(mainPart)); @@ -26,7 +28,7 @@ public override async Task Render(object model, Stream output, Cancel cancel) foreach (var part in parts) { cancel.ThrowIfCancellationRequested(); - await RenderPartAsync(doc, mainPart, part, context, model, numberingState, styles); + await RenderPartAsync(doc, mainPart, part, context, model, numberingState, editableState, styles); } foreach (var (_, root) in DocxCloner.EnumerateParts(doc)) @@ -41,7 +43,7 @@ public override async Task Render(object model, Stream output, Cancel cancel) await stream.CopyToAsync(output, cancel); } - async Task RenderPartAsync(WordprocessingDocument doc, MainDocumentPart mainPart, PartScopeTree part, TemplateContext context, object model, WordNumberingState numberingState, Lazy styles) + async Task RenderPartAsync(WordprocessingDocument doc, MainDocumentPart mainPart, PartScopeTree part, TemplateContext context, object model, WordNumberingState numberingState, EditableState editableState, Lazy styles) { OpenXmlCompositeElement? root = null; foreach (var (uri, candidate) in DocxCloner.EnumerateParts(doc)) @@ -58,6 +60,12 @@ async Task RenderPartAsync(WordprocessingDocument doc, MainDocumentPart mainPart return; } + // Editable fields dispatch only in the document body. Word does not reliably honor + // editable-range exceptions in headers/footers/notes, so the same token there renders + // as plain read-only text — deliberate (e.g. an editable PO number in the body can be + // mirrored read-only in the footer). + var isBody = part.PartUri == mainPart.Uri.ToString(); + var map = Anchors.BuildMap(root); var runner = new ScopeTreeRunner( Name, @@ -69,6 +77,8 @@ async Task RenderPartAsync(WordprocessingDocument doc, MainDocumentPart mainPart excelsiorTables, formats, stringLists, + isBody ? editables : EditableMap.Empty, + editableState, numberingState, styles, imagePolicies); diff --git a/src/Parchment/TemplateStore.cs b/src/Parchment/TemplateStore.cs index 74462d6..7b693fe 100644 --- a/src/Parchment/TemplateStore.cs +++ b/src/Parchment/TemplateStore.cs @@ -20,14 +20,14 @@ public sealed class TemplateStore(ILogger? logger = null) ImagePolicies Policies => new(LocalImages, WebImages); - public void RegisterDocxTemplate(string name, string path) + public void RegisterDocxTemplate(string name, string path, ProtectionMode protection = ProtectionMode.WhenEditable) { ArgumentException.ThrowIfNullOrWhiteSpace(path); using var file = File.OpenRead(path); - RegisterDocxTemplate(name, file); + RegisterDocxTemplate(name, file, protection); } - public void RegisterDocxTemplate(string name, Stream template) + public void RegisterDocxTemplate(string name, Stream template, ProtectionMode protection = ProtectionMode.WhenEditable) { ArgumentException.ThrowIfNullOrWhiteSpace(name); @@ -37,11 +37,13 @@ public void RegisterDocxTemplate(string name, Stream template) var excelsiorMap = ExcelsiorTableMap.Build(typeof(TModel), name); var formatMap = FormatMap.Build(typeof(TModel), name); var stringListMap = StringListMap.Build(typeof(TModel)); + var editableMap = EditableMap.Build(typeof(TModel), name); using var stream = DocxCloner.ToWritableStream(template); IReadOnlyList parts; using (var doc = WordprocessingDocument.Open(stream, true)) { + var bodyUri = doc.MainDocumentPart?.Uri.ToString(); foreach (var (uri, root) in DocxCloner.EnumerateParts(doc)) { var classifications = TokenScanner.Scan(root, name, uri); @@ -55,6 +57,13 @@ public void RegisterDocxTemplate(string name, Stream template) validator.ValidateTree(tree); ExcelsiorTokenValidator.Validate(classifications, excelsiorMap, name, uri); FormatTokenValidator.Validate(classifications, formatMap, name, uri); + EditableTokenValidator.Validate(classifications, editableMap, excelsiorMap, formatMap, name, uri, isBody: uri == bodyUri); + } + + if (!editableMap.IsEmpty && + protection == ProtectionMode.WhenEditable) + { + SettingsProtection.Apply(doc.MainDocumentPart!); } doc.Save(); @@ -63,7 +72,7 @@ public void RegisterDocxTemplate(string name, Stream template) } var canonicalBytes = stream.ToArray(); - var registered = new RegisteredDocxTemplate(name, typeof(TModel), canonicalBytes, parts, excelsiorMap, formatMap, stringListMap, Policies); + var registered = new RegisteredDocxTemplate(name, typeof(TModel), canonicalBytes, parts, excelsiorMap, formatMap, stringListMap, editableMap, Policies); templates[name] = registered; logger.LogInformation("Registered docx template {Name} for {ModelType}", name, typeof(TModel).Name); } diff --git a/src/Parchment/Tokens/RangeNode/IfBranch.cs b/src/Parchment/Tokens/RangeNode/IfBranch.cs index 1daf377..ee8d49a 100644 --- a/src/Parchment/Tokens/RangeNode/IfBranch.cs +++ b/src/Parchment/Tokens/RangeNode/IfBranch.cs @@ -1,3 +1,10 @@ +/// +/// One conditional branch of an . is the +/// anchor of the branch's opening tag paragraph ({% if %} for the first branch, +/// {% elsif %} for the rest) — the render-time boundary used to keep the chosen branch's +/// content positionally. +/// sealed record IfBranch( Expression Condition, + string TagAnchorName, IReadOnlyList Body); diff --git a/src/Parchment/Tokens/RangeNode/IfNode.cs b/src/Parchment/Tokens/RangeNode/IfNode.cs index 8d1589b..fb232d4 100644 --- a/src/Parchment/Tokens/RangeNode/IfNode.cs +++ b/src/Parchment/Tokens/RangeNode/IfNode.cs @@ -1,6 +1,13 @@ +/// +/// is the anchor of the {% else %} tag paragraph, or +/// null when the conditional has no else branch. Distinct from ElseBody.Count == 0: an +/// else branch whose content is purely static paragraphs has an empty body (static paragraphs +/// carry no anchors and no tree nodes) but must still render its physical range. +/// sealed record IfNode( string OpenAnchorName, string CloseAnchorName, IReadOnlyList Branches, + string? ElseAnchorName, IReadOnlyList ElseBody) : RangeNode; diff --git a/src/Parchment/Tokens/ScopeTreeBuilder.cs b/src/Parchment/Tokens/ScopeTreeBuilder.cs index 183ea2e..9fd7815 100644 --- a/src/Parchment/Tokens/ScopeTreeBuilder.cs +++ b/src/Parchment/Tokens/ScopeTreeBuilder.cs @@ -122,23 +122,38 @@ static IfNode BuildIf( { var branches = new List(); var elseBody = new List(); + string? elseAnchorName = null; // First branch is the if itself var firstBody = BuildBlock(queue, BlockTagKind.EndIf, templateName, partUri); - branches.Add(new(opening.Block!.Condition!, firstBody)); + branches.Add(new(opening.Block!.Condition!, opening.AnchorName, firstBody)); // Collect elsif / else branches until endif while (queue.TryPeek(out var peek) && peek.Block?.Kind is BlockTagKind.ElsIf or BlockTagKind.Else) { var branchOpening = queue.Dequeue(); + + // Branch tags after {% else %} break the positional branch ranges the runner keeps — + // and are malformed liquid anyway (the markdown flow's full-template Fluid parse + // rejects them too). + if (elseAnchorName != null) + { + throw new ParchmentRegistrationException( + templateName, + $"Unexpected '{branchOpening.Block!.Source}' after '{{% else %}}'.", + partUri, + branchOpening.Block!.Source); + } + var branchBody = BuildBlock(queue, BlockTagKind.EndIf, templateName, partUri); if (branchOpening.Block!.Kind == BlockTagKind.ElsIf) { - branches.Add(new(branchOpening.Block!.Condition!, branchBody)); + branches.Add(new(branchOpening.Block!.Condition!, branchOpening.AnchorName, branchBody)); } else { + elseAnchorName = branchOpening.AnchorName; elseBody.AddRange(branchBody); } } @@ -158,6 +173,7 @@ static IfNode BuildIf( opening.AnchorName, closing.AnchorName, branches, + elseAnchorName, elseBody); } diff --git a/src/Parchment/Tokens/ScopeTreeRunner.cs b/src/Parchment/Tokens/ScopeTreeRunner.cs index f240bc8..f93144b 100644 --- a/src/Parchment/Tokens/ScopeTreeRunner.cs +++ b/src/Parchment/Tokens/ScopeTreeRunner.cs @@ -12,6 +12,8 @@ class ScopeTreeRunner( ExcelsiorTableMap excelsiorTables, FormatMap formats, StringListMap stringLists, + EditableMap editables, + EditableState editableState, WordNumberingState numberingState, Lazy styles, ImagePolicies imagePolicies) @@ -94,13 +96,39 @@ async Task ProcessSubstitutionAsync(SubstitutionNode node) List<(DocxTokenSite site, object value)>? soloStructuralTokens = null; var splitQueued = false; var tokenCount = node.Tokens.Count; + var hasEditableSibling = HasEditableToken(node); for (var i = tokenCount - 1; i >= 0; i--) { var token = node.Tokens[i]; var evaluated = await EvaluateTokenAsync(token, host, tokenCount); + if (evaluated is EditableToken editable) + { + EditableSplicer.Insert( + host, + token.Offset, + token.Length, + sitePr => EditableFieldBuilder.Build(editable.Entry, editable.Value, sitePr, editableState, context.CultureInfo)); + cachedText = null; + continue; + } + if (evaluated is MarkdownToken or HtmlToken or OpenXmlToken) { + if (hasEditableSibling) + { + // Structural splice/split clones paragraph halves, which would corrupt the + // editable field's perm-range markers. Statically-known cases are rejected + // at registration; this guards TokenValue-typed properties that only turn + // out structural at render time. + throw new ParchmentRenderException( + templateName, + $"Token '{token.Source}' produced structural content in a paragraph that also contains an editable field. Move one of them to its own paragraph.", + partUri, + Snippet(host, token), + token.Source); + } + if (tokenCount == 1 && token.Offset == 0 && token.Length == originalLength) { // Whole host paragraph is the token — queue for replacement after every @@ -225,6 +253,11 @@ ValueTask EvaluateTokenAsync(DocxTokenSite site, Paragraph host, int sib return new(formatted); } + if (TryResolveEditable(site) is { } editable) + { + return new(editable); + } + if (TryResolveStringList(site, host, siblingCount) is { } stringList) { return new(stringList); @@ -376,6 +409,43 @@ ParchmentRenderException WrapException(Exception exception, Paragraph host, Docx return TokenValueHelpers.BulletList(items.ToList()); } + EditableToken? TryResolveEditable(DocxTokenSite site) + { + if (editables.IsEmpty || + site.References.Count == 0) + { + return null; + } + + if (!editables.TryGet(site.References[0].Dotted, out var entry)) + { + return null; + } + + return new(entry, entry.Getter(rootModel)); + } + + bool HasEditableToken(SubstitutionNode node) + { + if (editables.IsEmpty || + node.Tokens.Count < 2) + { + // A solo structural token never conflicts with an editable sibling. + return false; + } + + foreach (var token in node.Tokens) + { + if (token.References.Count > 0 && + editables.TryGet(token.References[0].Dotted, out _)) + { + return true; + } + } + + return false; + } + TokenValue? TryResolveFormatted(DocxTokenSite site) { if (formats.IsEmpty || site.References.Count == 0) @@ -427,6 +497,9 @@ async Task ProcessLoopAsync(LoopNode loop) // by reference and re-populated per iteration, so the runner sees the fresh per-iteration // anchor map without needing to be re-allocated. ApplyStructural clears the runner's // structuralReplacements list at the end of each iteration. + // Loop bodies deliberately get an empty editable map: a root-pathed editable token + // inside {% for %} would stamp one content control per iteration, producing duplicate + // tags that break extraction. Loop-body editable tokens render as plain read-only text. var clonedRunner = new ScopeTreeRunner( templateName, partUri, @@ -437,6 +510,8 @@ async Task ProcessLoopAsync(LoopNode loop) excelsiorTables, formats, stringLists, + EditableMap.Empty, + editableState, numberingState, styles, imagePolicies); @@ -576,66 +651,75 @@ async Task ProcessIfAsync(IfNode ifNode) return; } - var parent = open.Parent; - if (parent == null) + if (open.Parent == null) { return; } IReadOnlyList? chosen = null; - foreach (var branch in ifNode.Branches) + var chosenIndex = -1; + for (var i = 0; i < ifNode.Branches.Count; i++) { - if (!await EvaluateConditionAsync(branch.Condition)) + if (!await EvaluateConditionAsync(ifNode.Branches[i].Condition)) { continue; } - chosen = branch.Body; + chosen = ifNode.Branches[i].Body; + chosenIndex = i; break; } + // Fall back to the else branch when it exists. Signalled by the tag anchor, not + // ElseBody.Count — a static-only else branch has an empty body (static paragraphs carry + // no tree nodes) but still owns a physical range that must render. if (chosen == null && - ifNode.ElseBody.Count > 0) + ifNode.ElseAnchorName != null) { chosen = ifNode.ElseBody; } - // Collect all branch paragraphs between open and close — everything that should be removed - var allBranchParagraphs = CaptureBetween(open, close); + // Everything physically between {% if %} and {% endif %}: branch tag paragraphs, token + // paragraphs, static paragraphs, tables. + var allBranchElements = CaptureBetween(open, close); if (chosen == null) { - foreach (var element in allBranchParagraphs) + foreach (var element in allBranchElements) { element.Remove(); } + + open.Remove(); + close.Remove(); + return; } - else + + // The chosen branch's content is kept positionally — everything strictly between the + // branch's tag paragraph and the next boundary tag ({% elsif %} / {% else %} / + // {% endif %}). An anchor-derived keep-set would drop static paragraphs and tables: + // neither carries an anchor or a scope-tree node. + var (start, end) = ChosenBranchBoundaries(ifNode, chosenIndex, open, close); + var keepElements = CaptureBetween(start, end); + + // Process the chosen branch in place (no cloning — branch elements are used once). + // Anchors are collected across nested content too, so tokens inside tables resolve. + var branchAnchors = new Dictionary(StringComparer.Ordinal); + foreach (var element in keepElements) { - // Process chosen branch in place (no cloning — branch paragraphs are used once) - var branchAnchors = new Dictionary(StringComparer.Ordinal); - foreach (var p in allBranchParagraphs.OfType()) - { - var start = p.Elements() - .FirstOrDefault(_ => _.Name?.Value?.StartsWith(Anchors.Prefix, StringComparison.Ordinal) == true); - if (start?.Name?.Value != null) - { - branchAnchors[start.Name.Value] = p; - } - } + CollectAnchors(element, branchAnchors); + } - var innerRunner = new ScopeTreeRunner(templateName, partUri, branchAnchors, context, mainPart, rootModel, excelsiorTables, formats, stringLists, numberingState, styles, imagePolicies); - await innerRunner.RunAsync(chosen); - innerRunner.ApplyStructural(); + var innerRunner = new ScopeTreeRunner(templateName, partUri, branchAnchors, context, mainPart, rootModel, excelsiorTables, formats, stringLists, editables, editableState, numberingState, styles, imagePolicies); + await innerRunner.RunAsync(chosen); + innerRunner.ApplyStructural(); - // Only keep paragraphs that belong to the chosen branch; remove others - var keep = new HashSet(CollectBranchParagraphs(chosen, branchAnchors)); - foreach (var element in allBranchParagraphs) + var keep = new HashSet(keepElements); + foreach (var element in allBranchElements) + { + if (!keep.Contains(element)) { - if (!keep.Contains(element)) - { - element.Remove(); - } + element.Remove(); } } @@ -643,64 +727,34 @@ async Task ProcessIfAsync(IfNode ifNode) close.Remove(); } - static IEnumerable CollectBranchParagraphs( - IReadOnlyList nodes, - Dictionary anchors) + /// + /// The paragraphs bounding the chosen branch's physical content: its own tag paragraph and + /// the next branch boundary (the following {% elsif %}, the {% else %}, or + /// {% endif %}). of -1 means the else branch. + /// + (Paragraph Start, Paragraph End) ChosenBranchBoundaries(IfNode ifNode, int chosenIndex, Paragraph open, Paragraph close) { - foreach (var node in nodes) + if (chosenIndex < 0) { - switch (node) - { - case SubstitutionNode s when anchors.TryGetValue(s.AnchorName, out var p): - yield return p; - break; - case StaticNode s when anchors.TryGetValue(s.AnchorName, out var p): - yield return p; - break; - case LoopNode l: - if (anchors.TryGetValue(l.OpenAnchorName, out var lo)) - { - yield return lo; - } - - foreach (var child in CollectBranchParagraphs(l.Body, anchors)) - { - yield return child; - } - - if (anchors.TryGetValue(l.CloseAnchorName, out var lc)) - { - yield return lc; - } - - break; - case IfNode ifn: - if (anchors.TryGetValue(ifn.OpenAnchorName, out var io)) - { - yield return io; - } - - foreach (var branch in ifn.Branches) - { - foreach (var child in CollectBranchParagraphs(branch.Body, anchors)) - { - yield return child; - } - } - - foreach (var child in CollectBranchParagraphs(ifn.ElseBody, anchors)) - { - yield return child; - } - - if (anchors.TryGetValue(ifn.CloseAnchorName, out var ic)) - { - yield return ic; - } - - break; - } + var elseStart = anchorMap.GetValueOrDefault(ifNode.ElseAnchorName!, open); + return (elseStart, close); } + + var start = anchorMap.GetValueOrDefault(ifNode.Branches[chosenIndex].TagAnchorName, open); + + if (chosenIndex + 1 < ifNode.Branches.Count && + anchorMap.TryGetValue(ifNode.Branches[chosenIndex + 1].TagAnchorName, out var nextBranchTag)) + { + return (start, nextBranchTag); + } + + if (ifNode.ElseAnchorName != null && + anchorMap.TryGetValue(ifNode.ElseAnchorName, out var elseTag)) + { + return (start, elseTag); + } + + return (start, close); } async Task EvaluateConditionAsync(Expression condition) => diff --git a/src/Parchment/Word/SettingsProtection.cs b/src/Parchment/Word/SettingsProtection.cs new file mode 100644 index 0000000..9712592 --- /dev/null +++ b/src/Parchment/Word/SettingsProtection.cs @@ -0,0 +1,76 @@ +/// +/// Writes <w:documentProtection w:edit="readOnly" w:enforcement="1"/> into the +/// template's settings part at registration time. Passwordless by design: protection is a +/// cooperative UI lock, not security — a password would add no real protection but would force +/// non-deterministic salt/hash generation into the output. +/// +/// CT_Settings is a strict xsd:sequence, so the element cannot simply be appended — it must land +/// after every element that precedes w:documentProtection in the schema and before +/// everything else. Word rejects (or "repairs") files that get this wrong. +/// +static class SettingsProtection +{ + // Every CT_Settings member that precedes w:documentProtection, in schema order + // (ECMA-376 §17.15.1.78). Insertion goes before the first child NOT in this set. + static readonly HashSet preceding = + [ + typeof(WriteProtection), + typeof(View), + typeof(Zoom), + typeof(RemovePersonalInformation), + typeof(RemoveDateAndTime), + typeof(DoNotDisplayPageBoundaries), + typeof(DisplayBackgroundShape), + typeof(PrintPostScriptOverText), + typeof(PrintFractionalCharacterWidth), + typeof(PrintFormsData), + typeof(EmbedTrueTypeFonts), + typeof(EmbedSystemFonts), + typeof(SaveSubsetFonts), + typeof(SaveFormsData), + typeof(MirrorMargins), + typeof(AlignBorderAndEdges), + typeof(BordersDoNotSurroundHeader), + typeof(BordersDoNotSurroundFooter), + typeof(GutterAtTop), + typeof(HideSpellingErrors), + typeof(HideGrammaticalErrors), + typeof(ActiveWritingStyle), + typeof(ProofState), + typeof(FormsDesign), + typeof(AttachedTemplate), + typeof(LinkStyles), + typeof(StylePaneFormatFilter), + typeof(StylePaneSortMethods), + typeof(DocumentType), + typeof(MailMerge), + typeof(RevisionView), + typeof(TrackRevisions), + typeof(DoNotTrackMoves), + typeof(DoNotTrackFormatting) + ]; + + public static void Apply(MainDocumentPart mainPart) + { + var part = mainPart.DocumentSettingsPart ?? mainPart.AddNewPart(); + var settings = part.Settings ??= new(); + + settings.RemoveAllChildren(); + + var protection = new DocumentProtection + { + Edit = DocumentProtectionValues.ReadOnly, + Enforcement = true + }; + + var before = settings.ChildElements.FirstOrDefault(_ => !preceding.Contains(_.GetType())); + if (before == null) + { + settings.AppendChild(protection); + } + else + { + settings.InsertBefore(protection, before); + } + } +}