メインコンテンツへスキップ

ADR 0040 - Preservation matrix amendment: CF / DV range extension + outline level

  • Status: accepted (fully implemented; CF/DV sqref extension landed with fixture 171-cf-dv-range-extension, outline level with 172-outline-level-preservation)
  • Date: 2026-05-18
  • Spec target: XTL 0.1
  • Affects: ADR-0036 (template feature preservation matrix), evaluation.md § "Styles and Workbook Structure"

Context

ADR-0036 pinned nine common Excel features as preserve-verbatim (P) — conditional formatting, charts (D), named ranges, print area, freeze pane, sheet protection, data validation, cell comments, and image anchors. It deliberately deferred the preserve-and-extend (PE) case for all of them: when @repeat expands a data block from N rows to M rows (M > N), the template's CF rule, named range, data-validation range, etc., were kept as-authored and not auto-stretched to cover the expanded region. The rationale (ADR-0036 § "Why P, not PE") was cross-impl cost: each feature has its own range encoding, and porting nine extension behaviors is nine ports' worth of work.

Production usage since 0.4.x surfaces three items where P is inadequate and PE is cheap:

  1. Conditional formatting sqref range. Operators routinely author a CF rule covering the template's N data rows ("highlight when Amount < 0"). With P, the rule applies only to the first N output rows; rows N+1..M render without the rule. The fix the author wants is mechanical and unambiguous: stretch the sqref by the row delta.
  2. Data validation sqref range. Same shape — a dropdown or range constraint authored over the template's data block must cover the expanded block, not just its first N rows.
  3. Outline level (row.outlineLevel). Not in the ADR-0036 matrix at all. ExcelJS exposes this per-row, and the reference impl's cloneWorksheet / spliceRowsPreservingMerges paths were silently dropping it. Pinning it as P closes a preservation gap.

CF and DV extension is unambiguous when the template's authored range is contained within the @repeat block. The hard case (ranges that overlap part of the block) is rare enough to leave out of scope.

Per ADR-0034 Corollary 1, this is absorption-of-experience: name the cases, choose XTL's answer, pin it.

Considered Options

A. Promote CF and DV from P to PE (with outline level added as P). Adopted below. Costs one impl path per range encoding; pays back the common authoring case directly.

B. Leave CF and DV at P; require authors to write whole-column ranges ($A:$A). Status quo. Works, but pushes the burden to every author of every template — exactly the kind of foot-gun ADR-0036 § Consequences flagged as "candidate for 0.2+ if production users report it as the dominant authoring pain point". Production users have reported it.

C. PE for every P item in ADR-0036 (named ranges, print area, …). Over-broad. Named ranges and print area are workbook-scope defined names whose extension semantics are tangled with formula references and Stage 2 OOXML positioning. Out of scope for this amendment; revisit case by case.

Decision

Adopt A. ADR-0036's matrix is amended as follows:

#FeaturePreviousNow
2Conditional formattingPPE (rules: P; sqref ranges: extended per rule below)
8Data validationPPE (rules: P; sqref ranges: extended per rule below)
10Row outline level (row.outlineLevel)(not listed)P

All other rows of ADR-0036's matrix are unchanged.

Extension rule for CF and DV sqref ranges (normative)

When @repeat expands a data block from N template rows to M output rows (delta = M - N, M ≥ N):

  1. For each sqref range R on the sheet:
    1. R MUST be extended if and only if R's start row is at or above the @repeat block's first row AND R's end row is at or below the @repeat block's last row. Equivalently: the range is fully contained in the block's row span (column span is irrelevant — only rows extend).
    2. Otherwise R MUST be left unchanged. In particular, ranges that overlap only part of the block are not extended: the rule cannot infer the author's intent in that case (was the partial overlap deliberate? was it a typo?). Authors who need partial-overlap behavior MUST use whole-column references or restructure the template.
  2. The extension applied is: end-row += delta. Start row, start column, and end column are unchanged.
  3. Whole-column references ($A:$A, 1048576-style end rows) are not modified — they already cover the expanded region.
  4. Multi-range sqref values (e.g., "A2:A5 C2:C5") are processed per sub-range. Sub-ranges that match the containment rule are extended; others are left unchanged.

Outline level (normative)

The reference impl's cloneWorksheet and spliceRowsPreservingMerges paths MUST copy outlineLevel on every row written to the output sheet. This includes:

  • Header rows above the @repeat block.
  • The first cloned row of the @repeat block (carries the template row's outline level).
  • Every subsequent row produced by @repeat (each takes the template row's outline level; outline level is replicated, not extended).
  • Trailer rows below the block.

There is no "extension" semantics for outline level — the value is per-row, not per-range. The PE concept does not apply.

Composition with multi-output directives

  • File-per-group / sheet-per-group splits (@file / @sheet). Each output workbook/sheet gets its own copy of the template's CF and DV rules with the extension applied per that output's row count. The extension is computed per output, not once for the union of all outputs.
  • Multi-block templates (two independent @repeat blocks on one sheet). The extension is per-block: a CF range contained in block 1 extends by block 1's delta; a range contained in block 2 extends by block 2's delta. A range spanning across multiple blocks is out of scope — the engine emits a warning (no error code added; see § "Consequences") and leaves the range as-authored. This case is rare; authors who need it should split into per-block ranges.

Error catalog

No new error codes. Failure to apply the extension (because the range doesn't satisfy the containment rule, or because the output library does not surface sqref for editing) is a silent no-op — the workbook still renders correctly, just without the auto-extension. A warning MAY be emitted via the implementation's existing warning channel; this is not normative.

Consequences

  • Conformance fixture 171-cf-dv-range-extension pins the containment rule across:

    • CF range fully inside the @repeat block (extended).
    • CF range partially overlapping (not extended).
    • CF range entirely above the block (not extended).
    • CF range with multi-range sqref (per-sub-range behavior).
    • Whole-column reference (unchanged).
    • DV authored on the block row (replicated across the expanded rows, which the writer coalesces back into one sqref).
  • Conformance fixture 172-outline-level-preservation pins per-row outlineLevel survival through @repeat.

    Numbering note. This section previously named fixtures 125 and 126. Neither was ever written, and both numbers were later taken by unrelated fixtures (125-hyperlink-function, 126-date-arithmetic-functions), so the citation pointed at real fixtures that verify nothing about this ADR. Same defect as the one corrected in ADR-0036 (xl3#108). The names above are the fixtures that exist.

    Writing 172 is also what surfaced the gap in the outline-level half. It was recorded as shipped in 0.6.0, and the cloneWorksheet / spliceRowsPreservingMerges copies named below were indeed in place — but the path that writes @repeat-expanded rows copied row height and cell styles without the level, so produced rows kept whatever the splice left behind. The normative sentence "each takes the template row's outline level" was unimplemented for the exact case it is about. A claimed-but-absent fixture is how that survived two minor releases.

  • The reference impl gains two code paths:

    1. A post-expansion sweep over the sheet's CF and DV rule collections that rewrites contained sqref ranges.
    2. An outlineLevel copy on every row write — in cloneWorksheet, in spliceRowsPreservingMerges, and in the @repeat expansion write loops (both the plain and the grouped path). The level is assigned unconditionally rather than only when truthy, so a level-0 template row clears a residue the splice left on a reused row object.
  • Ports based on libraries other than ExcelJS MUST replicate both paths. Range encoding varies: ports SHOULD reuse their library's sqref parser/serializer rather than rolling their own.

  • ADR-0036's matrix is now amended, not superseded. ADR-0036 remains the authoritative document for the unchanged seven rows; this ADR is the authoritative document for rows 2, 8, and 10.

  • The remaining P rows of ADR-0036 (images, named ranges, print area, freeze pane, sheet protection, cell comments) stay P. Any future promotion to PE will require its own ADR with the same shape as this one.

References

  • ADR-0034 — Relationship to prior-art template engines (Corollary 1: absorb experience)
  • ADR-0036 — Template feature preservation matrix (the ADR this amends; § "Why P, not PE" called this case out as a candidate for 0.2+)
  • ADR-0035 — Data-row merged cells (recent absorption following ADR-0034 Corollary 1)
  • docs/internal/jxls-absorption-plan.md (Category A items A3, A9 + outline level promotion)
  • evaluation.md § "Styles and Workbook Structure"
  • src/excel-document.ts (cloneWorksheet, spliceRowsPreservingMerges — the paths that copy per-row metadata)