Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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<T>` opens read-only, matches `body.Descendants<SdtElement>()` 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/`)

Expand Down Expand Up @@ -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<ContainerState>` 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`).
Expand Down
Loading
Loading